feat(09-02): GET + PUT /api/me/theme
- 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)
This commit is contained in:
parent
485053c639
commit
dc5dc913bd
1 changed files with 96 additions and 0 deletions
96
app/api/me/theme/route.ts
Normal file
96
app/api/me/theme/route.ts
Normal file
|
|
@ -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<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.
|
||||
// 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue