feat(07.1-02): add GET/PUT /api/me/timezone endpoint
- New app/api/me/timezone/route.ts with GET + PUT handlers
- requireAuth() gate on both methods (401 unauthenticated)
- IANA whitelist via Intl.supportedValuesOf('timeZone') + 64-char cap
- PUT writes only session.user.id — no userId body/query param
- Updates audit column updated_at = NOW() on write
- Resolves TZ-03
This commit is contained in:
parent
bee35e0260
commit
f50215f8fc
1 changed files with 103 additions and 0 deletions
103
app/api/me/timezone/route.ts
Normal file
103
app/api/me/timezone/route.ts
Normal file
|
|
@ -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<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, 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue