Session 10: Internal auth refactor, prefetch cascade keys, Sentry, welcome email (1286 tests)
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 “Read It” — your grade appears in seconds</li>
|
||||
</ol>
|
||||
<p>When you’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’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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user