diff --git a/app/api/me/timezone/route.ts b/app/api/me/timezone/route.ts new file mode 100644 index 0000000..98915bd --- /dev/null +++ b/app/api/me/timezone/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { postgresClient } from '@/lib/services/postgres-client'; + +// GET /api/me/timezone -> { timezone: string, source: 'user' | 'default' } +// PUT /api/me/timezone -> body { timezone: string } -> { timezone: string } +// +// TZ-03. 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 timezone must appear in +// `Intl.supportedValuesOf('timeZone')`. Anything else is rejected with 400 +// before touching the database. + +function getDefaultTimezone(): string { + return process.env.DEFAULT_TIMEZONE || 'UTC'; +} + +function isValidIanaTimezone(tz: unknown): tz is string { + if (typeof tz !== 'string' || tz.length === 0 || tz.length > 64) return false; + try { + const zones = Intl.supportedValuesOf('timeZone'); + return zones.includes(tz); + } catch { + return false; + } +} + +export async function GET(): Promise { + const { session, error } = await requireAuth(); + if (error) return error; + + try { + const result = await postgresClient.query<{ timezone: string | null }>( + 'SELECT timezone FROM "user" WHERE id = $1', + [session!.user.id], + ); + const stored = result.rows[0]?.timezone; + const fallback = getDefaultTimezone(); + const timezone = stored && stored.length > 0 ? stored : fallback; + const source: 'user' | 'default' = + stored && stored.length > 0 && stored !== fallback ? 'user' : 'default'; + return NextResponse.json({ timezone, source }); + } catch (e) { + console.error('GET /api/me/timezone failed:', e); + return NextResponse.json( + { error: 'Failed to read timezone', 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' && 'timezone' in body + ? (body as { timezone: unknown }).timezone + : undefined; + + if (!isValidIanaTimezone(candidate)) { + return NextResponse.json( + { + error: 'Invalid timezone', + message: "timezone must be an IANA zone present in Intl.supportedValuesOf('timeZone')", + }, + { status: 400 }, + ); + } + + try { + // Authoritative write target: session.user.id. NO userId from body. + const result = await postgresClient.query<{ timezone: string }>( + 'UPDATE "user" SET timezone = $1, updated_at = NOW() WHERE id = $2 RETURNING timezone', + [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({ timezone: result.rows[0].timezone }); + } catch (e) { + console.error('PUT /api/me/timezone failed:', e); + return NextResponse.json( + { error: 'Failed to update timezone', message: e instanceof Error ? e.message : 'unknown' }, + { status: 500 }, + ); + } +}