Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -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’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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
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’t request this, ignore this email. Your password won’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’t, you’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 });
|
||||
}
|
||||
@@ -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' },
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user