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:
@@ -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: <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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user