Phase 18: Auto timezone detection and ability to change timezone #21

Merged
luckberg merged 38 commits from gsd/phase-18-auto-timezone-detection-and-ability-to-change-timezone into main 2026-06-15 09:55:53 -04:00
Showing only changes of commit 3bd6a5d97e - Show all commits
+86 -1
View File
@@ -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);
});