110 lines
3.8 KiB
TypeScript
110 lines
3.8 KiB
TypeScript
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';
|
|
}
|
|
|
|
// Node's Intl.supportedValuesOf('timeZone') returns canonical IANA zones only —
|
|
// it omits 'UTC', 'Etc/UTC', 'GMT', and the entire Etc/* alias namespace, even
|
|
// though those are valid for Postgres AT TIME ZONE and JS Date methods. The
|
|
// migration default is 'UTC', so the validator must accept it explicitly.
|
|
const EXTRA_ALLOWED_TIMEZONES = new Set(['UTC', 'Etc/UTC', 'GMT', 'Etc/GMT']);
|
|
|
|
function isValidIanaTimezone(tz: unknown): tz is string {
|
|
if (typeof tz !== 'string' || tz.length === 0 || tz.length > 64) return false;
|
|
if (EXTRA_ALLOWED_TIMEZONES.has(tz)) return true;
|
|
try {
|
|
const zones = Intl.supportedValuesOf('timeZone');
|
|
return zones.includes(tz);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function GET(): Promise<NextResponse> {
|
|
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<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' && '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, "updatedAt" = 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 },
|
|
);
|
|
}
|
|
}
|