diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index a80dbc6de..95d65fdbf 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -64,6 +64,7 @@ import { getServerTranslator } from "@/lib/i18n/server-translator"; import { type Locale } from "@/lib/i18n/config"; import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; +import { validTimezoneOr } from "@/lib/tz/format"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today"; import { @@ -321,7 +322,7 @@ export const GET = apiHandler(async () => { // timezone. Falls back to Europe/Berlin when the column is somehow // missing (defensive — the schema's NOT NULL default normally pins // it). - const userTz = user.timezone ?? DEFAULT_TIMEZONE; + const userTz = validTimezoneOr(user.timezone, DEFAULT_TIMEZONE); // v1.4.38 W-F — wrap the whole response in the analytics LRU. // Subsequent iOS polls inside the TTL hit memory; measurement / diff --git a/src/app/api/mood/linked-context/route.ts b/src/app/api/mood/linked-context/route.ts index 97e13d4c0..c66e0030c 100644 --- a/src/app/api/mood/linked-context/route.ts +++ b/src/app/api/mood/linked-context/route.ts @@ -39,6 +39,7 @@ import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { resolveLinkedDayContext } from "@/lib/mood/linked-context"; import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { isValidTimezone } from "@/lib/tz/format"; export const dynamic = "force-dynamic"; @@ -47,7 +48,12 @@ const querySchema = z.object({ // The zone the day is anchored to. Absent falls back to the account's own // display timezone; the edit dialog passes the entry's stored `tz` so a row // logged elsewhere keeps its own day boundaries. - tz: z.string().min(1).max(64).optional(), + tz: z + .string() + .min(1) + .max(64) + .refine(isValidTimezone, "Invalid IANA timezone") + .optional(), }); export const GET = apiHandler(async (request: NextRequest) => { diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index e932224c8..d6bccfced 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -61,6 +61,7 @@ import { loadDenseMeasurementBuckets } from "./dense-buckets"; import { summariseDenseBuckets } from "./measurement-series"; import { emptyGlucoseClinical } from "./glucose-panel"; import { buildAdministrationLedger, buildGlp1Block } from "./medications"; +import { validTimezoneOr } from "@/lib/tz/format"; import { loadAllergies, loadAnamnesis, @@ -145,7 +146,7 @@ export async function collectDoctorReportData( insurerIkNumber: true, }, }); - const reportTz = userProfile?.timezone ?? "Europe/Berlin"; + const reportTz = validTimezoneOr(userProfile?.timezone, "Europe/Berlin"); const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS; const densePulse = aggregateDenseTypes && !excluded.includes("PULSE"); diff --git a/src/lib/gamification/achievements-result.ts b/src/lib/gamification/achievements-result.ts index 0ae30939a..de50bdfb5 100644 --- a/src/lib/gamification/achievements-result.ts +++ b/src/lib/gamification/achievements-result.ts @@ -40,7 +40,7 @@ import { } from "@/lib/analytics/classifications"; import { classifyIntakeTiming } from "@/lib/analytics/compliance"; import { wallClockInTz } from "@/lib/tz/wall-clock"; -import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; +import { DEFAULT_TIMEZONE, userDayKey, validTimezoneOr } from "@/lib/tz/format"; export type AuthedUser = Awaited>["user"]; @@ -555,7 +555,7 @@ export async function buildAchievementsResult( // day-streak badge (login, entry, weekend, miss-free, sleep-log, on-time, // compliance-80) plus the hidden night-owl / early-bird / leap-day // triggers for every non-CET account. - const tz = user.timezone || DEFAULT_TIMEZONE; + const tz = validTimezoneOr(user.timezone, DEFAULT_TIMEZONE); const [ vitalsBuckets, diff --git a/src/lib/rollups/tiered-context.ts b/src/lib/rollups/tiered-context.ts index fdf43b62c..28ec0a6a6 100644 --- a/src/lib/rollups/tiered-context.ts +++ b/src/lib/rollups/tiered-context.ts @@ -33,7 +33,7 @@ import type { MeasurementType, RollupGranularity, } from "@/generated/prisma/client"; -import { isNearUtc } from "@/lib/tz/format"; +import { isNearUtc, isValidTimezone } from "@/lib/tz/format"; import { ensureUserRollupsFresh, readRollupBuckets, @@ -400,7 +400,12 @@ async function readBand( // straight to the live path keyed in their zone. The coarser bands stay // on the UTC-anchored rollups — a few hours of offset moves bucket // MEMBERSHIP at week/month/year grain by at most one edge reading. - if (granularity === "DAY" && tz && !isNearUtc(tz, new Date(now))) { + if ( + granularity === "DAY" && + tz && + isValidTimezone(tz) && + !isNearUtc(tz, new Date(now)) + ) { return readBandLive(userId, type, granularity, from, to, tz); } // `readRollupBuckets` never throws — an empty / missing partition yields diff --git a/src/lib/tz/__tests__/resolver.test.ts b/src/lib/tz/__tests__/resolver.test.ts index 0f81b2aef..c6d8c6d62 100644 --- a/src/lib/tz/__tests__/resolver.test.ts +++ b/src/lib/tz/__tests__/resolver.test.ts @@ -55,6 +55,24 @@ describe("isValidTimezone", () => { it("rejects strings longer than 64 chars", () => { expect(isValidTimezone("A".repeat(65))).toBe(false); }); + + // The runtime accepts UTC offsets as zone ids, but Postgres reads a bare + // offset in `AT TIME ZONE` with the POSIX sign (east is negative), so a day + // cut in SQL would land on the wrong side of UTC. + it("rejects bare UTC offsets", () => { + expect(isValidTimezone("+05:30")).toBe(false); + expect(isValidTimezone("-0800")).toBe(false); + expect(isValidTimezone("+05")).toBe(false); + expect(isValidTimezone("\u221205:00")).toBe(false); + }); + + it("keeps accepting named zones, aliases and the Etc family", () => { + expect(isValidTimezone("Asia/Kolkata")).toBe(true); + expect(isValidTimezone("Asia/Calcutta")).toBe(true); + expect(isValidTimezone("Europe/Kyiv")).toBe(true); + expect(isValidTimezone("Etc/UTC")).toBe(true); + expect(isValidTimezone("Etc/GMT+5")).toBe(true); + }); }); describe("resolveServerDefaultTimezone", () => { @@ -109,6 +127,16 @@ describe("resolveUserTimezone", () => { expect(await resolveUserTimezone("user-1")).toBe("Pacific/Auckland"); }); + it("falls back to the server default for a stored bare offset", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "+05:30", + } as never); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + defaultUserTimezone: "Asia/Tokyo", + } as never); + expect(await resolveUserTimezone("user-1")).toBe("Asia/Tokyo"); + }); + it("falls back to the server default when the user row is missing", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue(null); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ @@ -337,3 +365,17 @@ describe("DEFAULT_TIMEZONE", () => { expect(DEFAULT_TIMEZONE).toBe("Europe/Berlin"); }); }); + +describe("request schemas refuse a bare offset zone", () => { + it("the screener submission", async () => { + const { createAssessmentSchema } = + await import("@/lib/validations/mental-health"); + const base = { instrument: "PHQ9", items: [0, 0, 0, 0, 0, 0, 0, 0, 0] }; + expect( + createAssessmentSchema.safeParse({ ...base, tz: "Asia/Kolkata" }).success, + ).toBe(true); + expect( + createAssessmentSchema.safeParse({ ...base, tz: "+05:30" }).success, + ).toBe(false); + }); +}); diff --git a/src/lib/tz/format.ts b/src/lib/tz/format.ts index 1fa9ba047..4277a48a0 100644 --- a/src/lib/tz/format.ts +++ b/src/lib/tz/format.ts @@ -48,9 +48,20 @@ const WALL_CLOCK_PARTS_OPTIONS: Omit = }); /** - * Validate a timezone string by asking `Intl.DateTimeFormat` to use it. - * Returns `true` if the runtime accepts the zone, `false` otherwise. - * Cheap (microseconds) — call freely at write paths. + * A zone id the runtime resolved to a bare UTC offset (`+05:30`, `-08:00`). + */ +const OFFSET_ZONE = /^[+-]\d/; + +/** + * Validate a timezone string: a named IANA zone (or `UTC`) the runtime + * accepts. Returns `false` for anything else. Cheap (microseconds) — call + * freely at write paths. + * + * Bare UTC offsets are refused although the runtime accepts them as zone ids. + * The zone ends up in Postgres `AT TIME ZONE`, which reads a bare offset with + * the POSIX sign (east of UTC is negative), so `+05:30` would cut every day + * eleven hours off. Named zones, their aliases and the `Etc/GMT±N` family + * mean the same thing to both. */ export function isValidTimezone(tz: string): boolean { if (!tz || typeof tz !== "string" || tz.length === 0 || tz.length > 64) { @@ -60,13 +71,29 @@ export function isValidTimezone(tz: string): boolean { // The memo only caches successful constructions, so probing an // invalid zone through it cannot poison the formatter map — and a // valid zone leaves its formatter warm for the real callers. - getDateTimeFormat("en-US", tz, VALIDATION_OPTIONS); - return true; + const resolved = getDateTimeFormat( + "en-US", + tz, + VALIDATION_OPTIONS, + ).resolvedOptions().timeZone; + return !OFFSET_ZONE.test(resolved); } catch { return false; } } +/** + * `tz` when it is a valid zone, else `fallback`. For every place a stored or + * supplied zone reaches SQL: a value written before offsets were refused may + * still sit in the database, and it must never be spliced. + */ +export function validTimezoneOr( + tz: string | null | undefined, + fallback: string, +): string { + return tz && isValidTimezone(tz) ? tz : fallback; +} + /** * The list of every IANA zone the current Node runtime accepts. * Returns an empty array on engines older than Node 22 / V8 12.5 diff --git a/src/lib/validations/mental-health.ts b/src/lib/validations/mental-health.ts index 9331f9f85..ca6b70784 100644 --- a/src/lib/validations/mental-health.ts +++ b/src/lib/validations/mental-health.ts @@ -12,6 +12,7 @@ import { type InstrumentId, } from "@/lib/mental-health/instruments"; import { assertStableExternalId } from "@/lib/validations/external-id"; +import { isValidTimezone } from "@/lib/tz/format"; export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]); @@ -38,7 +39,11 @@ export const createAssessmentSchema = z functionalDifficulty: z.number().int().min(0).max(3).optional(), /** ISO instant the screener was taken; defaults to now server-side. */ takenAt: z.string().datetime().optional(), - tz: z.string().max(64).optional(), + tz: z + .string() + .max(64) + .refine(isValidTimezone, "Invalid IANA timezone") + .optional(), /** Locale of the validated wording actually presented. */ locale: z.string().max(16).optional(), /** Client provenance; defaults to WEB. */