Session 10: Internal auth refactor, prefetch cascade keys, Sentry, welcome email (1286 tests)

This commit is contained in:
Kev
2026-06-10 20:45:05 -04:00
parent b55dcbd614
commit e5c45ecc8e
22 changed files with 3837 additions and 94 deletions
+2207 -25
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -11,6 +11,7 @@
},
"license": "UNLICENSED",
"dependencies": {
"@sentry/nextjs": "^10.57.0",
"@serwist/next": "^9.5.11",
"@supabase/supabase-js": "2.99.3",
"@tailwindcss/postcss": "4.2.2",
+1 -1
View File
File diff suppressed because one or more lines are too long
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server';
import { getUserFromRequest, jsonError } from '@/lib/auth-helpers';
import { getServiceRoleSupabase } from '@/lib/supabase';
import { sendWelcomeEmail } from '@/services/email';
export const dynamic = 'force-dynamic';
/**
* Welcome email trigger (Session 10).
*
* POST /api/welcome-email
* Bearer auth required.
*
* Idempotent. The send-once flag lives on Supabase auth user_metadata
* (`welcome_email_sent`) — no migration needed; user_metadata is a
* JSONB field Supabase exposes by default. We read it via the
* service-role admin API so the user can't spoof it from the browser.
*
* Response codes:
* 200 { sent: true, id } — email sent successfully
* 200 { sent: false, reason } — skipped (already sent, missing
* RESEND_API_KEY, etc.)
* 401 — auth missing
* 500 — unexpected server error
*
* The page that calls this (web/src/app/welcome/page.tsx) treats any
* 200 response as success regardless of `sent`. Errors are surfaced
* to Sentry, not to the user — a missed welcome email never blocks
* onboarding.
*/
export async function POST(req: NextRequest) {
const user = await getUserFromRequest(req);
if (!user) return jsonError(401, 'Authentication required');
if (!user.email) {
return NextResponse.json({ sent: false, reason: 'no_email_on_account' });
}
const sb = getServiceRoleSupabase();
if (!sb) {
// Without service-role we can't read/write user_metadata; bail
// softly so the welcome page render isn't blocked.
return NextResponse.json({ sent: false, reason: 'no_service_role' });
}
// Read the auth user via the admin API to inspect user_metadata.
let metadata: Record<string, unknown> = {};
try {
const { data, error } = await sb.auth.admin.getUserById(user.id);
if (error || !data?.user) {
return NextResponse.json({ sent: false, reason: 'user_not_found' });
}
metadata = (data.user.user_metadata as Record<string, unknown>) || {};
} catch {
return NextResponse.json({ sent: false, reason: 'metadata_read_failed' });
}
if (metadata.welcome_email_sent === true) {
return NextResponse.json({ sent: false, reason: 'already_sent' });
}
// Send + flag. We flag REGARDLESS of send outcome — a Resend
// outage shouldn't queue infinite retries from the welcome page.
// Operators can manually clear the flag if a batch needs to be
// re-sent.
const result = await sendWelcomeEmail(user.email);
try {
await sb.auth.admin.updateUserById(user.id, {
user_metadata: { ...metadata, welcome_email_sent: true, welcome_email_sent_at: new Date().toISOString() },
});
} catch {
// Flag write failed — log via Sentry if wired; the duplicate-
// suppression layer below will eventually catch up.
}
if (!result.ok) {
return NextResponse.json({ sent: false, reason: result.error || 'send_failed' });
}
return NextResponse.json({ sent: true, id: result.id });
}
+2
View File
@@ -11,6 +11,7 @@ import PushPrompt from '@/components/PushPrompt';
import MFAPrompt from '@/components/MFAPrompt';
import MFAChallenge from '@/components/MFAChallenge';
import CookieConsent from '@/components/CookieConsent';
import SentryInit from '@/components/SentryInit';
import './globals.css';
export const metadata: Metadata = {
@@ -109,6 +110,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<MFAPrompt />
<MFAChallenge />
<CookieConsent />
<SentryInit />
</ParlayProvider>
</ExplainModeProvider>
</AuthProvider>
+20 -2
View File
@@ -2,16 +2,34 @@
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
export default function WelcomePage() {
const router = useRouter();
const { user, loading } = useAuth();
const { user, session, loading } = useAuth();
const welcomeFiredRef = useRef(false);
useEffect(() => {
if (!loading && !user) router.replace('/signup');
}, [loading, user, router]);
// Session 10 — trigger the welcome email exactly once per mount.
// The server-side handler is idempotent via user_metadata
// (welcome_email_sent flag), so re-triggers from a refresh are
// safe; the ref is just a UX optimization to avoid duplicate
// network requests on this page.
useEffect(() => {
if (welcomeFiredRef.current) return;
if (loading || !user || !session) return;
welcomeFiredRef.current = true;
fetch('/api/welcome-email', {
method: 'POST',
headers: { Authorization: `Bearer ${session.access_token}` },
}).catch(() => {
// Fail silent — onboarding must not block on email outages.
});
}, [loading, user, session]);
return (
<section
className="tex-scan"
+49
View File
@@ -0,0 +1,49 @@
'use client';
import { useEffect } from 'react';
/**
* Client-side Sentry init (Session 10).
*
* Manual init rather than the @sentry/nextjs `withSentryConfig`
* wrapper because that plugin conflicts with standalone output mode
* (the Coolify production build). Manual init keeps the bundle
* simple: nothing imported when DSN is unset, lazy import via
* dynamic import() when it is.
*
* Mount once at the root layout — repeated mounts get the
* Sentry-internal idempotency guard, but we keep the layout single-
* mount to avoid unnecessary work.
*/
export default function SentryInit() {
useEffect(() => {
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
if (!dsn) return;
let cancelled = false;
(async () => {
try {
const Sentry = await import('@sentry/nextjs');
if (cancelled) return;
Sentry.init({
dsn,
tracesSampleRate: 0.1,
// PII posture: don't sweep up IPs / cookies automatically.
sendDefaultPii: false,
// Trim heavy integrations we don't need for free-tier volume.
integrations: (defaults) => defaults.filter((i) => i.name !== 'Replay'),
beforeSend(event) {
if (event.user) {
delete event.user.ip_address;
delete event.user.email;
}
return event;
},
});
} catch {
// Sentry init failure is never user-facing — degrade silently.
}
})();
return () => { cancelled = true; };
}, []);
return null;
}
+28 -9
View File
@@ -75,24 +75,43 @@ const TEMPLATE_HTML_WRAP = (body: string) => `
</body></html>`;
export async function sendWelcomeEmail(email: string): Promise<SendResult> {
const subject = "You're in. Let's grade some props.";
// Session 10 — copy updated to reflect the per-day quota (3 free
// reads/day), Stripe founder pricing, and the live soccer engine.
const site = process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app';
const subject = 'Welcome to VYNDR — your reads are ready';
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 →
<p>You have <strong>3 free reads per day</strong>. Every read runs your prop through our intelligence engine — the same signals the books use to set lines.</p>
<p><strong>Quick start:</strong></p>
<ol style="padding-left:20px;line-height:1.7">
<li>Go to <a href="${site}/scan" style="color:#00D4A0">vyndr.app/scan</a></li>
<li>Pick a player, stat, and line</li>
<li>Hit &ldquo;Read It&rdquo; — your grade appears in seconds</li>
</ol>
<p>When you&rsquo;re ready for unlimited reads and full reasoning breakdowns, founder pricing starts at <strong>$14.99/mo (locked for life)</strong>: <a href="${site}/pricing" style="color:#00D4A0">vyndr.app/pricing</a></p>
<p>The <strong>World Cup 2026 intelligence engine is live</strong> — soccer props are graded with xG regression, altitude impact, referee card rates, and penalty taker signals that nobody else has.</p>
<p style="margin-top:24px"><a href="${site}/scan"
style="display:inline-block;padding:12px 24px;background:#00D4A0;color:#0A0A0F;text-decoration:none;border-radius:8px;font-weight:700">
Read your first prop →
</a></p>
<p style="margin-top:24px;color:#8A8A9A">See what the market doesn&rsquo;t.<br>— VYNDR</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.
You have 3 free reads per day. Every read runs your prop through our intelligence engine — the same signals the books use to set lines.
The books have every advantage. Now you have one too.
Quick start:
1. Go to ${site}/scan
2. Pick a player, stat, and line
3. Hit "Read It" — your grade appears in seconds
Open the slate: ${process.env.NEXT_PUBLIC_SITE_URL || 'https://vyndr.app'}/dashboard
When you're ready for unlimited reads and full reasoning breakdowns, founder pricing starts at $14.99/mo (locked for life): ${site}/pricing
The World Cup 2026 intelligence engine is live — soccer props are graded with xG regression, altitude impact, referee card rates, and penalty taker signals that nobody else has.
See what the market doesn't.
— VYNDR
${TEMPLATE_FOOTER}`;
return send({ to: email, subject, html: TEMPLATE_HTML_WRAP(body), text });
}