Sessions 5-7a: 955 tests, deployment ready

This commit is contained in:
Kev
2026-06-08 18:35:13 -04:00
parent 06b82624a2
commit 1fa04dc776
371 changed files with 49366 additions and 955 deletions
+248
View File
@@ -0,0 +1,248 @@
/**
* Transactional email via Resend.
*
* Three flows for launch:
* - sendWelcomeEmail() — on signup
* - sendPaymentReceipt() — on successful NexaPay webhook
* - sendRenewalReminder() — daily cron when subscription_end < 3 days out
*
* All functions return { ok: boolean, id?: string, error?: string } and
* never throw — email is best-effort and must not break the auth or
* payment flow if Resend is unreachable.
*/
const RESEND_API = 'https://api.resend.com/emails';
const FROM_DEFAULT = 'VYNDR <grades@vyndr.app>';
interface SendResult {
ok: boolean;
id?: string;
error?: string;
}
async function send(payload: { to: string; subject: string; html: string; text: string }): Promise<SendResult> {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
if (process.env.NODE_ENV !== 'production') {
console.warn('[email] RESEND_API_KEY not set, skipping send to', payload.to);
}
return { ok: false, error: 'RESEND_API_KEY missing' };
}
try {
const res = await fetch(RESEND_API, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: process.env.RESEND_FROM_EMAIL || FROM_DEFAULT,
to: [payload.to],
subject: payload.subject,
html: payload.html,
text: payload.text,
}),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
return { ok: false, error: `${res.status} ${body.slice(0, 200)}` };
}
const data = await res.json().catch(() => ({}));
return { ok: true, id: (data as { id?: string }).id };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'unknown' };
}
}
const TEMPLATE_FOOTER = `\n\n— VYNDR\nBuilt in Detroit.\n\nNot a sportsbook. Gamble responsibly. 1-800-522-4700.\n`;
const TEMPLATE_HTML_WRAP = (body: string) => `
<!doctype html>
<html><body style="margin:0;padding:32px 16px;background:#0A0A0F;color:#F0F0F5;font-family:'Instrument Sans',-apple-system,system-ui,sans-serif;line-height:1.6">
<div style="max-width:560px;margin:0 auto;background:#12121A;border:1px solid #2A2A3A;border-radius:16px;padding:32px">
<h1 style="font-size:22px;font-weight:800;letter-spacing:0.10em;margin:0 0 24px;color:#E8E8F0;font-family:'IBM Plex Mono','JetBrains Mono',monospace">
VYND<span style="color:#00D4A0;text-shadow:0 0 12px rgba(0,212,160,0.6)">R</span>
</h1>
${body}
<hr style="border:none;border-top:1px solid #2A2A3A;margin:32px 0 16px" />
<p style="font-size:11px;color:#5A5A6A;margin:0;font-family:'JetBrains Mono',monospace">
Built in Detroit. Not a sportsbook. Gamble responsibly. 1-800-522-4700.
</p>
</div>
</body></html>`;
export async function sendWelcomeEmail(email: string): Promise<SendResult> {
const subject = "You're in. Let's grade some props.";
const body = `
<p style="font-size:16px">Welcome to VYNDR.</p>
<p>You have <strong>5 free reads every month</strong>. Pick a game, read a prop, and see what the model thinks.</p>
<p>The books have every advantage. Now you have one too.</p>
<p style="margin-top:24px"><a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'}/dashboard"
style="display:inline-block;padding:12px 24px;background:#1A4A3A;color:#F0F0F5;text-decoration:none;border-radius:12px;font-weight:600">
Open the slate →
</a></p>
`;
const text =
`Welcome to VYNDR.
You have 5 free reads every month. Pick a game, read a prop, and see what the model thinks.
The books have every advantage. Now you have one too.
Open the slate: ${process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'}/dashboard
${TEMPLATE_FOOTER}`;
return send({ to: email, subject, html: TEMPLATE_HTML_WRAP(body), text });
}
export async function sendPaymentReceipt(
email: string,
opts: { tier: 'analyst' | 'desk'; amount: string; renewsAt: string },
): Promise<SendResult> {
const tierLabel = opts.tier === 'desk' ? 'Desk' : 'Analyst';
const subject = `Receipt — VYNDR ${tierLabel} Access`;
const body = `
<p style="font-size:16px">Payment received. You&rsquo;re in.</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0">
<tr><td style="padding:6px 0;color:#8A8A9A;font-size:13px">Tier</td>
<td style="padding:6px 0;text-align:right;font-family:'JetBrains Mono',monospace">${tierLabel}</td></tr>
<tr><td style="padding:6px 0;color:#8A8A9A;font-size:13px">Amount</td>
<td style="padding:6px 0;text-align:right;font-family:'JetBrains Mono',monospace">${opts.amount}</td></tr>
<tr><td style="padding:6px 0;color:#8A8A9A;font-size:13px">Renews</td>
<td style="padding:6px 0;text-align:right;font-family:'JetBrains Mono',monospace">${opts.renewsAt}</td></tr>
</table>
<p>Full intelligence unlocked. Go read something.</p>
<p><a href="${process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'}/scan"
style="display:inline-block;padding:12px 24px;background:#1A4A3A;color:#F0F0F5;text-decoration:none;border-radius:12px;font-weight:600">
Start reading →
</a></p>
`;
const text =
`Payment received. You're in.
Tier: ${tierLabel}
Amount: ${opts.amount}
Renews: ${opts.renewsAt}
Full intelligence unlocked. Go read something.
${TEMPLATE_FOOTER}`;
return send({ to: email, subject, html: TEMPLATE_HTML_WRAP(body), text });
}
// Tiny HTML escape so any caller-supplied value can't inject markup.
const esc = (s: string | number) =>
String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
export async function sendPasswordResetEmail(email: string, resetLink: string): Promise<SendResult> {
const subject = 'Reset your VYNDR password';
const safeLink = esc(resetLink);
const body = `
<p style="font-size:16px">Reset your password.</p>
<p>Click the link below to set a new password. This link expires in 1 hour and can only be used once.</p>
<p style="margin-top:24px">
<a href="${safeLink}"
style="display:inline-block;padding:12px 24px;background:#1A5A42;color:#E8FFF4;text-decoration:none;border-radius:12px;font-weight:600">
Reset Password →
</a>
</p>
<p style="margin-top:16px;color:#7A7A8E;font-size:13px">
If you didn&rsquo;t request this, ignore this email. Your password won&rsquo;t change.
</p>
`;
const text =
`Reset your VYNDR password.
Click here: ${resetLink}
This link expires in 1 hour and can only be used once. If you didn't request this, ignore this email.
${TEMPLATE_FOOTER}`;
return send({ to: email, subject, html: TEMPLATE_HTML_WRAP(body), text });
}
export async function sendQuotaReminderEmail(email: string): Promise<SendResult> {
const subject = 'You used all 5 reads this month. Good taste.';
const checkoutUrl = `${process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'}/api/checkout?tier=analyst`;
const body = `
<p style="font-size:16px">You used all 5 reads this month.</p>
<p>Next month you get 5 more — or unlock unlimited right now.</p>
<p style="margin-top:16px">
<span style="font-family:'IBM Plex Mono','JetBrains Mono',monospace;font-size:28px;font-weight:800;color:#00D4A0;text-shadow:0 0 12px rgba(0,212,160,0.6)">$14.99</span>
<span style="color:#7A7A8E;font-size:14px">/mo · Locked for life</span>
</p>
<p style="color:#FFB347;font-size:13px;font-weight:600">This rate disappears June 15.</p>
<p style="margin-top:20px">
<a href="${esc(checkoutUrl)}"
style="display:inline-block;padding:12px 24px;background:#1A5A42;color:#E8FFF4;text-decoration:none;border-radius:12px;font-weight:600">
Unlock Unlimited Reads →
</a>
</p>
`;
const text =
`You used all 5 reads this month.
Next month you get 5 more — or unlock unlimited for $14.99/mo.
This rate disappears June 15.
${checkoutUrl}
${TEMPLATE_FOOTER}`;
return send({ to: email, subject, html: TEMPLATE_HTML_WRAP(body), text });
}
export async function sendCancellationEmail(
email: string,
opts: { accessUntil: string; iqScore?: number; record?: string },
): Promise<SendResult> {
const subject = "We're sorry to see you go.";
const pricingUrl = `${process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'}/pricing`;
const statsLine = opts.iqScore != null
? `<p style="color:#7A7A8E;font-size:14px">VYNDR IQ: ${esc(opts.iqScore)}. Record: ${esc(opts.record ?? 'N/A')}. You were on a good run.</p>`
: '';
const body = `
<p style="font-size:16px">Your subscription has been cancelled.</p>
<p>Your access continues until <strong>${esc(opts.accessUntil)}</strong>. Your Ledger and grade history are still here if you come back.</p>
${statsLine}
<p style="margin-top:20px">
<a href="${esc(pricingUrl)}"
style="display:inline-block;padding:12px 24px;background:#1A5A42;color:#E8FFF4;text-decoration:none;border-radius:12px;font-weight:600">
Come Back Anytime →
</a>
</p>
`;
const text =
`Your VYNDR subscription has been cancelled.
Access continues until ${opts.accessUntil}.
${opts.iqScore != null ? `VYNDR IQ: ${opts.iqScore}. Record: ${opts.record ?? 'N/A'}.\n` : ''}Come back anytime: ${pricingUrl}
${TEMPLATE_FOOTER}`;
return send({ to: email, subject, html: TEMPLATE_HTML_WRAP(body), text });
}
export async function sendRenewalReminder(
email: string,
opts: { daysLeft: number; renewalLink: string; tier: string },
): Promise<SendResult> {
const subject = `Your VYNDR access expires in ${opts.daysLeft} day${opts.daysLeft === 1 ? '' : 's'}`;
const body = `
<p style="font-size:16px">Your <strong>${opts.tier}</strong> access expires in ${opts.daysLeft} day${opts.daysLeft === 1 ? '' : 's'}.</p>
<p>If you renew, you keep the same pricing and zero interruption to your reads. If you don&rsquo;t, you&rsquo;ll drop back to 5 reads/month with the analysis blurred.</p>
<p style="margin-top:24px"><a href="${opts.renewalLink}"
style="display:inline-block;padding:12px 24px;background:#1A4A3A;color:#F0F0F5;text-decoration:none;border-radius:12px;font-weight:600">
Renew now →
</a></p>
`;
const text =
`Your ${opts.tier} access expires in ${opts.daysLeft} day${opts.daysLeft === 1 ? '' : 's'}.
Renew now: ${opts.renewalLink}
${TEMPLATE_FOOTER}`;
return send({ to: email, subject, html: TEMPLATE_HTML_WRAP(body), text });
}
+144
View File
@@ -0,0 +1,144 @@
import crypto from 'crypto';
/**
* NexaPay payment processor wrapper.
*
* NexaPay accepts cards (Visa/Mastercard/Apple Pay/Google Pay) on the customer
* side and settles to VYNDR in stablecoin (USDC/USDT). The customer never
* sees crypto.
*
* Required env vars (set on the deployment, never commit):
* NEXAPAY_API_KEY — bearer token used for outbound API calls
* NEXAPAY_WEBHOOK_SECRET — HMAC secret for verifying inbound webhooks
* NEXAPAY_API_URL — defaults to https://api.nexapay.one/v1
* NEXT_PUBLIC_SITE_URL — used to construct redirect + webhook URLs
*/
const API_URL = process.env.NEXAPAY_API_URL || 'https://api.nexapay.one/v1';
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
export type NexaPayTier = 'analyst' | 'desk';
export interface CreatePaymentLinkParams {
userId: string;
tier: NexaPayTier;
amount: number; // dollars, e.g. 14.99
description: string;
founderPricing?: boolean;
}
export interface NexaPayPaymentLink {
id: string;
url: string;
expires_at: string;
}
export interface NexaPayWebhookEvent {
id: string;
type: 'payment.succeeded' | 'payment.failed' | 'payment.refunded' | 'subscription.canceled';
created: number;
data: {
payment_id: string;
customer_id?: string;
amount: number;
currency: string;
metadata: Record<string, string>;
settled_amount?: number;
settled_currency?: string;
};
}
function requireApiKey(): string {
const key = process.env.NEXAPAY_API_KEY;
if (!key) {
throw new Error('NEXAPAY_API_KEY is not set');
}
return key;
}
export async function createPaymentLink(params: CreatePaymentLinkParams): Promise<NexaPayPaymentLink> {
const apiKey = requireApiKey();
const body = {
amount: Math.round(params.amount * 100),
currency: 'USD',
description: params.description,
redirect_url: `${SITE_URL}/scan?upgraded=true`,
cancel_url: `${SITE_URL}/?canceled=true#pricing`,
webhook_url: `${SITE_URL}/api/webhook/nexapay`,
customer_reference: params.userId,
metadata: {
userId: params.userId,
tier: params.tier,
type: 'subscription',
founderPricing: String(params.founderPricing ?? false),
},
};
const res = await fetch(`${API_URL}/payment-links`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const errBody = await res.text().catch(() => '');
throw new Error(`NexaPay create payment link failed (${res.status}): ${errBody}`);
}
return (await res.json()) as NexaPayPaymentLink;
}
export async function getTransaction(paymentId: string) {
const apiKey = requireApiKey();
const res = await fetch(`${API_URL}/payments/${paymentId}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) {
throw new Error(`NexaPay get transaction failed (${res.status})`);
}
return res.json();
}
/**
* Verify a NexaPay webhook signature.
* NexaPay sends `x-nexapay-signature: t=<unix>, v1=<hex>` where v1 is
* HMAC-SHA256(secret, `${t}.${rawBody}`).
*/
export function verifyWebhookSignature(rawBody: string, signatureHeader: string | null): boolean {
const secret = process.env.NEXAPAY_WEBHOOK_SECRET;
if (!secret || !signatureHeader) return false;
const parts = signatureHeader.split(',').reduce<Record<string, string>>((acc, part) => {
const [k, v] = part.trim().split('=');
if (k && v) acc[k] = v;
return acc;
}, {});
const timestamp = parts['t'];
const expected = parts['v1'];
if (!timestamp || !expected) return false;
// 5-minute replay window
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(ageSeconds) || ageSeconds > 300) return false;
const computed = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
try {
return crypto.timingSafeEqual(Buffer.from(computed, 'hex'), Buffer.from(expected, 'hex'));
} catch {
return false;
}
}
export const TIER_PRICING: Record<NexaPayTier, { regular: number; founder: number; label: string }> = {
analyst: { regular: 24.99, founder: 14.99, label: 'VYNDR Analyst — Monthly' },
desk: { regular: 49.99, founder: 44.99, label: 'VYNDR Desk — Monthly' },
};
+131
View File
@@ -0,0 +1,131 @@
/**
* Supabase-backed cache wrapper around upstream Odds API and backend
* grading-engine calls. Keeps user-facing requests off the rate-limited
* upstream API (500 req/mo on free tier) by serving from a 5-minute TTL
* cache row in `odds_cache`.
*
* Cache key pattern: `{sport}:{data_type}:{date?}`
* e.g. `nba:games:2026-05-18`, `mlb:props:2026-05-18`, `wnba:games:today`
*
* Failure mode: if Supabase is unreachable, we still call the loader so
* a fresh response is returned. If both Supabase AND the loader fail,
* the caller gets the stale cache row (if any) or the loader's thrown
* error.
*/
import { getServiceRoleSupabase } from '@/lib/supabase';
interface CacheEntry<T> {
payload: T;
fetched_at: string;
expires_at: string;
}
const DEFAULT_TTL_SECONDS = 300; // 5 min
export interface CachedFetchOptions<T> {
/**
* Unique cache key. Reuse it across calls that want the same data.
*/
key: string;
sport: string;
dataType: string;
/** How long the row stays fresh. Defaults to 300s. */
ttlSeconds?: number;
/** Loader called on a miss. Must return a value or throw. */
loader: () => Promise<T>;
/**
* If true, returns the cached row even after it has expired when
* the loader throws. Defaults to true.
*/
fallbackToStale?: boolean;
}
export async function cachedFetch<T>(opts: CachedFetchOptions<T>): Promise<T> {
const sb = getServiceRoleSupabase();
const now = new Date();
const ttl = opts.ttlSeconds ?? DEFAULT_TTL_SECONDS;
// 1. Try the cache.
let cached: CacheEntry<T> | null = null;
if (sb) {
try {
const { data } = await sb
.from('odds_cache')
.select('payload, fetched_at, expires_at')
.eq('cache_key', opts.key)
.maybeSingle();
if (data) cached = data as CacheEntry<T>;
} catch {
/* fall through to loader */
}
}
if (cached && new Date(cached.expires_at) > now) {
return cached.payload;
}
// 2. Refresh via the loader.
try {
const fresh = await opts.loader();
if (sb) {
const expires = new Date(now.getTime() + ttl * 1000).toISOString();
// upsert is racy but the conflict is harmless — last writer wins.
await sb
.from('odds_cache')
.upsert(
{
cache_key: opts.key,
sport: opts.sport,
data_type: opts.dataType,
payload: fresh as unknown as object,
fetched_at: now.toISOString(),
expires_at: expires,
},
{ onConflict: 'cache_key' },
);
}
return fresh;
} catch (err) {
if (opts.fallbackToStale !== false && cached) return cached.payload;
throw err;
}
}
/**
* Wrap a `fetch` against the BACKEND_URL with the cache. Useful for
* routes that pass-through to the Express grading engine but want to
* absorb its load spikes.
*/
export async function cachedBackendJson<T>(
key: string,
sport: string,
dataType: string,
backendPath: string,
ttlSeconds = DEFAULT_TTL_SECONDS,
): Promise<T> {
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000';
return cachedFetch<T>({
key,
sport,
dataType,
ttlSeconds,
loader: async () => {
const res = await fetch(`${BACKEND_URL}${backendPath}`, {
headers: { Accept: 'application/json' },
// Force fresh from backend so we control the TTL ourselves.
cache: 'no-store',
});
if (!res.ok) throw new Error(`backend ${backendPath} returned ${res.status}`);
return (await res.json()) as T;
},
});
}
/**
* Helper for daily-keyed caches.
*/
export function todayKey(sport: string, dataType: string): string {
const d = new Date().toISOString().slice(0, 10);
return `${sport.toLowerCase()}:${dataType}:${d}`;
}