From dc5dc913bd37561a2f4819ef73824bd8bf04ad34 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 10 May 2026 07:28:03 -0400 Subject: [PATCH] feat(09-02): GET + PUT /api/me/theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ALLOWED_THEMES allowlist for light/dark/system - GET returns { theme, source: 'user'|'default' } - PUT validates against allowlist, writes session.user.id only - Uses updated_at (snake_case) — matches migration 012 schema - No userId from body (T-09-02-01 mitigation) --- app/api/me/theme/route.ts | 96 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 app/api/me/theme/route.ts diff --git a/app/api/me/theme/route.ts b/app/api/me/theme/route.ts new file mode 100644 index 0000000..f50a96a --- /dev/null +++ b/app/api/me/theme/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { postgresClient } from '@/lib/services/postgres-client'; + +// GET /api/me/theme -> { theme: 'light' | 'dark' | 'system', source: 'user' | 'default' } +// PUT /api/me/theme -> body { theme: 'light' | 'dark' | 'system' } -> { theme: string } +// +// THEME-02. Authentication: requireAuth(). The PUT handler updates ONLY the +// calling user's row — there is no `userId` query param or body field. The +// write target is always `session.user.id`. +// +// Validation: the input theme must be one of: 'light', 'dark', 'system'. +// Anything else is rejected with 400 before touching the database. +// +// NOTE: The UPDATE statement uses `updated_at` (snake_case, unquoted) — the +// actual column in migration 012_create_auth_tables.sql. This matches the +// working precedent in app/api/settings/profile/route.ts (not the timezone route). + +const ALLOWED_THEMES = new Set(['light', 'dark', 'system'] as const); + +function isValidTheme(t: unknown): t is 'light' | 'dark' | 'system' { + return typeof t === 'string' && ALLOWED_THEMES.has(t as 'light' | 'dark' | 'system'); +} + +export async function GET(): Promise { + const { session, error } = await requireAuth(); + if (error) return error; + + try { + const result = await postgresClient.query<{ theme: string | null }>( + 'SELECT theme FROM "user" WHERE id = $1', + [session!.user.id], + ); + const stored = result.rows[0]?.theme; + // source is 'user' when a non-default value is explicitly stored; + // 'default' when value equals fallback ('system') or no row found. + const theme = stored ?? 'system'; + const source: 'user' | 'default' = stored && stored !== 'system' ? 'user' : 'default'; + return NextResponse.json({ theme, source }); + } catch (e) { + console.error('GET /api/me/theme failed:', e); + return NextResponse.json( + { error: 'Failed to read theme', message: e instanceof Error ? e.message : 'unknown' }, + { status: 500 }, + ); + } +} + +export async function PUT(request: NextRequest): Promise { + const { session, error } = await requireAuth(); + if (error) return error; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: 'Invalid JSON', message: 'Request body must be JSON' }, + { status: 400 }, + ); + } + + const candidate = + body && typeof body === 'object' && 'theme' in body + ? (body as { theme: unknown }).theme + : undefined; + + if (!isValidTheme(candidate)) { + return NextResponse.json( + { error: 'Invalid theme', message: 'theme must be one of: light, dark, system' }, + { status: 400 }, + ); + } + + try { + // Authoritative write target: session.user.id. NO userId from body. + // updated_at is unquoted snake_case — matches migration 012 schema. + const result = await postgresClient.query<{ theme: string }>( + 'UPDATE "user" SET theme = $1, updated_at = NOW() WHERE id = $2 RETURNING theme', + [candidate, session!.user.id], + ); + if (result.rowCount === 0) { + return NextResponse.json( + { error: 'User not found', message: 'No user row matched the session' }, + { status: 404 }, + ); + } + return NextResponse.json({ theme: result.rows[0].theme }); + } catch (e) { + console.error('PUT /api/me/theme failed:', e); + return NextResponse.json( + { error: 'Failed to update theme', message: e instanceof Error ? e.message : 'unknown' }, + { status: 500 }, + ); + } +}