diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index c68f354..797f131 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -25,8 +25,9 @@ import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { eq } from 'drizzle-orm'; import { db } from '../db/client.js'; -import { users, memberCredentials, calendars } from '../db/schema.js'; +import { users, memberCredentials, calendars, appConfig } from '../db/schema.js'; import { requireAdmin } from '../lib/requireAdmin.js'; +import { isValidIanaTimezone, getHouseholdTimezone } from '../lib/householdTimezone.js'; import { validateEncryptAndStoreCredential, CredentialValidationError, @@ -51,6 +52,15 @@ const credentialSchema = z.object({ appPassword: z.string().min(1).max(500), }); +// Timezone schema — no noEchoHook needed (timezone strings are non-sensitive, T-18-06) +const timezoneSchema = z.object({ + timezone: z + .string() + .min(1) + .max(64) + .refine(isValidIanaTimezone, { message: 'Invalid IANA timezone identifier' }), +}); + /** * noEchoHook: NEVER return result.error from zValidator for credential routes. * Zod's error object contains issues[].received which echoes the submitted value @@ -178,3 +188,78 @@ adminRouter.put('/calendars/:id/shared', async (c) => { return c.json({ ok: true }, 200); }); + +// --------------------------------------------------------------------------- +// GET /api/admin/config/timezone +// +// Returns the stored household timezone and whether it has been explicitly set +// (D-01, D-04, D-06). isExplicitlySet: false when no row is in app_config +// (the response still includes the D-06 fallback as the timezone value). +// --------------------------------------------------------------------------- + +adminRouter.get('/config/timezone', async (c) => { + const [row] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'household_timezone')) + .limit(1); + + const isExplicitlySet = row?.value != null; + const timezone = isExplicitlySet + ? (row.value as string) + : await getHouseholdTimezone(db); + + return c.json({ timezone, isExplicitlySet }); +}); + +// --------------------------------------------------------------------------- +// PUT /api/admin/config/timezone +// +// Validates the IANA timezone string (server-side, T-18-04) and upserts the +// household_timezone key in app_config (D-01, D-04). +// Uses onDuplicateKeyUpdate — appConfig.key is the PK so this is a true upsert. +// No noEchoHook: timezone strings are non-sensitive (T-18-06). +// --------------------------------------------------------------------------- + +adminRouter.put('/config/timezone', zValidator('json', timezoneSchema), async (c) => { + const { timezone } = c.req.valid('json'); + + await db + .insert(appConfig) + .values({ key: 'household_timezone', value: timezone }) + .onDuplicateKeyUpdate({ set: { value: timezone } }); + + return c.json({ ok: true }, 200); +}); + +// --------------------------------------------------------------------------- +// POST /api/admin/config/timezone/seed +// +// Seeds household_timezone ONLY when currently unset (D-02 first-run / D-03 +// no-overwrite). Used by the Phase 12 setup wizard and the Phase 18 auto-detect +// flow to store the browser-detected IANA zone without clobbering an admin's +// explicit choice. +// +// Always returns 200 with { ok: true, seeded: }. +// Does NOT use onDuplicateKeyUpdate — an explicit SELECT + conditional INSERT +// ensures the existing value is never overwritten (D-03). +// --------------------------------------------------------------------------- + +adminRouter.post('/config/timezone/seed', zValidator('json', timezoneSchema), async (c) => { + const { timezone } = c.req.valid('json'); + + const [existing] = await db + .select({ value: appConfig.value }) + .from(appConfig) + .where(eq(appConfig.key, 'household_timezone')) + .limit(1); + + const alreadySet = existing?.value != null; + if (!alreadySet) { + await db + .insert(appConfig) + .values({ key: 'household_timezone', value: timezone }); + } + + return c.json({ ok: true, seeded: !alreadySet }, 200); +});