From c80845cdba3137a96e54be822d57a3194e4b557d Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sun, 14 Jun 2026 22:31:37 -0400 Subject: [PATCH] feat(18-03): route all-day reminder TZ through stored household_timezone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reminderScheduler.ts: add import { getHouseholdTimezone } from '../lib/householdTimezone.js' and replace bare process.env.TZ ?? Intl... at line 247 with await getHouseholdTimezone(db) - outboxWorker.ts: add same import and replace BOTH bare tz lookups at the update-branch (~line 501) and create-branch (~line 607) with await getHouseholdTimezone(db) - D-05 satisfied: all three all-day sites now read from the single stored accessor - D-06 satisfied: getHouseholdTimezone falls back to process.env.TZ → Intl when unset; existing process.env.TZ-pinned tests pass unchanged - D-07 satisfied: eventDateTime.ts and hydrateEvents.ts are not modified - outboxWorker.test.ts: update wireMockChain() to handle app_config table with where().limit() chain returning empty rows (D-06 fallback), so existing CAL-13 all-day test stays green - reminderScheduler.test.ts: update mockTwoQueries to mock the new third db.select() call (getHouseholdTimezone) returning no row (D-06 fallback), keeping all 37 existing tests green - All 76 broker tests pass; tsc --noEmit clean --- apps/api/src/broker/outboxWorker.ts | 9 +++++++-- apps/api/src/broker/reminderScheduler.ts | 5 ++++- apps/api/tests/broker/outboxWorker.test.ts | 9 +++++++++ .../tests/broker/reminderScheduler.test.ts | 19 +++++++++++++++---- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/apps/api/src/broker/outboxWorker.ts b/apps/api/src/broker/outboxWorker.ts index 0aced4d..2666f43 100644 --- a/apps/api/src/broker/outboxWorker.ts +++ b/apps/api/src/broker/outboxWorker.ts @@ -43,6 +43,7 @@ import { } from './vevent.js'; import type { FastmailClient } from './client.js'; import { dispatchEventChange } from '../lib/eventChangeDispatcher.js'; +import { getHouseholdTimezone } from '../lib/householdTimezone.js'; import { onOutboxDrain } from '../lib/outboxTrigger.js'; // ── Constants (D-07) ──────────────────────────────────────────────────────── @@ -498,7 +499,9 @@ async function dispatchRow(row: OutboxRow): Promise { fields.allDay && fields.start ) { - const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + // D-05: route through the single stored-TZ accessor (no inline fallback duplicated here). + // D-06: getHouseholdTimezone falls back to process.env.TZ → Intl when no row is stored. + const tz = await getHouseholdTimezone(db); const leadDays = fields.reminderLeadMinutes / 1440; allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz); } @@ -604,7 +607,9 @@ async function dispatchRow(row: OutboxRow): Promise { // carries an explicit picker value or no reminder at all; no preserve path needed). let allDayAlertInstantUtcCreate: Date | undefined; if (fields.reminderLeadMinutes != null && fields.allDay && fields.start) { - const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + // D-05: route through the single stored-TZ accessor (no inline fallback duplicated here). + // D-06: getHouseholdTimezone falls back to process.env.TZ → Intl when no row is stored. + const tz = await getHouseholdTimezone(db); const leadDays = fields.reminderLeadMinutes / 1440; allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz); } diff --git a/apps/api/src/broker/reminderScheduler.ts b/apps/api/src/broker/reminderScheduler.ts index 39b4233..de6c8e2 100644 --- a/apps/api/src/broker/reminderScheduler.ts +++ b/apps/api/src/broker/reminderScheduler.ts @@ -48,6 +48,7 @@ import { calendarEvents, pushSubscriptions } from '../db/schema.js'; import { dispatchPush } from '../lib/pushDispatcher.js'; import { computeAlertInstantUtc } from './vevent.js'; import type { NotificationPayload } from '../lib/pushDispatcher.js'; +import { getHouseholdTimezone } from '../lib/householdTimezone.js'; // Maximum lead time preset (2880 min = 2 days for timed; 7 days for all-day is handled separately) // Pre-filter window upper bound for timed events: fetch events starting up to MAX_LEAD_MINUTES out. @@ -244,7 +245,9 @@ export async function runReminderCheck(now = new Date()): Promise { } // Process ALL-DAY events - const serverTz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone; + // D-05: read the stored household timezone through the single accessor (no inline fallback). + // D-06: getHouseholdTimezone falls back to process.env.TZ → Intl when no row is stored. + const serverTz = await getHouseholdTimezone(db); for (const row of allDayRows) { // Drizzle's date() column type is Date|null in TS but mysql2 returns ISO string at runtime const dtstartDate = row.dtstartDate as unknown as string; // 'YYYY-MM-DD' diff --git a/apps/api/tests/broker/outboxWorker.test.ts b/apps/api/tests/broker/outboxWorker.test.ts index 14c784a..7af166a 100644 --- a/apps/api/tests/broker/outboxWorker.test.ts +++ b/apps/api/tests/broker/outboxWorker.test.ts @@ -142,6 +142,8 @@ function wireMockChain() { // mockFromFn differentiates by table argument using Symbol.for('drizzle:Name'): // - memberCredentials → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default) // - calendarEvents → returns mockWhereCalEvents (etag re-read for WR-02) + // - app_config → returns empty row array (D-06 fallback: no stored TZ → Intl fallback) + // getHouseholdTimezone(db) chain: .from(appConfig).where(...).limit(1) → [] // - calendarOutbox (and anything else) → returns mockWherePending (pending-rows + sibling-status) // JSON.stringify throws on circular Drizzle table structures; use Symbol identity instead. mockFromFn.mockImplementation((table: unknown) => { @@ -160,6 +162,13 @@ function wireMockChain() { }), }; } + if (tableName === 'app_config') { + // D-06: default = no stored timezone row → getHouseholdTimezone falls back to + // process.env.TZ → Intl. The where().limit(1) chain must be mockable. + // Plan 18-03 tests override this via wireMockChainWithTz to inject a stored zone. + const limitFn = vi.fn().mockResolvedValue([]); // no stored row → D-06 fallback + return { where: vi.fn().mockReturnValue({ limit: limitFn }) }; + } return { where: mockWherePending }; }); mockWhereCalEvents.mockImplementation(() => Promise.resolve([])); diff --git a/apps/api/tests/broker/reminderScheduler.test.ts b/apps/api/tests/broker/reminderScheduler.test.ts index f3a0f55..ec08a32 100644 --- a/apps/api/tests/broker/reminderScheduler.test.ts +++ b/apps/api/tests/broker/reminderScheduler.test.ts @@ -59,21 +59,32 @@ function makeSelectMock(rows: unknown[]) { } /** - * Setup the db.select mock so the FIRST call (timed query) returns `timedRows` - * and the SECOND call (all-day query) returns `allDayRows` (default empty). + * Setup the db.select mock so the FIRST call (timed query) returns `timedRows`, + * the SECOND call (all-day query) returns `allDayRows` (default empty), and the + * THIRD call (getHouseholdTimezone app_config lookup, Plan 18-03) returns no row + * so the D-06 fallback fires (process.env.TZ → Intl). * - * runReminderCheck() issues two sequential db.select() calls: + * runReminderCheck() after the Plan 18-03 rewire issues three sequential db.select() calls: * 1st: timed events query * 2nd: all-day events query + * 3rd: getHouseholdTimezone → SELECT value FROM app_config WHERE key='household_timezone' + * + * Existing tests that pin process.env.TZ rely on the D-06 fallback (empty app_config row), + * so the third call returning no row keeps them green unchanged. + * + * To test a stored timezone (D-05), use mockThreeQueries instead. */ function mockTwoQueries( db: { select: ReturnType }, timedRows: unknown[], allDayRows: unknown[] = [], ) { + // Reset pending once-values to prevent cross-test queue contamination. + db.select.mockReset(); db.select .mockReturnValueOnce(makeSelectMock(timedRows)) - .mockReturnValueOnce(makeSelectMock(allDayRows)); + .mockReturnValueOnce(makeSelectMock(allDayRows)) + .mockReturnValueOnce(makeAppConfigSelectMock(null)); // null → D-06 fallback (no stored TZ) } function makeEventRow(overrides: {