feat(18-02): admin timezone GET/PUT/seed endpoints

- Add appConfig + getHouseholdTimezone/isValidIanaTimezone imports to admin.ts
- Add timezoneSchema: z.object({ timezone: z.string().min(1).max(64).refine(isValidIanaTimezone) })
  No noEchoHook — timezone strings are non-sensitive (T-18-06)
- GET /api/admin/config/timezone: returns { timezone, isExplicitlySet } using D-06 fallback
- PUT /api/admin/config/timezone: validates via timezoneSchema + upserts via onDuplicateKeyUpdate
- POST /api/admin/config/timezone/seed: SELECT-then-INSERT (no onDuplicateKeyUpdate) to enforce D-03 no-overwrite
- All three routes appended AFTER existing routes so line-41 requireAdmin covers them (T-18-03)
- All 25 admin.test.ts tests pass; 366/366 full API suite green; tsc --noEmit clean
This commit is contained in:
Lucas Berger
2026-06-14 22:12:35 -04:00
parent f109b3cf38
commit 3bd6a5d97e
+86 -1
View File
@@ -25,8 +25,9 @@ import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db } from '../db/client.js'; 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 { requireAdmin } from '../lib/requireAdmin.js';
import { isValidIanaTimezone, getHouseholdTimezone } from '../lib/householdTimezone.js';
import { import {
validateEncryptAndStoreCredential, validateEncryptAndStoreCredential,
CredentialValidationError, CredentialValidationError,
@@ -51,6 +52,15 @@ const credentialSchema = z.object({
appPassword: z.string().min(1).max(500), 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. * noEchoHook: NEVER return result.error from zValidator for credential routes.
* Zod's error object contains issues[].received which echoes the submitted value * 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); 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: <bool> }.
// 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);
});