Phase 18: Auto timezone detection and ability to change timezone #21
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user