wulf-pulse/app/api/me/theme/route.ts
lorentz 041fb164e3 fix(09): theme PUT uses "updatedAt" not updated_at (Better Auth column is camelCase)
Verified via psql that the user table has quoted camelCase columns from
Better Auth ("updatedAt", "createdAt", "emailVerified"). The original
route comment claimed app/api/settings/profile as precedent — that route
is ALSO broken with the same bug; only app/api/me/timezone got it right.
Aligning theme route with the timezone precedent.
2026-05-10 22:49:47 -04:00

96 lines
3.5 KiB
TypeScript

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 `"updatedAt"` (quoted camelCase) — that is
// the actual column name in the Better Auth `"user"` table (verified via psql).
// Matches the working precedent in app/api/me/timezone/route.ts.
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<NextResponse> {
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<NextResponse> {
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.
// "updatedAt" is quoted camelCase — matches Better Auth schema (verified).
const result = await postgresClient.query<{ theme: string }>(
'UPDATE "user" SET theme = $1, "updatedAt" = 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 },
);
}
}