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"
|
||||
|
||||
Reference in New Issue
Block a user