diff --git a/src/__tests__/derived-response-consumer-guard.test.ts b/src/__tests__/derived-response-consumer-guard.test.ts index 8b58f5780..355827706 100644 --- a/src/__tests__/derived-response-consumer-guard.test.ts +++ b/src/__tests__/derived-response-consumer-guard.test.ts @@ -160,6 +160,10 @@ function zodObjectShape(path: string, schemaName: string): ZodObjectShape { let current = expression; while (ts.isCallExpression(current)) { const callee = current.expression; + // The shared instant helper (`@/lib/validations/datetime`) is a leaf. + if (ts.isIdentifier(callee) && callee.text === "isoDateTime") { + return [prefix]; + } expect( ts.isPropertyAccessExpression(callee), `${prefix || schemaName} contains an unsupported Zod call: ${current.getText(file)}`, diff --git a/src/__tests__/written-outcome-response-consumer-guard.test.ts b/src/__tests__/written-outcome-response-consumer-guard.test.ts index b74ebaea5..aa62a99d9 100644 --- a/src/__tests__/written-outcome-response-consumer-guard.test.ts +++ b/src/__tests__/written-outcome-response-consumer-guard.test.ts @@ -535,6 +535,10 @@ function zodObjectShape(path: string, schemaName: string): ZodObjectShape { let current = expression; while (ts.isCallExpression(current)) { const callee = current.expression; + // The shared instant helper (`@/lib/validations/datetime`) is a leaf. + if (ts.isIdentifier(callee) && callee.text === "isoDateTime") { + return [prefix]; + } expect( ts.isPropertyAccessExpression(callee), `${prefix || schemaName} contains an unsupported Zod call: ${current.getText(file)}`, diff --git a/src/app/api/admin/app-logs/route.ts b/src/app/api/admin/app-logs/route.ts index 2482a1600..a653f488e 100644 --- a/src/app/api/admin/app-logs/route.ts +++ b/src/app/api/admin/app-logs/route.ts @@ -10,6 +10,7 @@ import { redactSecrets } from "@/lib/logging/redact"; import type { WideEvent } from "@/lib/logging/types"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; +import { isoDateTime } from "@/lib/validations/datetime"; export const dynamic = "force-dynamic"; @@ -26,8 +27,8 @@ const querySchema = z.object({ traceId: z.string().trim().min(1).max(200).optional(), level: z.enum(["debug", "info", "warn", "error"]).optional(), action: z.string().trim().min(1).max(200).optional(), - since: z.string().datetime().optional(), - until: z.string().datetime().optional(), + since: isoDateTime({ offset: false }).optional(), + until: isoDateTime({ offset: false }).optional(), limit: z.coerce.number().int().min(1).max(PROBLEMS_BUFFER_MAX).default(200), }); diff --git a/src/app/api/admin/audit-log/route.ts b/src/app/api/admin/audit-log/route.ts index 26a0e970f..8f073f194 100644 --- a/src/app/api/admin/audit-log/route.ts +++ b/src/app/api/admin/audit-log/route.ts @@ -6,6 +6,7 @@ import { redactSecrets } from "@/lib/logging/redact"; import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * v1.4.16 phase B4: parse the union of legacy + new query params. Legacy @@ -27,8 +28,8 @@ const querySchema = z.object({ actor: z.string().trim().min(1).max(200).optional(), action: z.string().trim().min(1).max(200).optional(), target: z.string().trim().min(1).max(200).optional(), - since: z.string().datetime().optional(), - until: z.string().datetime().optional(), + since: isoDateTime({ offset: false }).optional(), + until: isoDateTime({ offset: false }).optional(), }); export const dynamic = "force-dynamic"; diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index b64ff48ce..606759215 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -41,6 +41,7 @@ import { moodDateKey } from "@/lib/mood/date-key"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { deriveA1 } from "@/lib/mood/level-a"; import { zonedWallClockToUtc } from "@/lib/tz/wall-clock"; +import { isoDateTime } from "@/lib/validations/datetime"; // Derived from canonical enum so round-trip export → import covers every // type. Previous hardcoded subset silently dropped 4 of 11 types @@ -51,12 +52,12 @@ const measurementSchema = z value: z.number(), unit: z.string(), // v1.17.1 — share the canonical entry-instant bound used by the single - // POST + edit paths (v1.17 W1b). Previously a bare `z.string().datetime()` + // POST + edit paths (v1.17 W1b). Previously a bare datetime check // with no ceiling, so a crafted JSON import could forward-date a reading. // `validateEntryInstant` rejects any future instant beyond a 5-min skew // tolerance and any instant before 1900; the field transforms to a `Date`. measuredAt: validateEntryInstant( - z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + isoDateTime().transform((s) => new Date(s)), ), glucoseContext: glucoseContextEnum.optional(), source: z.string().optional(), @@ -96,7 +97,7 @@ const moodEntrySchema = z // v1.17.1 — same entry-instant bound as measurements; an imported mood // log cannot be forward-dated. Optional + transforms to a `Date` when set. loggedAt: validateEntryInstant( - z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + isoDateTime().transform((s) => new Date(s)), ).optional(), // v1.12.1 — optional source-stable id (e.g. a Daylio row id). When // present, the import upserts on `(userId, source, externalId)` so a diff --git a/src/app/api/insights/ecg/route.ts b/src/app/api/insights/ecg/route.ts index c6d17269c..95d9d306e 100644 --- a/src/app/api/insights/ecg/route.ts +++ b/src/app/api/insights/ecg/route.ts @@ -46,6 +46,7 @@ import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; import { persistEcgRecording } from "@/lib/ecg/persist-recording"; +import { isoDateTime } from "@/lib/validations/datetime"; export const dynamic = "force-dynamic"; @@ -96,7 +97,7 @@ const ingestSourceEnum = z.enum(["APPLE_HEALTH"]); const ecgIngestSchema = z .object({ externalRecordingId: z.string().min(1).max(120), - recordedAt: z.iso.datetime({ offset: true }), + recordedAt: isoDateTime(), // HealthKit hands both of these over as doubles. A watch reports its // average heart rate with a fraction (64.7) often enough that requiring // an integer refused most new recordings while the old ones, synced when diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 1a61187f6..ea7c78dd1 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -112,6 +112,7 @@ import { type MeasurementSource, type MeasurementType, } from "@/generated/prisma/client"; +import { isoDateTime } from "@/lib/validations/datetime"; // v1.4.25 W16c — historical-backfill threshold for PR push // suppression. A batch larger than this fires the detection job with @@ -156,8 +157,8 @@ const batchEntrySchema = z.object({ hkIdentifier: z.string().min(1).max(120), value: z.number().finite(), unit: z.string().min(1).max(60), - startDate: z.iso.datetime({ offset: true }), - endDate: z.iso.datetime({ offset: true }), + startDate: isoDateTime(), + endDate: isoDateTime(), sleepStage: z.number().int().min(0).max(20).optional(), // v1.10.0 — categorical events (WX-B). The integer `HKCategoryValue` // codepoint for an EVENT-class category sample (irregular-rhythm, diff --git a/src/app/api/medications/[id]/dose-history/route.ts b/src/app/api/medications/[id]/dose-history/route.ts index 2b9bb1370..acb1e411b 100644 --- a/src/app/api/medications/[id]/dose-history/route.ts +++ b/src/app/api/medications/[id]/dose-history/route.ts @@ -47,6 +47,7 @@ import { import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { dueSchedules } from "@/lib/medications/intake-tracking"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { isoDateTime } from "@/lib/validations/datetime"; type RouteParams = { params: Promise<{ id: string }> }; @@ -60,12 +61,10 @@ const RATE_WINDOW_MS = 60_000; const querySchema = z .object({ - from: z.iso - .datetime({ offset: true }) + from: isoDateTime() .transform((s) => new Date(s)) .optional(), - to: z.iso - .datetime({ offset: true }) + to: isoDateTime() .transform((s) => new Date(s)) .optional(), }) diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 085322060..9d0916ff1 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -101,6 +101,7 @@ import { INTAKE_NOT_TRACKED_REASON, isRecordOnly, } from "@/lib/medications/intake-tracking"; +import { isoDateTime } from "@/lib/validations/datetime"; const MAX_ENTRIES_PER_BATCH = 500; const BATCH_RATE_LIMIT_MAX = 60; @@ -109,8 +110,7 @@ const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000; const bulkEntrySchema = z .object({ medicationId: z.string().min(1), - scheduledFor: z.iso - .datetime({ offset: true }) + scheduledFor: isoDateTime() .transform((s) => new Date(s)) .optional(), // v1.16.9 — shared plausibility bounds (no future beyond clock skew, @@ -135,8 +135,7 @@ const bulkEntrySchema = z // entry skipped (`force_slot_invalid`) per the bulk per-entry contract. // Ignored on non-taken entries (a pending echo / skip carries no take // to attribute). - forceSlotInstant: z.iso - .datetime({ offset: true }) + forceSlotInstant: isoDateTime() .transform((s) => new Date(s)) .optional(), // v1.16.4 — per-entry dose override, mirroring the single intake diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index d22415e71..dbea11e57 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -63,6 +63,7 @@ import { droppedLinkKeysForWire, NO_DROPPED_LINK_KEYS, } from "@/lib/mood/tag-links"; +import { isoDateTime } from "@/lib/validations/datetime"; const MAX_ENTRIES_PER_BATCH = 500; const BATCH_RATE_LIMIT_MAX = 60; @@ -135,7 +136,7 @@ const bulkEntrySchema = z.object({ * @per-entry-parse: moodContextSchema, in the loop below */ context: z.unknown().optional(), - moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + moodLoggedAt: isoDateTime().transform((s) => new Date(s)), source: moodSourceEnum.optional().default("MANUAL"), /** * Optional iOS-side identifier (e.g. SwiftData row UUID) that lets diff --git a/src/lib/day/wire-schemas.ts b/src/lib/day/wire-schemas.ts index 4649265ae..150c5ad7e 100644 --- a/src/lib/day/wire-schemas.ts +++ b/src/lib/day/wire-schemas.ts @@ -32,6 +32,7 @@ import { TIMELINE_RANGE_MAX_DAYS, TIMELINE_ZOOMS, } from "./contract"; +import { isoDateTime } from "@/lib/validations/datetime"; /* ─── Shared pieces ───────────────────────────────────────────────────────── */ @@ -40,7 +41,7 @@ export const dateKeySchema = z .refine(isCalendarDateKey, { message: "Expected a YYYY-MM-DD calendar date" }) .describe("A local calendar date, `YYYY-MM-DD`."); -const instant = z.iso.datetime({ offset: true }); +const instant = isoDateTime(); const messageParams = z .record(z.string(), z.union([z.string(), z.number()])) @@ -212,7 +213,7 @@ export const dayMovementSchema = z countryCode: z.string().nullable(), }) .nullable(), - fetchedAt: z.iso.datetime({ offset: true }), + fetchedAt: isoDateTime(), }) .meta({ id: "DayMovement", diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index fa3d851d8..9d9defb92 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -101,6 +101,7 @@ import { import type { McpAuthContext } from "./auth"; import { dueSchedules } from "@/lib/medications/intake-tracking"; import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * Tool annotations (MCP 2025-11-25). The cloud connectors REQUIRE these on every @@ -881,8 +882,8 @@ const coachReadOutput: z.ZodRawShape = { */ const dateRangeShape = z .object({ - from: z.iso.datetime({ offset: true }), - to: z.iso.datetime({ offset: true }), + from: isoDateTime(), + to: isoDateTime(), }) .describe( "An explicit inclusive date range. Use INSTEAD OF `window` for an arbitrary span (e.g. before vs after a date). Both bounds are ISO-8601 instants.", diff --git a/src/lib/mcp/write-tools.ts b/src/lib/mcp/write-tools.ts index f6ae24c4e..160742de2 100644 --- a/src/lib/mcp/write-tools.ts +++ b/src/lib/mcp/write-tools.ts @@ -60,6 +60,7 @@ import { checkMcpBloodPressure, resolveMcpMeasurementUnit, } from "./writes"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * Write-tool annotations (MCP 2025-11-25). NOT read-only and NOT destructive @@ -187,8 +188,7 @@ const WRITE_TOOL_DEFINITIONS: McpToolDefinition[] = [ .describe( "Optional unit hint. Omit to use the type's canonical unit. A recognised alias (lb/lbs/pound(s), in/inch(es), °F, mmol/L) is converted to canonical; any other unit is refused with unsupported_unit — the value is never stored in a non-canonical unit.", ), - measuredAt: z.iso - .datetime({ offset: true }) + measuredAt: isoDateTime() .optional() .describe( "Optional ISO-8601 instant the reading was taken; defaults to now.", @@ -379,8 +379,7 @@ const WRITE_TOOL_DEFINITIONS: McpToolDefinition[] = [ inputShape: { systolic: z.number().describe("Systolic (the higher number), in mmHg."), diastolic: z.number().describe("Diastolic (the lower number), in mmHg."), - measuredAt: z.iso - .datetime({ offset: true }) + measuredAt: isoDateTime() .optional() .describe( "Optional ISO-8601 instant the reading was taken; defaults to now.", diff --git a/src/lib/medications/side-effects/validators.ts b/src/lib/medications/side-effects/validators.ts index dcb398bf3..c64769f44 100644 --- a/src/lib/medications/side-effects/validators.ts +++ b/src/lib/medications/side-effects/validators.ts @@ -19,6 +19,7 @@ import { MedicationSideEffectCategory, MedicationSideEffectEntry, } from "@/generated/prisma/client"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * v1.4.25 W21 Fix-N (simp-M1) — these arrays used to restate the @@ -54,20 +55,17 @@ const SIDE_EFFECT_NOTES_MAX = 280; export const createSideEffectSchema = z.object({ entry: z.nativeEnum(MedicationSideEffectEntry), severity: z.number().int().min(1).max(5), - occurredAt: z.iso - .datetime({ offset: true }) + occurredAt: isoDateTime() .transform((s) => new Date(s)) .optional(), notes: z.string().max(SIDE_EFFECT_NOTES_MAX).nullable().optional(), }); export const listSideEffectsSchema = z.object({ - from: z.iso - .datetime({ offset: true }) + from: isoDateTime() .transform((s) => new Date(s)) .optional(), - to: z.iso - .datetime({ offset: true }) + to: isoDateTime() .transform((s) => new Date(s)) .optional(), limit: z.coerce.number().int().min(1).max(200).optional().default(50), diff --git a/src/lib/onboarding/tour-progress.ts b/src/lib/onboarding/tour-progress.ts index c14c3a7f1..700859ed1 100644 --- a/src/lib/onboarding/tour-progress.ts +++ b/src/lib/onboarding/tour-progress.ts @@ -12,6 +12,7 @@ */ import { z } from "zod/v4"; +import { isoDateTime } from "@/lib/validations/datetime"; /** Terminal / running status of the module tour. */ export const TOUR_PROGRESS_STATUSES = [ @@ -33,7 +34,7 @@ export const tourProgressSchema = z.object({ lastStopId: z.string().min(1).max(64).nullable(), completedStopIds: z.array(z.string().min(1).max(64)).max(64).default([]), status: z.enum(TOUR_PROGRESS_STATUSES), - updatedAt: z.iso.datetime(), + updatedAt: isoDateTime({ offset: false }), }); export type TourProgress = z.infer; diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index eeea2cb37..a62587e9e 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -45,6 +45,7 @@ import { import { onboardingStateResource } from "./onboarding"; import { aiAccountBlock, moduleAccessMap } from "./profile"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * The four request bodies this table publishes, and the one thing to know about @@ -619,7 +620,7 @@ const managedProfileHandover = z "The one-time `hlp_` token. Returned only in this response and never again; only its hash is stored.", ), url: z.string().describe("The link to hand over: `/claim/`."), - expiresAt: z.iso.datetime({ offset: true }), + expiresAt: isoDateTime(), }) .meta({ id: "ManagedProfileHandover" }); @@ -639,8 +640,8 @@ const managedProfileHandoverStatus = z open: z .object({ id: z.string(), - createdAt: z.iso.datetime({ offset: true }), - expiresAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), + expiresAt: isoDateTime(), createdByYou: z.boolean(), }) .nullable() @@ -660,7 +661,7 @@ const pendingHandoverDecision = z .object({ pending: z .object({ - claimedAt: z.iso.datetime({ offset: true }), + claimedAt: isoDateTime(), guardians: z.array( z.object({ grantId: z diff --git a/src/lib/openapi/routes/admin.ts b/src/lib/openapi/routes/admin.ts index df06d2d96..38b028cc5 100644 --- a/src/lib/openapi/routes/admin.ts +++ b/src/lib/openapi/routes/admin.ts @@ -10,6 +10,7 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { inviteCreateSchema } from "@/lib/validations/invite"; import { adminReminderCheckSchema } from "@/lib/validations/notifications"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; // v1.4.48 H-APNs-1 — admin diagnostic endpoint for the notification // subsystem. Mirrors the runtime types in @@ -27,7 +28,7 @@ const adminDiagnosticDevice = z apnsTokenPrefix: z.string().nullable(), apnsTokenSuffix: z.string().nullable(), apnsEnvironment: z.string().nullable(), - lastSeenAt: z.iso.datetime({ offset: true }), + lastSeenAt: isoDateTime(), }) .meta({ id: "AdminDiagnosticDevice", @@ -53,7 +54,7 @@ const adminDiagnosticPushAttempt = z channel: z.string(), result: z.string(), reason: z.string().nullable(), - at: z.iso.datetime({ offset: true }), + at: isoDateTime(), }) .meta({ id: "AdminDiagnosticPushAttempt", @@ -89,10 +90,10 @@ export const adminInvitePaths: NonNullable = { z.array( z.object({ id: z.string(), - createdAt: z.iso.datetime({ offset: true }), - expiresAt: z.iso.datetime({ offset: true }), - usedAt: z.iso.datetime({ offset: true }).nullable(), - revokedAt: z.iso.datetime({ offset: true }).nullable(), + createdAt: isoDateTime(), + expiresAt: isoDateTime(), + usedAt: isoDateTime().nullable(), + revokedAt: isoDateTime().nullable(), uses: z.number().int(), maxUses: z.number().int(), creator: z @@ -104,7 +105,7 @@ export const adminInvitePaths: NonNullable = { redemptions: z.array( z.object({ id: z.string(), - redeemedAt: z.iso.datetime({ offset: true }), + redeemedAt: isoDateTime(), user: z .object({ id: z.string(), @@ -141,8 +142,8 @@ export const adminInvitePaths: NonNullable = { schema: dataEnvelope( z.object({ id: z.string(), - createdAt: z.iso.datetime({ offset: true }), - expiresAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), + expiresAt: isoDateTime(), uses: z.number().int(), maxUses: z.number().int(), token: z @@ -226,7 +227,7 @@ const adminReminderMedication = z const adminReminderCheckResult = z .object({ - timestamp: z.iso.datetime({ offset: true }), + timestamp: isoDateTime(), missedThresholdMinutes: z.number().int(), scoped: z .boolean() @@ -285,9 +286,9 @@ const adminMeasurementMaintenanceStatus = z run: z .object({ state: z.enum(["queued", "running", "completed", "failed"]), - requestedAt: z.iso.datetime(), - startedAt: z.iso.datetime().nullable(), - finishedAt: z.iso.datetime().nullable(), + requestedAt: isoDateTime({ offset: false }), + startedAt: isoDateTime({ offset: false }).nullable(), + finishedAt: isoDateTime({ offset: false }).nullable(), outcome: z .enum(["completed", "refused_purge_running", "already_running"]) .nullable() diff --git a/src/lib/openapi/routes/analytics.ts b/src/lib/openapi/routes/analytics.ts index 41dda0037..bbdda4373 100644 --- a/src/lib/openapi/routes/analytics.ts +++ b/src/lib/openapi/routes/analytics.ts @@ -29,6 +29,7 @@ import { healthScoreBasis, } from "./insights/schemas"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; // ── The per-type summary the tile strip renders ────────────────────── @@ -97,7 +98,7 @@ const lastSeenByType = z z.string(), z .object({ - lastSeenAt: z.iso.datetime({ offset: true }), + lastSeenAt: isoDateTime(), daysAgo: z .number() .int() @@ -250,7 +251,7 @@ const pillarValue = z.object({ .describe( "The complete display value, paired or panel values included, written in the reader's units (fasting glucose in the account's glucose unit).", ), - asOf: z.iso.datetime({ offset: true }), + asOf: isoDateTime(), sources: z.array(z.string()), }), reference: z.object({ @@ -343,7 +344,7 @@ const weightGoalValue = z.object({ .number() .nullable() .describe("Positive means the distance to the personal band narrowed."), - asOf: z.iso.datetime({ offset: true }), + asOf: isoDateTime(), source: z.string(), }); @@ -428,7 +429,7 @@ const healthScoreReport = z restMode: z .object({ active: z.literal(true), - since: z.iso.datetime({ offset: true }).nullable(), + since: isoDateTime().nullable(), episodeCount: z.number().int(), }) .nullable() diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 2556bb59d..82b07e7f2 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -55,6 +55,7 @@ import { claimSchema, } from "@/lib/validations/managed-profile-handover"; import { HANDOVER_ACCESS_LEVELS } from "@/lib/managed-profiles/handover-access"; +import { isoDateTime } from "@/lib/validations/datetime"; // ── Sub-schemas owned here (route-specific shapes) ─────────────────── @@ -109,14 +110,14 @@ const accessRefreshBundle = z .describe( "Access token (`hlk_<64hex>`); only present for native-policy callers. A browser caller gets the session cookie instead.", ), - tokenExpiresAt: z.iso.datetime({ offset: true }).optional(), + tokenExpiresAt: isoDateTime().optional(), refreshToken: z .string() .optional() .describe( "Refresh token (`hlr_<64hex>`); only present for native-policy callers.", ), - refreshTokenExpiresAt: z.iso.datetime({ offset: true }).optional(), + refreshTokenExpiresAt: isoDateTime().optional(), }) .meta({ id: "AccessRefreshBundle", @@ -195,8 +196,8 @@ const sessionListResponse = z .string() .nullable() .describe("Resolved coarse location, when available."), - lastActiveAt: z.iso.datetime({ offset: true }).nullable(), - createdAt: z.iso.datetime({ offset: true }), + lastActiveAt: isoDateTime().nullable(), + createdAt: isoDateTime(), isCurrent: z .boolean() .describe("True for the session making this request."), @@ -214,9 +215,9 @@ const trustedDeviceListResponse = z .string() .nullable() .describe("Coarse, IP-free device label (e.g. 'Firefox on macOS')."), - createdAt: z.iso.datetime({ offset: true }), - lastUsedAt: z.iso.datetime({ offset: true }), - expiresAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), + lastUsedAt: isoDateTime(), + expiresAt: isoDateTime(), isCurrent: z .boolean() .describe("True for the device making this request."), @@ -280,7 +281,7 @@ const securityActivityResponse = z action: z .string() .describe("Audit action name, e.g. auth.login.password."), - createdAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), location: z.string().nullable(), ipMasked: z.string().nullable().describe("Host-masked IP."), carrier: z.string().nullable(), @@ -295,8 +296,8 @@ const webauthnCredentialInfo = z .object({ id: z.string(), name: z.string(), - createdAt: z.iso.datetime({ offset: true }), - lastUsedAt: z.iso.datetime({ offset: true }).nullable(), + createdAt: isoDateTime(), + lastUsedAt: isoDateTime().nullable(), }) .meta({ id: "WebauthnMfaCredentialInfo" }); @@ -320,9 +321,8 @@ const passkeyInfo = z .describe( "Whether the authenticator reported the credential as backed up (synced to the platform keychain) at registration time. Not re-read afterwards.", ), - createdAt: z.iso.datetime({ offset: true }).describe("Registered at."), - lastUsedAt: z.iso - .datetime({ offset: true }) + createdAt: isoDateTime().describe("Registered at."), + lastUsedAt: isoDateTime() .nullable() .describe( "When this passkey last completed a verified assertion; null when it has never been used to sign in. Stamped on every successful login, so a client can render a real 'last used' rather than assuming never.", @@ -392,7 +392,7 @@ const stepUpMintResponse = z .describe( "Opaque single-use elevation (`hle_<64hex>`). Present it in the `X-Step-Up` header on ONE second-factor-management call. Returned exactly once; store it in memory only.", ), - expiresAt: z.iso.datetime({ offset: true }), + expiresAt: isoDateTime(), expiresInSeconds: z.number().int(), method: z .enum(["password", "totp", "webauthn", "passkey"]) @@ -473,12 +473,11 @@ const apiTokenInfo = z .describe( 'The token\'s scope list. `["*"]` is cookie-equivalent (what a native login mints); anything else is narrow and reaches only the routes that name it. Admin endpoints are cookie-only and are reachable by no token at any scope.', ), - lastUsedAt: z.iso - .datetime({ offset: true }) + lastUsedAt: isoDateTime() .nullable() .describe("Stamped fire-and-forget on use; null for an unused token."), - expiresAt: z.iso.datetime({ offset: true }).nullable(), - createdAt: z.iso.datetime({ offset: true }), + expiresAt: isoDateTime().nullable(), + createdAt: isoDateTime(), revoked: z .boolean() .describe( @@ -505,7 +504,7 @@ const createMeasurementTokenResponse = z "The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash, so there is no way to retrieve it later; a client that does not capture it here has to mint a replacement. Treat it as a secret in logs, crash reports and analytics.", ), name: z.string(), - expiresAt: z.iso.datetime({ offset: true }), + expiresAt: isoDateTime(), }) .meta({ id: "CreateMeasurementTokenResponse", @@ -527,7 +526,7 @@ const createWorkoutTokenResponse = z "The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash and cannot be retrieved later. Treat it as a secret.", ), name: z.string(), - expiresAt: z.iso.datetime({ offset: true }), + expiresAt: isoDateTime(), }) .meta({ id: "CreateWorkoutTokenResponse", @@ -549,7 +548,7 @@ const createDocumentTokenResponse = z "The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash and cannot be retrieved later. Treat it as a secret.", ), name: z.string(), - expiresAt: z.iso.datetime({ offset: true }), + expiresAt: isoDateTime(), }) .meta({ id: "CreateDocumentTokenResponse", @@ -715,7 +714,7 @@ const claimPreview = z .string() .nullable() .describe("The profile's name, as its guardians set it."), - expiresAt: z.iso.datetime({ offset: true }), + expiresAt: isoDateTime(), guardians: z .array( z.object({ @@ -1116,7 +1115,7 @@ export const authPaths: NonNullable = { z .object({ method: z.enum(["password", "totp", "webauthn", "passkey"]), - verifiedAt: z.string().datetime(), + verifiedAt: isoDateTime({ offset: false }), }) .meta({ id: "ReproofResponse" }), "ReproofEnvelope", diff --git a/src/lib/openapi/routes/awards.ts b/src/lib/openapi/routes/awards.ts index d61b80c41..96a6031da 100644 --- a/src/lib/openapi/routes/awards.ts +++ b/src/lib/openapi/routes/awards.ts @@ -22,6 +22,7 @@ import { recordRefusal, stdResponses, } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; const personalRecord = z .object({ @@ -45,7 +46,7 @@ const personalRecord = z ), value: z.number(), unit: z.string(), - achievedAt: z.iso.datetime({ offset: true }), + achievedAt: isoDateTime(), sourceMeasurementId: z .string() .nullable() @@ -54,7 +55,7 @@ const personalRecord = z ), source: measurementSourceEnum, externalId: z.string().nullable(), - createdAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), benchmark: recordBenchmark .nullable() .optional() @@ -93,7 +94,7 @@ const achievement = z points: z.number().int(), unlocked: z.boolean(), progressPercent: z.number(), - completedAt: z.iso.datetime({ offset: true }).nullable(), + completedAt: isoDateTime().nullable(), isHidden: z .boolean() .describe( @@ -144,7 +145,7 @@ const achievementIosEntry = z description: z.string(), iconName: z.string(), unlocked: z.boolean(), - unlockedAt: z.iso.datetime({ offset: true }).nullable(), + unlockedAt: isoDateTime().nullable(), progress: z.number().describe("0–1, clamped. Not the 0–100 percent."), category: z.string(), points: z.number().int(), diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index 0b2d67cdd..3f9aec5b6 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -79,6 +79,7 @@ import { aiExtractionRefusalDescription, aiExtractionRefusals, } from "./ai-extraction-refusals"; +import { isoDateTime } from "@/lib/validations/datetime"; // The assigning form, deliberately: `schema.meta({...})` as a bare statement // returns a clone and registers nothing, so the component id would never @@ -269,8 +270,7 @@ const insightsLayoutPutBody = insightsLayoutSchema }); const insightsLayoutResult = insightsLayoutSchema .extend({ - updatedAt: z.iso - .datetime({ offset: true }) + updatedAt: isoDateTime() .optional() .describe( "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque.", @@ -315,14 +315,13 @@ const coachFactItem = z.object({ .string() .nullable() .describe("v1.41 — the message the fact came out of, when known."), - lastUsedAt: z.iso - .datetime({ offset: true }) + lastUsedAt: isoDateTime() .nullable() .describe("v1.41 — when the fact last went into a Coach turn."), - createdAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso - .datetime({ offset: true }) - .describe("v1.41 — when the fact was last written or edited."), + createdAt: isoDateTime(), + updatedAt: isoDateTime().describe( + "v1.41 — when the fact was last written or edited.", + ), }); const coachFactCreateRequest = coachFactCreateSchema.meta({ @@ -355,7 +354,7 @@ const coachFactCreatedResponse = z.object({ const coachFactUpdatedResponse = z.object({ fact: coachFactWrittenItem.extend({ - updatedAt: z.iso.datetime({ offset: true }), + updatedAt: isoDateTime(), }), }); @@ -414,8 +413,7 @@ const coachPlanItem = z status: z .enum(COACH_PLAN_STATUSES) .describe("App-side lifecycle: proposed | active | met | abandoned."), - reviewDate: z.iso - .datetime({ offset: true }) + reviewDate: isoDateTime() .nullable() .describe("Optional check-in checkpoint; null when none."), sourceConversationId: z @@ -424,8 +422,8 @@ const coachPlanItem = z .describe( "Id of the conversation the proposal was extracted from; null for a plan without conversation provenance. Lets a chat surface show only the proposals born in the open thread.", ), - createdAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), + updatedAt: isoDateTime(), }) .meta({ id: "CoachPlan", @@ -481,12 +479,12 @@ const coachReminderItem = z ), metric: z.string().nullable(), triggerKind: z.enum(["date", "context"]), - dueAt: z.iso.datetime({ offset: true }).nullable(), + dueAt: isoDateTime().nullable(), contextCue: z.string().nullable(), status: z.enum(COACH_REMINDER_STATUSES), source: z.enum(["sentinel", "extractor", "manual", "action"]), - createdAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), + updatedAt: isoDateTime(), }) .meta({ id: "CoachReminder", @@ -549,7 +547,7 @@ const coachMessageSchema = z content: z .string() .describe("Decrypted message body — the server decrypts on read."), - createdAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), metricSource: coachProvenanceSchema .nullable() .describe("Provenance envelope (assistant turns); null for user turns."), @@ -601,10 +599,10 @@ const coachConversationSchema = z .object({ id: z.string(), title: z.string().describe("Title summarised from the first user message."), - createdAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso - .datetime({ offset: true }) - .describe("Bumped on every appended message; the rail orders by this."), + createdAt: isoDateTime(), + updatedAt: isoDateTime().describe( + "Bumped on every appended message; the rail orders by this.", + ), messageCount: z.number().int(), fenced: z .boolean() @@ -650,8 +648,8 @@ const coachConversationDetailSchema = z .object({ id: z.string(), title: z.string(), - createdAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), + updatedAt: isoDateTime(), messageCount: z.number().int(), messages: z .array(coachMessageSchema) @@ -1386,8 +1384,7 @@ export const coachPaths: NonNullable = { "application/json": { schema: dataEnvelope( z.object({ - nudgedAt: z.iso - .datetime({ offset: true }) + nudgedAt: isoDateTime() .nullable() .describe( "Timestamp of the newest Coach assistant message; null when none exists.", @@ -1429,9 +1426,9 @@ export const coachPaths: NonNullable = { "application/json": { schema: dataEnvelope( z.object({ - seenAt: z.iso - .datetime({ offset: true }) - .describe("The server-minted open timestamp."), + seenAt: isoDateTime().describe( + "The server-minted open timestamp.", + ), }), "CoachSeenResponse", ), @@ -1462,7 +1459,7 @@ export const coachPaths: NonNullable = { coachFocus: z.string().nullable(), aiIncludedSections: z.array(healthProfileAiSectionSchema), pendingQuestions: z.array(z.string()), - updatedAt: z.iso.datetime({ offset: true }).nullable(), + updatedAt: isoDateTime().nullable(), maxChars: z.number().int(), fieldMaxChars: z.number().int(), ai: aiCapabilityState.describe( @@ -1506,7 +1503,7 @@ export const coachPaths: NonNullable = { coachFocus: z.string().nullable(), aiIncludedSections: z.array(healthProfileAiSectionSchema), pendingQuestions: z.array(z.string()), - updatedAt: z.iso.datetime({ offset: true }), + updatedAt: isoDateTime(), maxChars: z.number().int(), fieldMaxChars: z.number().int(), ai: aiCapabilityState.describe( @@ -2130,7 +2127,7 @@ export const coachFeedbackPaths: NonNullable = { schema: dataEnvelope( z.object({ id: z.string(), - createdAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), }), "CoachMessageFeedbackResponse", ), diff --git a/src/lib/openapi/routes/consent.ts b/src/lib/openapi/routes/consent.ts index e042f14ce..a50c0b645 100644 --- a/src/lib/openapi/routes/consent.ts +++ b/src/lib/openapi/routes/consent.ts @@ -32,6 +32,7 @@ import { webConsentGrantBody as webConsentGrantBodyBase, } from "@/lib/validations/consent"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; // `.meta()` CLONES in Zod 4 rather than annotating in place, so the returned // schema has to be captured and referenced. A bare `schema.meta({...})` @@ -88,9 +89,9 @@ const serialisedReceipt = z id: z.string(), userId: z.string(), kind: consentKindEnum, - signedAt: z.iso.datetime({ offset: true }), - revokedAt: z.iso.datetime({ offset: true }).nullable(), - createdAt: z.iso.datetime({ offset: true }), + signedAt: isoDateTime(), + revokedAt: isoDateTime().nullable(), + createdAt: isoDateTime(), }) .meta({ id: "SerialisedConsentReceipt", diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index 27b0d342c..c659d49b5 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -39,6 +39,7 @@ import { recordWriteRateLimitResponse, stdResponses, } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; // ── Cycle tracking (v1.15.0) ───────────────────────────────────────── // The `/api/cycle/*` capture / calendar / history / settings surface + @@ -139,8 +140,8 @@ export const cycleDayLogDto = z source: z.string(), externalId: z.string().nullable(), syncVersion: z.number().int(), - updatedAt: z.iso.datetime({ offset: true }), - deletedAt: z.iso.datetime({ offset: true }).nullable(), + updatedAt: isoDateTime(), + deletedAt: isoDateTime().nullable(), }) .meta({ id: "CycleDayLogDTO", @@ -159,7 +160,7 @@ export const menstrualCycleDto = z ovulationConfirmed: z.boolean(), isPredicted: z.boolean(), syncVersion: z.number().int(), - updatedAt: z.iso.datetime({ offset: true }), + updatedAt: isoDateTime(), }) .meta({ id: "MenstrualCycleDTO", @@ -289,7 +290,7 @@ const cycleProfileDto = z typicalCycleLength: z.number().int().nullable(), typicalPeriodLength: z.number().int().nullable(), lutealPhaseLength: z.number().int().nullable(), - updatedAt: z.iso.datetime({ offset: true }), + updatedAt: isoDateTime(), }) .meta({ id: "CycleProfileDTO", @@ -315,7 +316,7 @@ const cycleCalendarResponse = z.object({ // Additive + back-compatible. stillLearning: z.boolean(), days: z.array(cycleCalendarDayDto), - meta: z.object({ generatedAt: z.iso.datetime({ offset: true }) }), + meta: z.object({ generatedAt: isoDateTime() }), }); const cycleHistoryResponse = z.object({ diff --git a/src/lib/openapi/routes/dashboard.ts b/src/lib/openapi/routes/dashboard.ts index 09ae45169..0a33fecbc 100644 --- a/src/lib/openapi/routes/dashboard.ts +++ b/src/lib/openapi/routes/dashboard.ts @@ -36,6 +36,7 @@ import { recordRefusal, stdResponses, } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; const dashboardWidgetConfig = z.object({ id: z.enum(DASHBOARD_WIDGET_CATALOGUE_IDS), @@ -102,8 +103,7 @@ const dashboardLayoutPutBody = dashboardLayoutSchema const dashboardLayoutResult = dashboardLayoutSchema .extend({ - updatedAt: z.iso - .datetime({ offset: true }) + updatedAt: isoDateTime() .optional() .describe( "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque.", @@ -226,8 +226,7 @@ const dashboardMetricCard = z .describe( "Up to seven points: the last seven DAY rollup buckets for a measured metric regardless of age (daily sum for cumulative metrics such as steps, daily mean otherwise), the trailing nights' asleep hours for `sleep`, the last seven daily means for `mood`, and the trailing derived series for `bmi`. May be empty.", ), - updatedAt: z.iso - .datetime({ offset: true }) + updatedAt: isoDateTime() .nullable() .describe( "Instant behind `latestValue`, falling back to the metric's newest reading. For `mood` this is the first instant of the latest daily bucket's day in the account's time zone, not a reading time (the series pins a local day, not an instant); formatted in that zone it always lands on the day the entry was logged. For `bmi` it is the WEIGHT metadata, since BMI moves exactly when weight does.", @@ -238,8 +237,7 @@ const dashboardMetricCard = z .describe( "Total readings ever logged for this metric — the gate that keeps a tile visible through a logging gap. Summed across both sides for `bloodPressure`; the mood entry count for `mood`; the WEIGHT count for `bmi`.", ), - lastSeenAt: z.iso - .datetime({ offset: true }) + lastSeenAt: isoDateTime() .nullable() .describe( "The metric's newest reading, for the 'last reading N days ago' caption when it is older than the sparkline window.", @@ -267,7 +265,7 @@ const dashboardSummaryResponse = z .describe( "Already-localised greeting including the display name (or username). One of the few strings this payload still translates server-side, in the account's own locale.", ), - date: z.iso.datetime({ offset: true }).describe("Build instant."), + date: isoDateTime().describe("Build instant."), }) .describe("Header block."), streak: z @@ -308,9 +306,7 @@ const dashboardSummaryResponse = z sleepRhythm: sleepRhythmResponse.describe( "The same server-authoritative sleep-debt + chronotype + average-per-night DTO `GET /api/sleep/rhythm` returns, off the same canonical night reconstruction. Always present, with its own calm `partial` / `learning` states — it is NOT gated by the sleep module here, so it can carry figures while the sleep metric CARD is filtered out.", ), - lastUpdated: z.iso - .datetime({ offset: true }) - .describe("Build instant of the cached body."), + lastUpdated: isoDateTime().describe("Build instant of the cached body."), }) .meta({ id: "DashboardSummaryResponse", diff --git a/src/lib/openapi/routes/dawarich.ts b/src/lib/openapi/routes/dawarich.ts index d3afa4825..4d320f65a 100644 --- a/src/lib/openapi/routes/dawarich.ts +++ b/src/lib/openapi/routes/dawarich.ts @@ -24,6 +24,7 @@ import { moduleDisabledResponse, stdResponses, } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; const dawarichConnectRequest = dawarichConnectSchema.meta({ id: "DawarichConnectRequest", @@ -207,7 +208,7 @@ export const dawarichPaths: NonNullable = { schema: dataEnvelope( z.object({ ok: z.literal(true), - lastSyncedAt: z.iso.datetime({ offset: true }).nullable(), + lastSyncedAt: isoDateTime().nullable(), latencyMs: z.number().int(), }), "DawarichProbeEnvelope", diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 9a7167387..35fb5495d 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -47,6 +47,7 @@ import { recordRefusal, stdResponses, } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; const kindEnum = z.enum(INBOUND_DOCUMENT_KINDS); @@ -755,7 +756,7 @@ export const inboundDocumentPaths: NonNullable = { linkedProcedures: z.array( z.object({ encounterId: z.string(), - occurredAt: z.iso.datetime({ offset: true }), + occurredAt: isoDateTime(), reason: z.string().nullable(), bodySite: z.string().nullable(), laterality: z diff --git a/src/lib/openapi/routes/environment.ts b/src/lib/openapi/routes/environment.ts index ca4ffcc6b..73074980b 100644 --- a/src/lib/openapi/routes/environment.ts +++ b/src/lib/openapi/routes/environment.ts @@ -32,6 +32,7 @@ import { moduleDisabledResponse, stdResponses, } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; const homeLocationRequest = homeLocationSchema.meta({ id: "EnvironmentHomeRequest", @@ -68,8 +69,7 @@ const environmentHome = z .describe( "The home's IANA zone. Falls back to the account's display timezone when the home was stored without one.", ), - since: z.iso - .datetime({ offset: true }) + since: isoDateTime() .nullable() .describe( "Effective-from instant. Every set/update re-stamps it: a home resolves days from the moment it was set, and days before it stay un-attributed unless a travel override covers them.", @@ -112,13 +112,13 @@ const environmentOverview = z .string() .nullable() .describe("Day key of the newest stored observation."), - latestFetchedAt: z.iso.datetime({ offset: true }).nullable(), + latestFetchedAt: isoDateTime().nullable(), }), lastFetchFailure: z .object({ - lastFailedAt: z.iso - .datetime({ offset: true }) - .describe("When the newest exhausted-retry failure gave up."), + lastFailedAt: isoDateTime().describe( + "When the newest exhausted-retry failure gave up.", + ), failures: z .number() .int() diff --git a/src/lib/openapi/routes/export.ts b/src/lib/openapi/routes/export.ts index a19a5d09c..6dcd5c9c4 100644 --- a/src/lib/openapi/routes/export.ts +++ b/src/lib/openapi/routes/export.ts @@ -25,6 +25,7 @@ import { SHARING_NOT_PERMITTED_DESCRIPTION, stdResponses, } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; const exportQuery = z .object({ @@ -43,15 +44,13 @@ const exportQuery = z }) .meta({ id: "ExportQuery" }); -const forgivingSince = z.iso - .datetime({ offset: true }) +const forgivingSince = isoDateTime() .optional() .describe( "Inclusive lower bound. Parsed with the plain Date constructor, so an ISO date (`2026-07-14`) works as well as a full instant — and an UNPARSEABLE value is silently DROPPED from the filter rather than refused, which widens the export instead of erroring. Validate on your side.", ); -const forgivingUntil = z.iso - .datetime({ offset: true }) +const forgivingUntil = isoDateTime() .optional() .describe("Inclusive upper bound, with the same forgiving parse as `since`."); diff --git a/src/lib/openapi/routes/health.ts b/src/lib/openapi/routes/health.ts index 1f9ffa54b..2540e8fe6 100644 --- a/src/lib/openapi/routes/health.ts +++ b/src/lib/openapi/routes/health.ts @@ -18,6 +18,7 @@ */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; +import { isoDateTime } from "@/lib/validations/datetime"; const healthStatusEnum = z.enum(["ok", "degraded"]).meta({ id: "HealthStatus", @@ -43,9 +44,9 @@ const healthAdminResponse = z .object({ status: healthStatusEnum, reason: healthReason, - timestamp: z.iso - .datetime({ offset: true }) - .describe("When the probe ran. UTC (`Z`) — no user context is loaded."), + timestamp: isoDateTime().describe( + "When the probe ran. UTC (`Z`) — no user context is loaded.", + ), database: z .enum(["connected", "disconnected"]) .describe("Result of a `SELECT 1` round-trip."), @@ -54,8 +55,7 @@ const healthAdminResponse = z .describe( "The in-process pg-boss worker loop. Reported for every process, including one that does not run the worker role — a web-only process reads `stopped` here and can still be `ok`.", ), - workerLastHeartbeat: z.iso - .datetime({ offset: true }) + workerLastHeartbeat: isoDateTime() .optional() .describe( "Last worker heartbeat. Absent when the worker has never beaten in this process.", diff --git a/src/lib/openapi/routes/import.ts b/src/lib/openapi/routes/import.ts index 5283047c0..2611958c4 100644 --- a/src/lib/openapi/routes/import.ts +++ b/src/lib/openapi/routes/import.ts @@ -22,6 +22,7 @@ import { measurementTypeEnum, stdResponses, } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; const csvImportRowResult = z .object({ @@ -71,11 +72,9 @@ const jsonImportMeasurement = z "Checked against the type's server-side plausibility range; an out-of-range value fails the whole request with 422.", ), unit: z.string(), - measuredAt: z.iso - .datetime({ offset: true }) - .describe( - "Bounded: no future instants beyond a 5-min clock-skew tolerance, nothing before 1900.", - ), + measuredAt: isoDateTime().describe( + "Bounded: no future instants beyond a 5-min clock-skew tolerance, nothing before 1900.", + ), glucoseContext: glucoseContextEnum .optional() .describe( @@ -121,8 +120,7 @@ const jsonImportMoodEntry = z .string() .optional() .describe("JSON-encoded array of tag strings, stored verbatim."), - loggedAt: z.iso - .datetime({ offset: true }) + loggedAt: isoDateTime() .optional() .describe( "Bounded like `measuredAt`. Absent → the server derives a stable per-row instant within the `date` day, so a re-import of the same file dedups instead of duplicating.", @@ -186,8 +184,8 @@ const healthConnectImportJob = z .describe( "`queued`, `unpacking`, `parsing`, `upserting`, `done` or `failed`. A plain string, like the Apple Health status.", ), - startedAt: z.iso.datetime({ offset: true }), - completedAt: z.iso.datetime({ offset: true }).nullable(), + startedAt: isoDateTime(), + completedAt: isoDateTime().nullable(), uploadBytes: z.number().int(), progress: z .record(z.string(), z.unknown()) @@ -493,11 +491,10 @@ export const importPaths: NonNullable = { .describe( "`queued`, `unpacking`, `parsing`, `upserting`, `done` or `failed`. A plain string on the wire, not a closed enum — the column is untyped and a future phase name would arrive here without a schema change.", ), - startedAt: z.iso.datetime({ offset: true }), - completedAt: z.iso.datetime({ offset: true }).nullable(), + startedAt: isoDateTime(), + completedAt: isoDateTime().nullable(), uploadBytes: z.number().int(), - exportedAt: z.iso - .datetime({ offset: true }) + exportedAt: isoDateTime() .nullable() .describe( "The instant the Health app stamped on the export itself, once the worker has read it. Null before then.", diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index 534a96116..769d78891 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -70,6 +70,7 @@ import { scoreHistoryResponse, } from "./schemas"; import { recommendationFeedbackRequestSchema } from "@/lib/validations/recommendation-feedback"; +import { isoDateTime } from "@/lib/validations/datetime"; const insightsFeedbackRequest = recommendationFeedbackRequestSchema.meta({ id: "InsightsFeedbackRequest", @@ -355,7 +356,7 @@ export const insightsPaths: NonNullable = { schema: dataEnvelope( z.object({ id: z.string(), - createdAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), }), "InsightsFeedbackCreatedEnvelope", ), diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 325af4af1..a07dc42c8 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -26,6 +26,7 @@ import { SCORE_HISTORY_IDS, SCORE_HISTORY_MAX_DAYS, } from "@/lib/insights/score-history-ids"; +import { isoDateTime } from "@/lib/validations/datetime"; /** The retrospective periods the narrative route accepts, read off the engine. */ const NARRATIVE_PERIOD_VALUES = Object.keys(PERIOD_DAYS); @@ -104,8 +105,7 @@ export const metricStatusResponse = z cached: z .boolean() .describe("True when `text` is served from cache (incl. last-good)."), - updatedAt: z.iso - .datetime({ offset: true }) + updatedAt: isoDateTime() .nullable() .describe("When the served assessment was generated; null when none."), preparing: z @@ -226,9 +226,7 @@ export const wellnessScoreValue = z .describe( "Days that actually carried a score inside the window. Compare against `provenance.windowDays` — a shorter record answers a wide request with fewer days, and this is where that shows.", ), - asOf: z.iso - .datetime({ offset: true }) - .describe("`measuredAt` of the latest score."), + asOf: isoDateTime().describe("`measuredAt` of the latest score."), series: z .array(z.number()) .describe( @@ -327,9 +325,9 @@ export const derivedProvenance = z .number() .int() .describe("Trailing window the value summarises, in days."), - computedAt: z.iso - .datetime({ offset: true }) - .describe("Compute time (for cache-staleness + the 'as of' chip)."), + computedAt: isoDateTime().describe( + "Compute time (for cache-staleness + the 'as of' chip).", + ), }) .meta({ id: "DerivedProvenance" }); @@ -392,9 +390,9 @@ export const derivedAssessment = z .describe( "'deterministic' for the always-on template text, or 'ai' when warmer provider prose has been cached.", ), - updatedAt: z.iso - .datetime({ offset: true }) - .describe("When the text was produced / last warmed."), + updatedAt: isoDateTime().describe( + "When the text was produced / last warmed.", + ), }) .meta({ id: "DerivedAssessment" }); @@ -782,8 +780,7 @@ export const insightStatusResponse = z cached: z .boolean() .describe("True when `text` is served from cache (incl. last-good)."), - updatedAt: z.iso - .datetime({ offset: true }) + updatedAt: isoDateTime() .nullable() .describe("When the served assessment was generated; null when none."), preparing: z @@ -843,8 +840,7 @@ export const biomarkerAssessmentResponse = z cached: z .boolean() .describe("True when `text` is served from cache (incl. last-good)."), - updatedAt: z.iso - .datetime({ offset: true }) + updatedAt: isoDateTime() .nullable() .describe("When the served assessment was generated; null when none."), preparing: z @@ -912,8 +908,7 @@ export const medicationComplianceStatusResponse = z .describe( "True when the envelope is served from cache (incl. last-good).", ), - updatedAt: z.iso - .datetime({ offset: true }) + updatedAt: isoDateTime() .nullable() .describe("When the served assessment was generated; null when none."), preparing: z @@ -1556,7 +1551,7 @@ const insightCard = z .describe( "Suggested follow-ups; empty on the current rule-engine cards.", ), - generatedAt: z.iso.datetime({ offset: true }), + generatedAt: isoDateTime(), provider: z .string() .describe( @@ -1586,9 +1581,9 @@ const ecgClassification = z const ecgRecordingListItem = z .object({ id: z.string().describe("Recording id (cuid)."), - recordedAt: z.iso - .datetime({ offset: true }) - .describe("On-device recording time (the display timestamp)."), + recordedAt: isoDateTime().describe( + "On-device recording time (the display timestamp).", + ), durationSeconds: z .number() .nullable() @@ -1660,9 +1655,9 @@ export const ecgDetailQuery = z export const ecgDetailResponse = z .object({ - recordedAt: z.iso - .datetime({ offset: true }) - .describe("On-device recording time (the display timestamp)."), + recordedAt: isoDateTime().describe( + "On-device recording time (the display timestamp).", + ), durationSeconds: z .number() .nullable() @@ -1717,11 +1712,9 @@ export const ecgIngestRequest = z .describe( "The recording's stable id AS THE CLIENT KNOWS IT — for an Apple Health client, the `HKSample.uuid`. It is the source's own identity, not a content hash: re-posting the same id overwrites that recording in place. It does not have to agree with the id the `export.zip` importer derives for the same strip; the server reconciles the two doors itself (see `status: duplicate`).", ), - recordedAt: z.iso - .datetime({ offset: true }) - .describe( - "When the strip was recorded on-device, with offset. Part of the recording's identity — see `status: duplicate`.", - ), + recordedAt: isoDateTime().describe( + "When the strip was recorded on-device, with offset. Part of the recording's identity — see `status: duplicate`.", + ), samplingFrequency: z .number() .min(1) @@ -1777,9 +1770,9 @@ export const ecgIngestResponse = z .describe( "What the write did. `inserted` — a new recording landed (HTTP 201). `updated` — this `externalRecordingId` was already stored and the row was overwritten in place (HTTP 200); a retry is safe and creates nothing. `duplicate` — this exact recording is already stored under a DIFFERENT id, i.e. it arrived earlier through the `export.zip` importer (HTTP 200); nothing was written and `id` names the row that already holds it. A recording is the same recording when `(source, recordedAt, samplingFrequency)` match, which is enforced by a unique index rather than by convention. All three statuses mean the recording is stored and the client may advance its cursor.", ), - recordedAt: z.iso - .datetime({ offset: true }) - .describe("The `recordedAt` that was submitted, echoed back."), + recordedAt: isoDateTime().describe( + "The `recordedAt` that was submitted, echoed back.", + ), sampleCount: z .number() .int() @@ -1833,9 +1826,9 @@ const rhythmEventListItem = z id: z.string().describe("Measurement row id (cuid)."), type: rhythmEventType, classification: rhythmEventClassification, - occurredAt: z.iso - .datetime({ offset: true }) - .describe("Device-reported event time (`measuredAt`)."), + occurredAt: isoDateTime().describe( + "Device-reported event time (`measuredAt`).", + ), source: measurementSourceEnum, deviceType: z .string() @@ -1931,7 +1924,7 @@ export const dataSummary = z const lastSeenSlot = z .object({ - lastSeenAt: z.iso.datetime({ offset: true }), + lastSeenAt: isoDateTime(), daysAgo: z .number() .int() @@ -2368,8 +2361,7 @@ export const insightsSettingsResponse = z .describe( "The account's own ChatGPT-OAuth connection state; `disconnected` when the account row carries none.", ), - codexConnectedAt: z.iso - .datetime({ offset: true }) + codexConnectedAt: isoDateTime() .nullable() .describe("When that connection was made; null when never connected."), hasAdminKey: z @@ -2397,8 +2389,7 @@ export const insightsSettingsResponse = z .describe( "What leaves for the provider: aggregated figures or raw readings. `aggregated` when the account has never chosen.", ), - lastInsightAt: z.iso - .datetime({ offset: true }) + lastInsightAt: isoDateTime() .nullable() .describe("When the cached insight text was last written."), }) @@ -2604,8 +2595,7 @@ export const insightsGenerateReadResponse = z cached: z .boolean() .describe("True when a payload was served from the cache."), - cachedAt: z.iso - .datetime({ offset: true }) + cachedAt: isoDateTime() .nullable() .optional() .describe( @@ -2673,8 +2663,7 @@ export const insightsGeneratePostResponse = z "The insight payload, fresh or cached depending on `cached`, with its `dailyBriefing` resolved for today exactly as on the read GET. A cached briefing generated on an earlier calendar day never short-circuits an account with a provider: like a briefingless cache, it regenerates, and degrades to the cached payload on an exhausted quota.", ), cached: z.boolean(), - cachedAt: z.iso - .datetime({ offset: true }) + cachedAt: isoDateTime() .optional() .describe("Present on the cached branch only."), legacyPayload: z.boolean(), @@ -2794,7 +2783,7 @@ export const narrativeResponse = z .describe( "Where the text came from — provider prose or the deterministic non-causal fallback a provider-less account gets.", ), - updatedAt: z.iso.datetime({ offset: true }), + updatedAt: isoDateTime(), }) .nullable() .describe( @@ -2842,11 +2831,9 @@ export const glp1TimelineResponse = z entries: z .array( z.object({ - date: z.iso - .datetime({ offset: true }) - .describe( - "When it happened. A `side-effect` entry is a DAY rather than an instant, and is stamped at 12:00 UTC of that day so it sorts — do not read it as a capture time.", - ), + date: isoDateTime().describe( + "When it happened. A `side-effect` entry is a DAY rather than an instant, and is stamped at 12:00 UTC of that day so it sorts — do not read it as a capture time.", + ), kind: z.enum([ "dose-change", "injection", diff --git a/src/lib/openapi/routes/life-circumstances.ts b/src/lib/openapi/routes/life-circumstances.ts index 706107f1b..590dcabd3 100644 --- a/src/lib/openapi/routes/life-circumstances.ts +++ b/src/lib/openapi/routes/life-circumstances.ts @@ -29,6 +29,7 @@ import { recordRefusal, stdResponses, } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; const lifeFactRevision = lifeFactRevisionSchema.meta({ id: "LifeFactRevision", @@ -46,7 +47,7 @@ const auditCCutoff = z.object({ const auditCResult = z .object({ id: z.string(), - takenAt: z.iso.datetime({ offset: true }), + takenAt: isoDateTime(), items: z.tuple([z.number().int(), z.number().int(), z.number().int()]), totalScore: z.number().int().min(0).max(12), locale: z.string(), @@ -92,7 +93,7 @@ const lifeCircumstances = z "GOALS", ]), aiIncluded: z.boolean(), - lastConfirmedAt: z.iso.datetime({ offset: true }).nullable(), + lastConfirmedAt: isoDateTime().nullable(), reviewDue: z .boolean() .describe("Older than 365 days; show the quiet yearly check."), @@ -123,7 +124,7 @@ const lifeCircumstances = z .array( z.object({ id: z.string(), - takenAt: z.iso.datetime({ offset: true }), + takenAt: isoDateTime(), totalScore: z.number().int(), }), ) @@ -218,7 +219,7 @@ export const lifeCircumstancesPaths: NonNullable = { z.enum(LIFE_AI_SECTIONS), z.boolean(), ), - updatedAt: z.iso.datetime({ offset: true }), + updatedAt: isoDateTime(), }), "LifeAiSectionsEnvelope", ), @@ -251,7 +252,7 @@ export const lifeCircumstancesPaths: NonNullable = { schema: dataEnvelope( z.object({ section: z.enum(LIFE_SECTION_KEYS), - lastConfirmedAt: z.iso.datetime({ offset: true }), + lastConfirmedAt: isoDateTime(), }), "LifeConfirmEnvelope", ), @@ -317,7 +318,7 @@ export const lifeCircumstancesPaths: NonNullable = { schema: dataEnvelope( z.object({ id: z.string(), - removedAt: z.iso.datetime({ offset: true }), + removedAt: isoDateTime(), }), "AuditCRemovedEnvelope", ), diff --git a/src/lib/openapi/routes/mcp.ts b/src/lib/openapi/routes/mcp.ts index ca585bd6e..009502db3 100644 --- a/src/lib/openapi/routes/mcp.ts +++ b/src/lib/openapi/routes/mcp.ts @@ -26,6 +26,7 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { createMcpTokenSchema } from "@/lib/validations/mcp"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * The operator kill switch. `isApiGloballyEnabled()` gates every route in this @@ -51,9 +52,8 @@ const mcpConnection = z scope: z .string() .describe("Space-separated scope string granted to this connection."), - createdAt: z.iso.datetime({ offset: true }), - lastUsedAt: z.iso - .datetime({ offset: true }) + createdAt: isoDateTime(), + lastUsedAt: isoDateTime() .nullable() .describe("Null when the connection has never been exercised."), }) @@ -72,9 +72,9 @@ const mcpConnectorToken = z .describe( 'The granted scopes — `["health:read"]` or `["health:read", "health:write"]`. A `health:write` token is audience-bound to `/mcp`: the resource-server guard refuses it on every REST write, so it only admits the confirmed in-process write tools.', ), - lastUsedAt: z.iso.datetime({ offset: true }).nullable(), - expiresAt: z.iso.datetime({ offset: true }).nullable(), - createdAt: z.iso.datetime({ offset: true }), + lastUsedAt: isoDateTime().nullable(), + expiresAt: isoDateTime().nullable(), + createdAt: isoDateTime(), revoked: z .boolean() .describe( @@ -101,7 +101,7 @@ const createMcpTokenResponse = z "The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash, so there is no way to retrieve it later; a client that does not capture it here has to mint a replacement. Treat it as a secret in logs, crash reports and analytics.", ), name: z.string(), - expiresAt: z.iso.datetime({ offset: true }), + expiresAt: isoDateTime(), }) .meta({ id: "CreateMcpTokenResponse", diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index ee7b945b2..0138c5937 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -30,6 +30,7 @@ import { recordWriteRateLimitResponse, stdResponses, } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; const batchEntrySchema = z .object({ @@ -67,8 +68,8 @@ const batchEntrySchema = z .describe( "Apple's `HKUnit` string for `value`, captured for audit: the server records it and never validates `value` against it. `value` is read in Apple's own `HKUnit` for the identifier — `m` for a length, `kg` for body mass, `kcal` for energy, `degC` for temperature, a `fraction` for the percent-shaped ones (oxygen saturation, body fat) — and HealthLog applies its storage scaling after that. A waist circumference sent as `0.86` in `m` is stored as `86` `cm`; an oxygen saturation sent as `0.98` in `fraction` is stored as `98` `%`. Send the sample in Apple's unit, not in HealthLog's.", ), - startDate: z.iso.datetime({ offset: true }), - endDate: z.iso.datetime({ offset: true }), + startDate: isoDateTime(), + endDate: isoDateTime(), sleepStage: z .number() .int() @@ -260,7 +261,7 @@ export const measurementResource = z type: measurementTypeEnum, value: z.number(), unit: z.string(), - measuredAt: z.iso.datetime({ offset: true }), + measuredAt: isoDateTime(), source: measurementSourceEnum, notes: z.string().nullable().optional(), // The list / detail reads hand back the whole stored row minus the @@ -318,13 +319,12 @@ export const measurementResource = z .describe( "Bumped on every server-side mutation; the last-writer-wins input for cross-device reconciliation.", ), - deletedAt: z.iso - .datetime({ offset: true }) + deletedAt: isoDateTime() .nullable() .optional() .describe("Soft-delete tombstone; null on a live row."), - createdAt: z.iso.datetime({ offset: true }).optional(), - updatedAt: z.iso.datetime({ offset: true }).optional(), + createdAt: isoDateTime().optional(), + updatedAt: isoDateTime().optional(), // Synthesised-row extras — present only on the collapsed day-sum mode. dayKey: z.string().optional(), sampleCount: z.number().int().optional(), @@ -364,7 +364,7 @@ const aggregatedMeasurementBucket = z .object({ type: measurementTypeEnum, value: z.number().describe("The bucket's aggregated value."), - measuredAt: z.iso.datetime({ offset: true }).describe("Bucket start."), + measuredAt: isoDateTime().describe("Bucket start."), count: z.number().int().nonnegative().optional(), unit: z.string().optional(), }) @@ -400,8 +400,7 @@ const listMeasurementsMeta = z .describe( "Rows discarded by the cross-source canonical picker on the day-collapse mode.", ), - windowFrom: z.iso - .datetime({ offset: true }) + windowFrom: isoDateTime() .optional() .describe( "Set on the per-night SLEEP_DURATION mode when the requested window was longer than 365 days: the instant the read started from. The window is a year ending at `to` (or now); page further back by moving `to`.", @@ -422,16 +421,16 @@ const sleepStageEnum = z.enum([ const sleepSegmentResource = z.object({ stage: sleepStageEnum.nullable(), - start: z.iso.datetime({ offset: true }), - end: z.iso.datetime({ offset: true }), + start: isoDateTime(), + end: isoDateTime(), minutes: z.number().int().nonnegative(), }); const sleepSessionResource = z.object({ night: z.string().describe("Wake-day key (YYYY-MM-DD)."), source: measurementSourceEnum.nullable(), - start: z.iso.datetime({ offset: true }), - end: z.iso.datetime({ offset: true }), + start: isoDateTime(), + end: isoDateTime(), asleepMinutes: z.number().int().nonnegative(), inBedMinutes: z.number().int().nonnegative().nullable(), awakeMinutes: z.number().int().nonnegative().nullable(), @@ -643,7 +642,7 @@ const seriesPointSchema = z.object({ .describe( "Measurement row id; `sleep:` for a sleep night; `day:` for a day bucket (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation` beyond 90 days); `hour:` for an hour bucket (the same kinds within 90 days when the window holds more than 10 000 readings).", ), - at: z.iso.datetime({ offset: true }).describe("Point timestamp (ISO-8601)."), + at: isoDateTime().describe("Point timestamp (ISO-8601)."), value: z.number().describe("Primary value in the top-level `unit`."), secondary: z .number() @@ -724,11 +723,9 @@ const seriesBatchRowSchema = z.object({ .describe( "The day's mean (spot metrics) or total (cumulative). A row of a coarser tier (a window longer than the daily cap) carries the average day of its bucket, the same quantity: a spot metric's mean of its daily means, a cumulative metric's total over the days of the bucket that hold a reading.", ), - measuredAt: z.iso - .datetime({ offset: true }) - .describe( - "Day-bucket start (ISO-8601): the user's local midnight. A window longer than the daily cap is served from a coarser tier (a UTC day, week or month); its rows are stamped at noon UTC of the bucket's first day, which falls inside the bucket's own week or month in every zone.", - ), + measuredAt: isoDateTime().describe( + "Day-bucket start (ISO-8601): the user's local midnight. A window longer than the daily cap is served from a coarser tier (a UTC day, week or month); its rows are stamped at noon UTC of the bucket's first day, which falls inside the bucket's own week or month in every zone.", + ), count: z .number() .int() diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 7ac5aa6dd..9edc5ea07 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -44,6 +44,7 @@ import { recordWriteRateLimitResponse, stdResponses, } from "../shared"; +import { isoDateTime } from "@/lib/validations/datetime"; // Zod 4's `.meta()` returns a NEW instance carrying the id rather than // annotating in place, so each annotated schema is bound to a const and the @@ -145,12 +146,10 @@ const bulkDeleteIntakeRequest = bulkDeleteIntakeEventsSchema.meta({ const bulkIntakeEntry = z .object({ medicationId: z.string().min(1), - scheduledFor: z.iso - .datetime({ offset: true }) + scheduledFor: isoDateTime() .optional() .describe("ISO instant; defaults to `takenAt` then now() when omitted."), - takenAt: z.iso - .datetime({ offset: true }) + takenAt: isoDateTime() .optional() .describe("ISO instant of the take; omit + `skipped:false` = pending."), skipped: z.boolean().optional().describe("Default false."), @@ -160,8 +159,7 @@ const bulkIntakeEntry = z .describe( "Per-entry injection site; a disallowed site marks THIS entry skipped without failing the batch.", ), - forceSlotInstant: z.iso - .datetime({ offset: true }) + forceSlotInstant: isoDateTime() .optional() .describe( "Pin a taken entry onto a named real scheduled slot; ignored on non-taken entries.", diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index abedc3702..50d1c89f9 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -21,6 +21,7 @@ import { MEDICATION_ORDER_MAX_ENTRIES, } from "@/lib/medication-list-layout"; import { MEDICATION_IMPORT_SKIP_REASONS } from "@/lib/jobs/medication-intake-import"; +import { isoDateTime } from "@/lib/validations/datetime"; // ── Medications (v1.5 scheduling) ──────────────────────────────────── // @@ -278,16 +279,15 @@ export const medicationResource = z .describe( "v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under `http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured.", ), - pausedAt: z.iso.datetime({ offset: true }).nullable(), - snoozedUntil: z.iso.datetime({ offset: true }).nullable(), + pausedAt: isoDateTime().nullable(), + snoozedUntil: isoDateTime().nullable(), remindersActive: z .boolean() .optional() .describe( "Present on the READ paths only (list and single GET). v1.42.2 — whether the server sends dose reminders for this medication: true only when `notificationsEnabled` is on, the medication is `active`, its course has not ended (`endsOn` is today or later on the person's clock, or unset), its intake is tracked (`trackIntake`), it is not `asNeeded`, and `autoMarkTaken` is off. The same decision gates every server-side reminder send. A client that arms local reminders arms them from this, not from `notificationsEnabled`. Read-only — computed, not stored.", ), - nextDueAt: z.iso - .datetime({ offset: true }) + nextDueAt: isoDateTime() .nullable() .optional() .describe( @@ -299,14 +299,12 @@ export const medicationResource = z .describe( "Present on the READ paths only, alongside `nextDueAt`. v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — computed, not stored.", ), - startsOn: z.iso - .datetime({ offset: true }) + startsOn: isoDateTime() .nullable() .describe( "v1.5 course start (ISO date). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL means active from creation.", ), - endsOn: z.iso - .datetime({ offset: true }) + endsOn: isoDateTime() .nullable() .describe( "v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true.", @@ -331,14 +329,13 @@ export const medicationResource = z .describe( "v1.42.2 — doses are recorded automatically. False (the default) for every medication that existed before the field. When true the server records each scheduled dose as taken (an intake with `autoRecorded: true`, `takenAt` = the slot time) shortly after its time passes, from `autoMarkTakenSince` on. A slot the person took, skipped or removed is never touched, and they can still change or skip any dose. No medication reminder is sent for it on any channel while it is on; a client that arms local reminders should not arm them for it either. `notificationsEnabled` keeps the person's own setting and applies again once this is switched off.", ), - autoMarkTakenSince: z.iso - .datetime({ offset: true }) + autoMarkTakenSince: isoDateTime() .nullable() .describe( "v1.42.2 — when `autoMarkTaken` was last switched on. No slot anchored before it is ever recorded automatically. NULL while the option is off. Read-only — set by the server on the switch.", ), - createdAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), + updatedAt: isoDateTime(), schedules: z .array(medicationScheduleResource) .describe( @@ -372,7 +369,7 @@ export const medicationListEntry = medicationResource ), // The list read always computes these two, so they are required here // even though the shared base leaves them optional for the write paths. - nextDueAt: z.iso.datetime({ offset: true }).nullable(), + nextDueAt: isoDateTime().nullable(), nextDueOverdue: z.boolean(), remindersActive: z .boolean() @@ -389,8 +386,7 @@ export const medicationListEntry = medicationResource .describe( "v1.39.4 — whether the medication offers taken / skip today: true only when it is `active`, its intake is tracked (`trackIntake`) and `courseStatus` is CURRENT. Clients render the dose actions from this and do not re-derive it. Logging a past dose from the history is not gated by it. Read-only — computed, not stored.", ), - lastTakenAt: z.iso - .datetime({ offset: true }) + lastTakenAt: isoDateTime() .nullable() .describe( "Latest non-skipped `MedicationIntakeEvent.takenAt` for the medication. Drives the rolling-cadence countdown surface.", @@ -521,20 +517,18 @@ export const medicationInventoryItemResource = z .describe( 'Units left in the container. v1.16.12 — fractional (a ½-tablet dose leaves 29.5 of 30). Decremented by the intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped, edited away, or deleted. v1.18.3 (iOS#31) — NULL when the count is unknown (a corrupt or legacy row); the client renders "unknown", never a fabricated 0 it could decrement into negatives.', ), - firstUseAt: z.iso - .datetime({ offset: true }) + firstUseAt: isoDateTime() .nullable() .describe( "Instant the container was first used. NULL until opened. On a PEN / AMPOULE it starts the 30-day in-use clock; on the other kinds it only records that the pack is open.", ), - expiresAt: z.iso - .datetime({ offset: true }) + expiresAt: isoDateTime() .nullable() .describe( "Persisted MIN(firstUseAt + 30 days, printedExpiry) for a clock-bearing container (PEN / AMPOULE), else printedExpiry alone. NULL when no deadline applies — an opened blister with no printed date has none.", ), - printedExpiry: z.iso.datetime({ offset: true }).nullable(), - purchasedAt: z.iso.datetime({ offset: true }).nullable(), + printedExpiry: isoDateTime().nullable(), + purchasedAt: isoDateTime().nullable(), manufacturer: z .string() .nullable() @@ -548,8 +542,8 @@ export const medicationInventoryItemResource = z 'Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text, not split into a number + unit — pens state strength per dose, per ml, or per cartridge. NULL when unknown.', ), notes: z.string().nullable(), - createdAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), + updatedAt: isoDateTime(), }) .meta({ id: "MedicationInventoryItem", @@ -597,8 +591,8 @@ export const medicationIntakeEventResource = z id: z.string(), userId: z.string(), medicationId: z.string(), - scheduledFor: z.iso.datetime({ offset: true }), - takenAt: z.iso.datetime({ offset: true }).nullable(), + scheduledFor: isoDateTime(), + takenAt: isoDateTime().nullable(), skipped: z.boolean(), autoMissed: z .boolean() @@ -617,8 +611,8 @@ export const medicationIntakeEventResource = z ), source: z.enum(["WEB", "API", "REMINDER", "IMPORT", "APPLE_HEALTH"]), idempotencyKey: z.string().nullable(), - createdAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), + updatedAt: isoDateTime(), injectionSite: z .enum([ "ABDOMEN_LEFT", @@ -658,8 +652,7 @@ export const medicationIntakeEventResource = z .describe( "Monotonic per-row version for incremental sync readers; bumps on every mutation.", ), - deletedAt: z.iso - .datetime({ offset: true }) + deletedAt: isoDateTime() .nullable() .describe( "Soft-delete tombstone. Non-null rows are excluded from every list/aggregate read; sync readers use it to propagate deletions.", @@ -673,9 +666,9 @@ export const medicationIntakeEventResource = z export const medicationCadenceTimelinePoint = z .object({ - day: z.iso.datetime({ offset: true }), - windowStart: z.iso.datetime({ offset: true }), - windowEnd: z.iso.datetime({ offset: true }), + day: isoDateTime(), + windowStart: isoDateTime(), + windowEnd: isoDateTime(), scheduleIndex: z.number().int().nonnegative(), status: z.string(), }) @@ -725,11 +718,11 @@ export const medicationCadenceChips = z export const medicationCadenceResponse = z .object({ windowDays: z.number().int().positive(), - anchorIso: z.iso.datetime({ offset: true }), + anchorIso: isoDateTime(), next: z .object({ - windowStart: z.iso.datetime({ offset: true }), - windowEnd: z.iso.datetime({ offset: true }), + windowStart: isoDateTime(), + windowEnd: isoDateTime(), scheduleIndex: z.number().int().nonnegative(), }) .nullable(), @@ -878,14 +871,12 @@ export const complianceDisplay = z .describe( "Open-cycle state, decoupled from the percentage rows: `on_track` = next dose not yet due; `due` = due now / in grace; `missed` = past grace with no logged intake (the only red state); `none` = no projected next dose (PRN / paused / ended).", ), - nextDueAt: z.iso - .datetime({ offset: true }) + nextDueAt: isoDateTime() .nullable() .describe( "The open cycle's due instant. Null when `state` is `none`.", ), - graceUntil: z.iso - .datetime({ offset: true }) + graceUntil: isoDateTime() .nullable() .describe( "End of the due slot's grace window. Null when `state` is `none`.", @@ -914,8 +905,7 @@ export const complianceDisplay = z .describe( "Per-dose state of the open cycle, server-derived from the window model so the card renders the take-window (green) / overdue / heavily-overdue escalation from one authority. `upcoming` when no dose is open (PRN / paused / ended).", ), - targetAt: z.iso - .datetime({ offset: true }) + targetAt: isoDateTime() .nullable() .describe( "Target instant of the open dose. Null when no dose is open.", @@ -1030,12 +1020,12 @@ export const scheduleRevisionEntrySummary = z export const scheduleRevisionResource = z .object({ id: z.string(), - validFrom: z.iso.datetime().describe("Inclusive start instant of the era."), - validUntil: z.iso - .datetime() - .describe( - "Exclusive end instant of the era — the moment the next plan took over.", - ), + validFrom: isoDateTime({ offset: false }).describe( + "Inclusive start instant of the era.", + ), + validUntil: isoDateTime({ offset: false }).describe( + "Exclusive end instant of the era — the moment the next plan took over.", + ), source: z .enum(["ARCHIVED", "MANUAL"]) .describe( @@ -1050,11 +1040,9 @@ export const scheduleRevisionResource = z }); export const scheduleRevisionListResponse = z.object({ - currentSince: z.iso - .datetime() - .describe( - "Instant the LIVE plan took over: the newest revision's `validUntil`, or the medication's `createdAt` when no era has been archived.", - ), + currentSince: isoDateTime({ offset: false }).describe( + "Instant the LIVE plan took over: the newest revision's `validUntil`, or the medication's `createdAt` when no era has been archived.", + ), revisions: z .array(scheduleRevisionResource) .describe("Archived eras, newest first."), @@ -1099,14 +1087,12 @@ export const medicationExtractRequest = z // (iOS #64) adds `intake.source` so a client can label how each dose was // recorded. export const doseHistoryQuery = z.object({ - from: z.iso - .datetime({ offset: true }) + from: isoDateTime() .optional() .describe( "Window start (inclusive). Defaults to 90 days before `to`; clamped to a 366-day span floor. Recorded doses are returned from this instant. A slot from before the medication's `createdAt` appears only when a recorded row (a take, a skip, or a miss carried in with restored history) claims it; the server's own placeholder for such a slot is not returned.", ), - to: z.iso - .datetime({ offset: true }) + to: isoDateTime() .optional() .describe( "Window end (inclusive). Defaults to now. Must be ≥ `from`. A dose recorded ahead of its slot (the slot's time within a day after `to`) is still returned.", @@ -1120,9 +1106,9 @@ const doseHistoryRow = z .describe( "`slot` — a scheduled dose window; `ad_hoc` — a standalone off-schedule intake.", ), - at: z.iso - .datetime({ offset: true }) - .describe("The slot anchor instant, or the ad-hoc take's own time."), + at: isoDateTime().describe( + "The slot anchor instant, or the ad-hoc take's own time.", + ), timeOfDay: z .string() .nullable() @@ -1143,7 +1129,7 @@ const doseHistoryRow = z ), nearestSlot: z .object({ - at: z.iso.datetime({ offset: true }), + at: isoDateTime(), timeOfDay: z.string(), filled: z.boolean(), }) @@ -1154,8 +1140,8 @@ const doseHistoryRow = z intake: z .object({ id: z.string().nullable(), - scheduledFor: z.iso.datetime({ offset: true }), - takenAt: z.iso.datetime({ offset: true }).nullable(), + scheduledFor: isoDateTime(), + takenAt: isoDateTime().nullable(), skipped: z.boolean(), autoMissed: z.boolean(), autoRecorded: z @@ -1181,16 +1167,12 @@ const doseHistoryRow = z export const doseHistoryResponse = z .object({ - from: z.iso - .datetime({ offset: true }) - .describe( - "Start of the window recorded doses were read over: the requested `from` after the span clamp.", - ), - to: z.iso - .datetime({ offset: true }) - .describe( - "End of the window recorded doses were read over: the requested `to`, or the slot time of a dose recorded ahead of its slot when that lies later (at most one day after `to`).", - ), + from: isoDateTime().describe( + "Start of the window recorded doses were read over: the requested `from` after the span clamp.", + ), + to: isoDateTime().describe( + "End of the window recorded doses were read over: the requested `to`, or the slot time of a dose recorded ahead of its slot when that lies later (at most one day after `to`).", + ), family: z .enum(["daily", "weekly", "one_shot", "none"]) .describe("Cadence family the window's slots were minted under."), @@ -1251,8 +1233,7 @@ export const medicationListLayoutPutBody = medicationListLayoutSchema export const medicationListLayoutResult = medicationListLayoutSchema .extend({ - updatedAt: z.iso - .datetime({ offset: true }) + updatedAt: isoDateTime() .optional() .describe( "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque.", @@ -1404,13 +1385,11 @@ export const medicationIntakeImportJobStatusResponse = z .string() .nullable() .describe("Set only when `status` is `failed`; null otherwise."), - createdAt: z.iso.datetime({ offset: true }), - startedAt: z.iso - .datetime({ offset: true }) + createdAt: isoDateTime(), + startedAt: isoDateTime() .nullable() .describe("When the worker picked the job up; null while still queued."), - completedAt: z.iso - .datetime({ offset: true }) + completedAt: isoDateTime() .nullable() .describe("When the job reached `done` or `failed`; null while active."), }) @@ -1539,19 +1518,16 @@ export const todayIntakeEntry = z .object({ id: z.string().describe("Intake-event id."), medicationId: z.string(), - scheduledAt: z.iso - .datetime({ offset: true }) - .describe( - "The slot the dose belongs to — the event's `scheduledFor`, renamed on this wire only.", - ), - takenAt: z.iso.datetime({ offset: true }).nullable(), + scheduledAt: isoDateTime().describe( + "The slot the dose belongs to — the event's `scheduledFor`, renamed on this wire only.", + ), + takenAt: isoDateTime().nullable(), status: z .enum(["skipped", "taken", "missed", "pending", "snoozed"]) .describe( "Resolved in that precedence order. `missed` is TERMINAL — the nightly cron closed a never-acted slot — and is not a `pending` that can still be answered. `snoozed` is not a property of this event at all: it reflects the MEDICATION's deferral stamp still lying in the future, so every pending dose of that medication reads snoozed at once.", ), - snoozedUntil: z.iso - .datetime({ offset: true }) + snoozedUntil: isoDateTime() .nullable() .describe( "The medication's deferral stamp, repeated on each of its events. Null when the medication is not deferred.", @@ -1585,7 +1561,7 @@ export const complianceDayBucket = z export const glp1DoseChange = z .object({ id: z.string(), - effectiveFrom: z.iso.datetime({ offset: true }), + effectiveFrom: isoDateTime(), doseValue: z.number(), doseUnit: z.string(), note: z @@ -1642,7 +1618,7 @@ export const glp1DetailResponse = z recentIntakes: z .array( z.object({ - takenAt: z.iso.datetime({ offset: true }).nullable(), + takenAt: isoDateTime().nullable(), injectionSite: z .string() .nullable() @@ -1670,7 +1646,7 @@ export const glp1InventoryEvent = z medicationId: z.string(), delta: z.number().int().describe("Positive added, negative consumed."), reason: z.string(), - occurredAt: z.iso.datetime({ offset: true }), + occurredAt: isoDateTime(), }) .meta({ id: "Glp1InventoryEvent", @@ -1776,7 +1752,7 @@ export const medicationSideEffect = z "The RECORD owner, which is not the actor on a delegated write.", ), medicationId: z.string(), - occurredAt: z.iso.datetime({ offset: true }), + occurredAt: isoDateTime(), category: z .string() .describe( @@ -1790,7 +1766,7 @@ export const medicationSideEffect = z .describe( "The free-text note, decrypted on read. Stored encrypted; the ciphertext column is stripped before the row leaves the server.", ), - createdAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), }) .meta({ id: "MedicationSideEffect", diff --git a/src/lib/openapi/routes/meta.ts b/src/lib/openapi/routes/meta.ts index 088cb1032..6f9430ccc 100644 --- a/src/lib/openapi/routes/meta.ts +++ b/src/lib/openapi/routes/meta.ts @@ -9,6 +9,7 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; // v1.10.2 — live capability / discovery response. Every list is sourced // server-side from the canonical registry it documents, so the wire shape @@ -221,8 +222,7 @@ const versionResponse = z .describe( "Short Git SHA baked at image build time. Null for a local dev run, which is how a client tells a released image from one.", ), - builtAt: z.iso - .datetime({ offset: true }) + builtAt: isoDateTime() .nullable() .describe("ISO-8601 build timestamp. Null for a local dev run."), license: z.string(), @@ -297,13 +297,11 @@ const updateCheckResponse = z .nullable() .optional() .describe("Release page to deep-link to. Only on `newer_available`."), - published_at: z.iso - .datetime({ offset: true }) + published_at: isoDateTime() .nullable() .optional() .describe("When that release was published. Only on `newer_available`."), - checked_at: z.iso - .datetime({ offset: true }) + checked_at: isoDateTime() .optional() .describe("When the check ran. Absent when `unknown`."), reason: z diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts index bcae9fb38..38ffde670 100644 --- a/src/lib/openapi/routes/mood.ts +++ b/src/lib/openapi/routes/mood.ts @@ -62,6 +62,7 @@ import { updatedAtTokenField, } from "./shared"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { isoDateTime } from "@/lib/validations/datetime"; // ── Request schemas — annotated for spec emission ──────────────────── // @@ -303,7 +304,7 @@ export const moodContextWire = z .max(5) .nullish() .describe("-5 (clearly bad) to +5 (clearly good)."), - eventAt: z.iso.datetime({ offset: true }).nullish(), + eventAt: isoDateTime().nullish(), note: z .string() .max(500) @@ -374,9 +375,7 @@ const bulkMoodEntry = z .describe( "The day's context. Absent leaves a stored context untouched on a re-post; an empty one removes it.", ), - moodLoggedAt: z.iso - .datetime({ offset: true }) - .describe("ISO instant the entry was logged."), + moodLoggedAt: isoDateTime().describe("ISO instant the entry was logged."), source: moodSourceEnum.optional().describe("Defaults to MANUAL."), externalId: z .string() @@ -515,16 +514,16 @@ const moodEntryDto = z .string() .nullable() .describe("The sync client's source-stable dedup id, or null."), - moodLoggedAt: z.iso.datetime({ offset: true }), + moodLoggedAt: isoDateTime(), tz: z .string() .nullable() .describe( `IANA zone the \`date\` label is anchored to. Null on legacy rows (read as ${DEFAULT_TIMEZONE}).`, ), - syncedAt: z.iso.datetime({ offset: true }), - createdAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), + syncedAt: isoDateTime(), + createdAt: isoDateTime(), + updatedAt: isoDateTime(), syncVersion: z .number() .int() @@ -712,7 +711,7 @@ const moodPrognosisPresent = z.object({ "Complete days the fit used. Display it WHEREVER `predicted` is displayed: a value built from 30 days and one built from 300 look identical otherwise.", ), modelVersion: z.string(), - computedAt: z.iso.datetime({ offset: true }), + computedAt: isoDateTime(), ageDays: z .number() .int() diff --git a/src/lib/openapi/routes/notifications.ts b/src/lib/openapi/routes/notifications.ts index da0d64aad..45ce29dbc 100644 --- a/src/lib/openapi/routes/notifications.ts +++ b/src/lib/openapi/routes/notifications.ts @@ -25,6 +25,7 @@ import { reEnableChannelSchema, } from "@/lib/validations/notifications"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; const channelTypeEnum = z .enum(CHANNEL_TYPES) @@ -108,11 +109,10 @@ const channelDeliveryStatus = z "Why the dispatcher auto-disabled the channel; null when it did not. This is the field that separates `auto_disabled` from `manually_disabled`.", ), consecutiveFailures: z.number().int(), - lastSuccessAt: z.iso.datetime({ offset: true }).nullable(), - lastFailureAt: z.iso.datetime({ offset: true }).nullable(), + lastSuccessAt: isoDateTime().nullable(), + lastFailureAt: isoDateTime().nullable(), lastFailureReason: z.string().nullable(), - nextRetryAt: z.iso - .datetime({ offset: true }) + nextRetryAt: isoDateTime() .nullable() .describe("End of the current cooldown; null when not paused."), }) @@ -124,8 +124,7 @@ const channelDeliveryStatus = z const eventDeliveryStatus = z .object({ - lastDeliveredAt: z.iso - .datetime({ offset: true }) + lastDeliveredAt: isoDateTime() .nullable() .describe("Null when this category has never been delivered."), }) diff --git a/src/lib/openapi/routes/onboarding.ts b/src/lib/openapi/routes/onboarding.ts index d306cdc26..bbff1c01e 100644 --- a/src/lib/openapi/routes/onboarding.ts +++ b/src/lib/openapi/routes/onboarding.ts @@ -37,6 +37,7 @@ import { onboardingRestartSchema, } from "@/lib/validations/onboarding-needs"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; /* ── v1.39 (C1): the needs-based flow ─────────────────────────────────────── */ @@ -97,8 +98,7 @@ const onboardingFirstResultResource = z .describe( "What the task is about — a source key for a connection, an area key for a reading. Null for the medication task.", ), - completedAt: z.iso - .datetime({ offset: true }) + completedAt: isoDateTime() .nullable() .describe("When the offered task produced its result, or null."), }) @@ -112,8 +112,7 @@ export const onboardingStateResource = z .object({ steps: z.array(onboardingStepResource), needs: onboardingNeedsResource, - completedAt: z.iso - .datetime({ offset: true }) + completedAt: isoDateTime() .nullable() .describe( "When the needs flow was completed. Distinct from the account payload's `onboardingCompletedAt`, which stays the first-run redirect's gate: `POST /api/onboarding/restart` clears this one and leaves that one alone.", diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index de83b2e61..cf4e37f81 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -60,6 +60,7 @@ import { updatedAtTokenField, validationIssue, } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; // v1.18.0 — module enable/disable. The PATCH request is the REAL runtime // validator (`modulePrefsPatchSchema` — strict over the toggleable key @@ -412,8 +413,7 @@ const healthScoreConfigResponse = z .describe( "Per-user recipe version, monotonic and incremented on every accepted write. 0 while no selection has been written. A score record names the version that produced it, so a comparison can see the recipe move.", ), - changedAt: z.iso - .datetime({ offset: true }) + changedAt: isoDateTime() .nullable() .describe("When the recipe last changed; null while it never has."), updatedAt: updatedAtTokenField, @@ -542,7 +542,7 @@ const profileResponse = z username: z.string(), displayName: z.string().nullable().optional(), email: z.string().nullable(), - dateOfBirth: z.iso.datetime({ offset: true }).nullable(), + dateOfBirth: isoDateTime().nullable(), gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(), heightCm: z.number().nullable(), locale: z.string().nullable(), @@ -590,7 +590,7 @@ const profileUpdateResponse = z username: z.string(), displayName: z.string().nullable().optional(), email: z.string().nullable(), - dateOfBirth: z.iso.datetime({ offset: true }).nullable(), + dateOfBirth: isoDateTime().nullable(), gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(), heightCm: z.number().nullable(), locale: z.string().nullable(), @@ -905,8 +905,8 @@ const deviceInfo = z .describe( "Which push transports this device row can currently be reached on. Derived from which token columns are populated, not from a stored list.", ), - lastSeenAt: z.iso.datetime({ offset: true }), - createdAt: z.iso.datetime({ offset: true }), + lastSeenAt: isoDateTime(), + createdAt: isoDateTime(), isCurrent: z .boolean() .describe( @@ -1526,7 +1526,7 @@ export const profilePaths: NonNullable = { "image/png", "image/webp", ]), - updatedAt: z.iso.datetime({ offset: true }), + updatedAt: isoDateTime(), }), "UploadAvatarResponse", ), diff --git a/src/lib/openapi/routes/record-settings.ts b/src/lib/openapi/routes/record-settings.ts index fc7dde72d..9f61eddf7 100644 --- a/src/lib/openapi/routes/record-settings.ts +++ b/src/lib/openapi/routes/record-settings.ts @@ -32,6 +32,7 @@ import { recordRefusal, stdResponses, } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; const thresholdMetricEnum = z .enum(Object.keys(METRIC_BOUNDS) as [string, ...string[]]) @@ -95,8 +96,8 @@ const recordSettingsIntegrationsResponse = z "disconnected", "parked", ]), - lastSuccessAt: z.iso.datetime({ offset: true }).nullable(), - lastAttemptAt: z.iso.datetime({ offset: true }).nullable(), + lastSuccessAt: isoDateTime().nullable(), + lastAttemptAt: isoDateTime().nullable(), health: connectionHealth .nullable() .describe( diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 3a9227705..c8722ac9c 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -17,6 +17,7 @@ import { import { loginPasswordSchema as loginPasswordSchemaBase } from "@/lib/validations/auth"; import { coachPrefsSchema as coachPrefsSchemaBase } from "@/lib/validations/coach-prefs"; import { createBatchWorkoutSchema as createBatchWorkoutSchemaBase } from "@/lib/validations/workout"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * Common envelopes — every HealthLog API response wraps payload in @@ -197,8 +198,7 @@ export const createBatchWorkoutSchema = createBatchWorkoutSchemaBase.meta({ * OpenAPI layer: the runtime strips it pre-Zod (`takeBaseToken`), so the * runtime schema alone would under-document the wire. */ -export const baseUpdatedAtField = z.iso - .datetime({ offset: true }) +export const baseUpdatedAtField = isoDateTime() .optional() .describe( "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored row still carrying this exact value — a stale token fails with 409 and changes nothing. A present-but-unparseable value fails with 422 and `meta.errorCode` = `invalid_base_updated_at` — note that this is NOT the unconditional write: sending `null` or a malformed string is rejected rather than treated as an omitted token. Treat as opaque: only ever echo a server-returned value, never parse or synthesise it.", @@ -209,8 +209,7 @@ export const baseUpdatedAtField = z.iso * Every guarded GET / write response carries the stored row's `updatedAt`; the * client echoes it back as `baseUpdatedAt` on the next write. */ -export const updatedAtTokenField = z.iso - .datetime({ offset: true }) +export const updatedAtTokenField = isoDateTime() .optional() .describe( "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque — only ever echo a server-returned value, never parse or synthesise it.", diff --git a/src/lib/openapi/routes/sync.ts b/src/lib/openapi/routes/sync.ts index ce59001c3..0a690d5f8 100644 --- a/src/lib/openapi/routes/sync.ts +++ b/src/lib/openapi/routes/sync.ts @@ -11,6 +11,7 @@ import { measurementResource } from "./measurements"; import { moodContextWire } from "./mood"; import { cycleDayLogDto, menstrualCycleDto } from "./cycle"; import { dataEnvelope, stdResponses } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; // ── Sync (v1.7.0 offline / server-optional) ───────────────────────── @@ -18,20 +19,20 @@ const syncStateResponse = z .object({ userId: z.string(), timezone: z.string(), - lastSyncedAt: z.iso.datetime({ offset: true }).nullable(), - serverNow: z.iso.datetime({ offset: true }), + lastSyncedAt: isoDateTime().nullable(), + serverNow: isoDateTime(), measurements: z.object({ - lastUpdatedAt: z.iso.datetime({ offset: true }).nullable(), + lastUpdatedAt: isoDateTime().nullable(), liveCount: z.number().int().nonnegative(), tombstonedCount: z.number().int().nonnegative(), }), mood: z.object({ - lastUpdatedAt: z.iso.datetime({ offset: true }).nullable(), + lastUpdatedAt: isoDateTime().nullable(), liveCount: z.number().int().nonnegative(), tombstonedCount: z.number().int().nonnegative(), }), intakes: z.object({ - lastUpdatedAt: z.iso.datetime({ offset: true }).nullable(), + lastUpdatedAt: isoDateTime().nullable(), liveCount: z.number().int().nonnegative(), tombstonedCount: z.number().int().nonnegative(), }), @@ -73,7 +74,7 @@ const syncMeasurementUpsert = measurementResource .describe( "LWW reconciliation counter; echo to keep the mirror monotonic.", ), - updatedAt: z.iso.datetime({ offset: true }), + updatedAt: isoDateTime(), }) .meta({ id: "SyncMeasurementUpsert" }); @@ -85,8 +86,8 @@ const syncMeasurementTombstone = z .nullable() .describe("The identity key the client dedups on for measurements."), syncVersion: z.number().int().positive(), - deletedAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), + deletedAt: isoDateTime(), + updatedAt: isoDateTime(), }) .meta({ id: "SyncMeasurementTombstone", @@ -124,14 +125,14 @@ const syncMoodUpsert = z ), tags: z.string().nullable().describe("JSON array of tag keys, or null."), note: z.string().nullable(), - moodLoggedAt: z.iso.datetime({ offset: true }), + moodLoggedAt: isoDateTime(), source: z.string(), syncVersion: z .number() .int() .nonnegative() .describe("LWW reconciliation counter; mood is last-writer-wins by it."), - updatedAt: z.iso.datetime({ offset: true }), + updatedAt: isoDateTime(), }) .meta({ id: "SyncMoodUpsert" }); @@ -141,8 +142,8 @@ const syncMoodTombstone = z .string() .describe("Server id — the identity key the client dedups on for mood."), syncVersion: z.number().int().nonnegative(), - deletedAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), + deletedAt: isoDateTime(), + updatedAt: isoDateTime(), }) .meta({ id: "SyncMoodTombstone", @@ -154,8 +155,8 @@ const syncIntakeUpsert = z .object({ id: z.string(), medicationId: z.string(), - scheduledFor: z.iso.datetime({ offset: true }), - takenAt: z.iso.datetime({ offset: true }).nullable(), + scheduledFor: isoDateTime(), + takenAt: isoDateTime().nullable(), skipped: z.boolean(), source: z.string(), autoRecorded: z @@ -170,7 +171,7 @@ const syncIntakeUpsert = z .describe( "Reconciliation counter. An intake is immutable; a correction is a tombstone + re-insert.", ), - updatedAt: z.iso.datetime({ offset: true }), + updatedAt: isoDateTime(), }) .meta({ id: "SyncIntakeUpsert" }); @@ -182,8 +183,8 @@ const syncIntakeTombstone = z "Server id — the identity key the client dedups on for intakes.", ), syncVersion: z.number().int().nonnegative(), - deletedAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), + deletedAt: isoDateTime(), + updatedAt: isoDateTime(), }) .meta({ id: "SyncIntakeTombstone", @@ -199,8 +200,8 @@ const syncCycleDayTombstone = z .nullable() .describe("The client's own key for the day log, when it set one."), syncVersion: z.number().int().nonnegative(), - deletedAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), + deletedAt: isoDateTime(), + updatedAt: isoDateTime(), }) .meta({ id: "SyncCycleDayTombstone", @@ -212,8 +213,8 @@ const syncCycleTombstone = z .object({ id: z.string(), syncVersion: z.number().int().nonnegative(), - deletedAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), + deletedAt: isoDateTime(), + updatedAt: isoDateTime(), }) .meta({ id: "SyncCycleTombstone", @@ -251,7 +252,7 @@ const syncChangesQuery = z */ export const syncChangesResponse = z .object({ - serverNow: z.iso.datetime({ offset: true }), + serverNow: isoDateTime(), cursor: z .string() .nullable() diff --git a/src/lib/openapi/routes/vaccinations.ts b/src/lib/openapi/routes/vaccinations.ts index 1cb6c1730..bbc34d674 100644 --- a/src/lib/openapi/routes/vaccinations.ts +++ b/src/lib/openapi/routes/vaccinations.ts @@ -44,6 +44,7 @@ import { recordWriteRateLimitResponse, stdResponses, } from "./shared"; +import { isoDateTime } from "@/lib/validations/datetime"; // Zod 4's `.meta()` returns a NEW instance carrying the id rather than // annotating in place, so each annotated schema is bound to a const and the @@ -164,8 +165,8 @@ const customVaccine = z ), typicalSeriesDoses: z.number().int().nullable(), boosterIntervalMonths: z.number().int().nullable(), - createdAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), + updatedAt: isoDateTime(), }) .meta({ id: "CustomVaccine", diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index e3c4678a1..a66e14b6d 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -20,6 +20,7 @@ import { stdResponses, } from "./shared"; import { aiCapabilityState } from "./profile"; +import { isoDateTime } from "@/lib/validations/datetime"; // v1.4.25 W16b — typed workout batch ingest response envelope. Mirrors // the measurements batch shape but reports the `workouts` count rather @@ -94,8 +95,8 @@ const workoutListEntry = z // session can arrive without a sport type or timing. Declaring them // non-null was a strict-decoder bomb for the Swift client. sportType: z.string().nullable(), - startedAt: z.iso.datetime({ offset: true }).nullable(), - endedAt: z.iso.datetime({ offset: true }).nullable(), + startedAt: isoDateTime().nullable(), + endedAt: isoDateTime().nullable(), durationSec: z.number().int().nonnegative().nullable(), distanceM: z.number().nullable(), activeEnergyKcal: z.number().nullable(), @@ -198,7 +199,7 @@ const workoutActivityInsight = z .object({ /** Plain text. No markup of any kind — clients render it as text. */ paragraph: z.string(), - generatedAt: z.iso.datetime({ offset: true }), + generatedAt: isoDateTime(), }) .meta({ id: "WorkoutActivityInsight" }); @@ -206,8 +207,8 @@ const workoutDetailResponse = z .object({ id: z.string(), sportType: z.string(), - startedAt: z.iso.datetime({ offset: true }), - endedAt: z.iso.datetime({ offset: true }), + startedAt: isoDateTime(), + endedAt: isoDateTime(), /** * The local calendar day the session belongs to, `YYYY-MM-DD`, resolved * server-side from `startedAt` in the user's timezone. Clients address @@ -280,8 +281,8 @@ export const workoutPaths: NonNullable = { query: z.object({ limit: z.coerce.number().int().min(1).max(200).optional(), offset: z.coerce.number().int().min(0).optional(), - since: z.iso.datetime({ offset: true }).optional(), - until: z.iso.datetime({ offset: true }).optional(), + since: isoDateTime().optional(), + until: isoDateTime().optional(), sportType: z.string().optional(), }), }, diff --git a/src/lib/symptoms/shared.ts b/src/lib/symptoms/shared.ts index 60e9b17b7..5738d3467 100644 --- a/src/lib/symptoms/shared.ts +++ b/src/lib/symptoms/shared.ts @@ -2,6 +2,7 @@ import { z } from "zod/v4"; import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared"; import { validateEntryInstant } from "@/lib/validations/entry-instant"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * Person-defined symptoms (v1.40): client-safe constants, wire types and the @@ -108,8 +109,7 @@ const intensitySchema = z .max(SYMPTOM_INTENSITY_MAX); const occurredAtSchema = validateEntryInstant( - z.iso - .datetime({ offset: true }) + isoDateTime() .transform((s) => new Date(s)) .pipe(z.date()), ); @@ -141,8 +141,8 @@ export const symptomEventUpdateSchema = z ); export const symptomEventListQuerySchema = z.object({ - from: z.iso.datetime({ offset: true }).optional(), - to: z.iso.datetime({ offset: true }).optional(), + from: isoDateTime().optional(), + to: isoDateTime().optional(), definitionId: z.string().min(1).max(40).optional(), episodeId: z.string().min(1).max(40).optional(), limit: z.coerce diff --git a/src/lib/validations/account-sharing.ts b/src/lib/validations/account-sharing.ts index 5e47ad567..6395ac832 100644 --- a/src/lib/validations/account-sharing.ts +++ b/src/lib/validations/account-sharing.ts @@ -13,6 +13,7 @@ import { z } from "zod/v4"; import { SHARE_DOMAINS } from "@/lib/sharing/scope"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * How long a value naming an account may be. @@ -41,7 +42,7 @@ export const inviteGrantSchema = z * ends it — the common household case, where an expiry the owner has to * remember to renew is worse than one that does not exist. */ - expiresAt: z.iso.datetime({ offset: true }).nullable().optional(), + expiresAt: isoDateTime().nullable().optional(), /** * What the invitation offers, and the ONLY place the level is defaulted. * diff --git a/src/lib/validations/allergy.ts b/src/lib/validations/allergy.ts index 8f336ed20..956f7585c 100644 --- a/src/lib/validations/allergy.ts +++ b/src/lib/validations/allergy.ts @@ -14,17 +14,19 @@ */ import { z } from "zod/v4"; import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * Plausible-instant bound shared with the measurement / illness paths: no * future instant beyond the 5-min skew, no instant before 1900. Stays a * string on the wire (iOS reads it back verbatim). */ -const boundedInstant = z.iso - .datetime({ offset: true }) - .refine((s) => isPlausibleEntryInstant(new Date(s)), { +const boundedInstant = isoDateTime().refine( + (s) => isPlausibleEntryInstant(new Date(s)), + { message: "must be a plausible instant (not future, not pre-1900)", - }); + }, +); /* ── enums (mirror the Prisma enums) ─────────────────────────────── */ diff --git a/src/lib/validations/clinician-share-link.ts b/src/lib/validations/clinician-share-link.ts index cbe22ff72..c04883074 100644 --- a/src/lib/validations/clinician-share-link.ts +++ b/src/lib/validations/clinician-share-link.ts @@ -22,6 +22,7 @@ import { z } from "zod/v4"; import { reportSelectionSchema } from "@/lib/report-selection/selection"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * The one leaf a share link may never carry. The share view has never @@ -56,8 +57,8 @@ export const SHARE_LINK_MAX_DOCUMENTS = 50; export const createShareLinkSchema = z .object({ label: z.string().trim().min(1).max(120), - rangeStart: z.iso.datetime({ offset: true }), - rangeEnd: z.iso.datetime({ offset: true }).nullable().optional(), + rangeStart: isoDateTime(), + rangeEnd: isoDateTime().nullable().optional(), selection: reportSelectionSchema.optional(), documentIds: z .array(z.string().trim().min(1).max(64)) @@ -72,8 +73,7 @@ export const createShareLinkSchema = z * server-side so a document link can never widen into a record. */ documentOnly: z.boolean().optional(), - expiresAt: z.iso - .datetime({ offset: true }) + expiresAt: isoDateTime() .refine((v) => new Date(v).getTime() > Date.now(), { message: "expiresAt must be in the future", }) diff --git a/src/lib/validations/coach-plan.ts b/src/lib/validations/coach-plan.ts index 75b37f4a3..a9c65a8b2 100644 --- a/src/lib/validations/coach-plan.ts +++ b/src/lib/validations/coach-plan.ts @@ -16,6 +16,7 @@ import { z } from "zod/v4"; import { COACH_PLAN_STATUSES } from "@/lib/ai/coach/plans"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * Optional `?scope=` filter on the list endpoint — a named status group so @@ -48,7 +49,7 @@ export const coachPlansListQuerySchema = z export const coachPlanPatchSchema = z .object({ status: z.enum(COACH_PLAN_STATUSES).optional(), - reviewDate: z.iso.datetime({ offset: true }).nullable().optional(), + reviewDate: isoDateTime().nullable().optional(), }) .strict() .refine( diff --git a/src/lib/validations/consent.ts b/src/lib/validations/consent.ts index ffd154cb4..aac483e5e 100644 --- a/src/lib/validations/consent.ts +++ b/src/lib/validations/consent.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * v1.4.40 SB-10 — AI consent receipts (App-Store Guideline 5.1.2(i) + @@ -51,7 +52,7 @@ export const consentPostBody = z.object({ .refine((value) => Buffer.byteLength(value, "utf8") <= ARTEFACT_MAX_BYTES, { message: "artefact exceeds 64 KB cap (UTF-8 byte length)", }), - signedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + signedAt: isoDateTime().transform((s) => new Date(s)), }); export type ConsentPostBody = z.infer; diff --git a/src/lib/validations/custom-metrics.ts b/src/lib/validations/custom-metrics.ts index 6b4367c24..5670ec9ff 100644 --- a/src/lib/validations/custom-metrics.ts +++ b/src/lib/validations/custom-metrics.ts @@ -17,6 +17,7 @@ import { z } from "zod/v4"; import { validateEntryInstant } from "@/lib/validations/entry-instant"; +import { isoDateTime } from "@/lib/validations/datetime"; /** Trimmed, bounded free-text field with a min-length guard. */ const requiredText = (max: number) => z.string().trim().min(1).max(max); @@ -98,10 +99,10 @@ const ENTRY_MAX_AGE_MS = 50 * 365.25 * 24 * 60 * 60 * 1000; /** Shared `measuredAt` field: ISO string → Date, plausibility-bounded. */ const measuredAtField = validateEntryInstant( - z - .string() - .datetime({ message: "measuredAt must be an ISO 8601 datetime" }) - .transform((s) => new Date(s)), + isoDateTime({ + offset: false, + message: "measuredAt must be an ISO 8601 datetime", + }).transform((s) => new Date(s)), { maxAgeMs: ENTRY_MAX_AGE_MS, pastMessage: "Measurement date is too far in the past", diff --git a/src/lib/validations/cycle.ts b/src/lib/validations/cycle.ts index 86fbd8cab..e01d0c842 100644 --- a/src/lib/validations/cycle.ts +++ b/src/lib/validations/cycle.ts @@ -12,6 +12,7 @@ import { z } from "zod/v4"; import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant"; import { assertStableExternalId } from "@/lib/validations/external-id"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * v1.17 W1b — `loggedAt` instant on the cycle wire stays a string (the iOS @@ -21,11 +22,12 @@ import { assertStableExternalId } from "@/lib/validations/external-id"; * the measurement / mood / medication-intake paths use: no future instant * beyond the 5-min skew, no instant before 1900. */ -const boundedLoggedAt = z.iso - .datetime({ offset: true }) - .refine((s) => isPlausibleEntryInstant(new Date(s)), { +const boundedLoggedAt = isoDateTime().refine( + (s) => isPlausibleEntryInstant(new Date(s)), + { message: "loggedAt must be a plausible instant (not future, not pre-1900)", - }); + }, +); /* ── enums (mirror the Prisma enums) ─────────────────────────────── */ diff --git a/src/lib/validations/encounters.ts b/src/lib/validations/encounters.ts index 5bd8f3cb3..ddf2deca1 100644 --- a/src/lib/validations/encounters.ts +++ b/src/lib/validations/encounters.ts @@ -18,6 +18,7 @@ * park a row a century out. */ import { z } from "zod/v4"; +import { isoDateTime } from "@/lib/validations/datetime"; /** Ten years out. Long enough for any real appointment, short enough that a * mistyped year lands as a 422 rather than as a row nobody sees again. */ @@ -30,7 +31,7 @@ const MAX_FUTURE_MS = 10 * 365 * 24 * 60 * 60 * 1000; * five-minute clock skew. That bound is right for a measurement, which can only * describe something that already happened, and wrong here. */ -const visitInstant = z.iso.datetime({ offset: true }).refine( +const visitInstant = isoDateTime().refine( (value) => { const at = new Date(value).getTime(); if (Number.isNaN(at)) return false; @@ -150,8 +151,8 @@ export type EncounterUpdate = z.infer; */ export const encounterListQuerySchema = z .object({ - from: z.iso.datetime({ offset: true }).optional(), - to: z.iso.datetime({ offset: true }).optional(), + from: isoDateTime().optional(), + to: isoDateTime().optional(), status: encounterStatusEnum.optional(), practitionerId: id.optional(), /** diff --git a/src/lib/validations/health-profile-facts.ts b/src/lib/validations/health-profile-facts.ts index b8bbf614c..9e7a56100 100644 --- a/src/lib/validations/health-profile-facts.ts +++ b/src/lib/validations/health-profile-facts.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { isoDateTime } from "@/lib/validations/datetime"; export const HEALTH_PROFILE_AI_SECTIONS = [ "ABOUT_ME", @@ -116,11 +117,11 @@ export const healthProfileFactDtoSchema = z.object({ kind: healthProfileFactKindSchema, value: z.string().nullable(), unreadable: z.boolean(), - validFrom: z.iso.datetime({ offset: true }), - validUntil: z.iso.datetime({ offset: true }).nullable(), + validFrom: isoDateTime(), + validUntil: isoDateTime().nullable(), provenance: z.enum(["USER_REPORTED", "USER_CORRECTION"]), supersededByRevisionId: z.string().nullable(), - createdAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), }); export const healthProfileFactsResponseSchema = z.object({ diff --git a/src/lib/validations/health-record-export.ts b/src/lib/validations/health-record-export.ts index 3b0ffea17..2a031ed69 100644 --- a/src/lib/validations/health-record-export.ts +++ b/src/lib/validations/health-record-export.ts @@ -22,14 +22,15 @@ import { z } from "zod/v4"; import { locales } from "@/lib/i18n/config"; import { reportSelectionSchema } from "@/lib/report-selection/selection"; +import { isoDateTime } from "@/lib/validations/datetime"; export const exportSelectionSchema = z .object({ format: z.enum(["pdf", "fhir", "package"]), range: z .object({ - startDate: z.iso.datetime({ offset: true }).optional(), - endDate: z.iso.datetime({ offset: true }).optional(), + startDate: isoDateTime().optional(), + endDate: isoDateTime().optional(), days: z.number().int().min(1).max(365).optional(), }) .strict() diff --git a/src/lib/validations/illness.ts b/src/lib/validations/illness.ts index afc5e5a83..49e7e7a48 100644 --- a/src/lib/validations/illness.ts +++ b/src/lib/validations/illness.ts @@ -16,17 +16,19 @@ import { z } from "zod/v4"; import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant"; import { lateralityEnum } from "@/lib/validations/encounters"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * Plausible-instant bound shared with the measurement / mood / cycle * paths: no future instant beyond the 5-min skew, no instant before 1900. * Stays a string on the wire (iOS reads it back verbatim). */ -const boundedInstant = z.iso - .datetime({ offset: true }) - .refine((s) => isPlausibleEntryInstant(new Date(s)), { +const boundedInstant = isoDateTime().refine( + (s) => isPlausibleEntryInstant(new Date(s)), + { message: "must be a plausible instant (not future, not pre-1900)", - }); + }, +); const dateString = z .string() @@ -141,8 +143,8 @@ export const illnessEpisodeListQuerySchema = z.object({ * episodes around a visit and pages older or newer from there. */ offset: z.coerce.number().int().min(0).max(100_000).optional(), - from: z.iso.datetime({ offset: true }).optional(), - to: z.iso.datetime({ offset: true }).optional(), + from: isoDateTime().optional(), + to: isoDateTime().optional(), /** Onset order; newest first by default. */ sortDir: z.enum(["asc", "desc"]).optional(), /** Case-insensitive substring over the episode's label. */ diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts index 38c7937dd..0da5ede72 100644 --- a/src/lib/validations/labs-ocr.ts +++ b/src/lib/validations/labs-ocr.ts @@ -22,6 +22,7 @@ import { z } from "zod/v4"; import type { WrittenOutcome } from "@/lib/outcome/written-outcome"; import { validateEntryInstant } from "@/lib/validations/entry-instant"; +import { isoDateTime } from "@/lib/validations/datetime"; /** Max rows a single scan / commit may carry — a dense panel is ~30 analytes. */ export const OCR_MAX_ROWS = 100; @@ -34,10 +35,10 @@ const LAB_TAKEN_AT_MAX_AGE_MS = 50 * 365.25 * 24 * 60 * 60 * 1000; /** Shared committed-row `takenAt`: ISO string → Date, plausibility-bounded. */ const takenAtField = validateEntryInstant( - z - .string() - .datetime({ message: "takenAt must be an ISO 8601 datetime" }) - .transform((s) => new Date(s)), + isoDateTime({ + offset: false, + message: "takenAt must be an ISO 8601 datetime", + }).transform((s) => new Date(s)), { maxAgeMs: LAB_TAKEN_AT_MAX_AGE_MS, pastMessage: "Sample date is too far in the past", diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts index 834ef6721..d19a4bfe5 100644 --- a/src/lib/validations/labs.ts +++ b/src/lib/validations/labs.ts @@ -16,6 +16,7 @@ import { z } from "zod/v4"; import { SOURCE_REFERENCE_TEXT_MAX } from "@/lib/labs/parse-reference-range"; import { validateEntryInstant } from "@/lib/validations/entry-instant"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * Far-past floor for a lab sample. A self-hoster legitimately backdates an @@ -26,10 +27,10 @@ const LAB_TAKEN_AT_MAX_AGE_MS = 50 * 365.25 * 24 * 60 * 60 * 1000; /** Shared `takenAt` field: ISO string → Date, plausibility-bounded. */ const takenAtField = validateEntryInstant( - z - .string() - .datetime({ message: "takenAt must be an ISO 8601 datetime" }) - .transform((s) => new Date(s)), + isoDateTime({ + offset: false, + message: "takenAt must be an ISO 8601 datetime", + }).transform((s) => new Date(s)), { maxAgeMs: LAB_TAKEN_AT_MAX_AGE_MS, pastMessage: "Sample date is too far in the past", @@ -289,14 +290,10 @@ export const listLabResultsSchema = z.object({ * picker can find one result in a long history without paging to it. */ q: z.string().trim().min(1).max(120).optional(), - from: z - .string() - .datetime() + from: isoDateTime({ offset: false }) .transform((s) => new Date(s)) .optional(), - to: z - .string() - .datetime() + to: isoDateTime({ offset: false }) .transform((s) => new Date(s)) .optional(), limit: z.coerce.number().int().min(1).max(500).default(100), diff --git a/src/lib/validations/life-circumstances.ts b/src/lib/validations/life-circumstances.ts index c7a6e5ece..6cf0fb8d0 100644 --- a/src/lib/validations/life-circumstances.ts +++ b/src/lib/validations/life-circumstances.ts @@ -18,6 +18,7 @@ import { SMOKING_STATUS_VALUES, isHealthProfileFactValue, } from "@/lib/validations/health-profile-facts"; +import { isoDateTime } from "@/lib/validations/datetime"; // ── Fact kinds and their values ────────────────────────────────────────────── @@ -189,7 +190,7 @@ export type LifeAiSection = (typeof LIFE_AI_SECTIONS)[number]; export const lifeAiSectionsPatchSchema = z .strictObject({ sections: z.partialRecord(z.enum(LIFE_AI_SECTIONS), z.boolean()), - baseUpdatedAt: z.iso.datetime({ offset: true }).optional(), + baseUpdatedAt: isoDateTime().optional(), }) .refine((value) => Object.keys(value.sections).length > 0, { path: ["sections"], @@ -212,7 +213,7 @@ const auditCItem = z.number().int().min(0).max(4); export const auditCRequestSchema = z .strictObject({ items: z.tuple([auditCItem, auditCItem, auditCItem]), - takenAt: z.iso.datetime({ offset: true }).optional(), + takenAt: isoDateTime().optional(), tz: z.string().min(1).max(64).optional(), locale: z.string().min(2).max(16).optional(), source: z.enum(["WEB", "IOS"]), @@ -343,7 +344,7 @@ export type LifeFactValue = export const removedFactSchema = z.object({ id: z.string(), kind: z.enum(STORED_FACT_KINDS), - removedAt: z.iso.datetime({ offset: true }), + removedAt: isoDateTime(), }); /** @@ -362,10 +363,10 @@ export const lifeFactRevisionSchema = z.object({ unreadable: z .boolean() .describe("The sealed value could not be opened on this host."), - validFrom: z.iso.datetime({ offset: true }), + validFrom: isoDateTime(), validUntil: z.null(), provenance: z.enum(["USER_REPORTED", "USER_CORRECTION"]), - createdAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), }); /** diff --git a/src/lib/validations/managed-profiles.ts b/src/lib/validations/managed-profiles.ts index e582c7c32..5653f9856 100644 --- a/src/lib/validations/managed-profiles.ts +++ b/src/lib/validations/managed-profiles.ts @@ -13,6 +13,7 @@ import { z } from "zod/v4"; import { isValidTimezone } from "@/lib/tz/format"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * The record's sex, as `User.gender` already stores it. @@ -84,6 +85,6 @@ export const updateManagedProfileSchema = z export const inviteManagedProfileGuardianSchema = z .object({ identifier: z.string().trim().min(1).max(255), - expiresAt: z.iso.datetime({ offset: true }).nullable().optional(), + expiresAt: isoDateTime().nullable().optional(), }) .strict(); diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts index a329afa46..6852527fb 100644 --- a/src/lib/validations/measurement-reminders.ts +++ b/src/lib/validations/measurement-reminders.ts @@ -13,6 +13,7 @@ * recomputes). */ import { z } from "zod/v4"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * The auto-resolve target types. Kept as an explicit allow-list rather @@ -147,10 +148,7 @@ const cadenceRefinement = ( * The first-due date: a calendar date (`YYYY-MM-DD`, read as that day in the * user's zone) or a date-time (the instant it names). */ -const anchorDateField = z.union([ - z.iso.date(), - z.iso.datetime({ offset: true }), -]); +const anchorDateField = z.union([z.iso.date(), isoDateTime()]); export const createMeasurementReminderSchema = z .object({ @@ -212,19 +210,19 @@ export const measurementReminderDto = z measurementType: measurementReminderTypeEnum.nullable(), intervalDays: z.number().int().nullable(), rrule: z.string().nullable(), - anchorDate: z.iso.datetime({ offset: true }).nullable(), - endsOn: z.iso.datetime({ offset: true }).nullable(), + anchorDate: isoDateTime().nullable(), + endsOn: isoDateTime().nullable(), origin: z.enum(["VORSORGE", "COACH"]), notifyHour: z.number().int(), location: z.string().nullable(), - nextDueAt: z.iso.datetime({ offset: true }).nullable(), - lastSatisfiedAt: z.iso.datetime({ offset: true }).nullable(), - snoozedUntil: z.iso.datetime({ offset: true }).nullable(), - lastSkippedAt: z.iso.datetime({ offset: true }).nullable(), + nextDueAt: isoDateTime().nullable(), + lastSatisfiedAt: isoDateTime().nullable(), + snoozedUntil: isoDateTime().nullable(), + lastSkippedAt: isoDateTime().nullable(), skipCount: z.number().int(), enabled: z.boolean(), - createdAt: z.iso.datetime({ offset: true }), - updatedAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), + updatedAt: isoDateTime(), }) .meta({ id: "MeasurementReminderDTO", @@ -310,7 +308,7 @@ export const measurementReminderEventDto = z .object({ id: z.string(), kind: z.enum(["SATISFIED", "SKIPPED"]), - occurredAt: z.iso.datetime({ offset: true }), + occurredAt: isoDateTime(), onTime: z.boolean(), source: z.enum([ "manual", @@ -321,7 +319,7 @@ export const measurementReminderEventDto = z "encounter", "skip", ]), - createdAt: z.iso.datetime({ offset: true }), + createdAt: isoDateTime(), }) .meta({ id: "MeasurementReminderEvent", diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 0659540e8..ac2b8da61 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -2,6 +2,7 @@ import { z } from "zod/v4"; import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source"; import { validateEntryInstant } from "./entry-instant"; import { isCalendarDateKey } from "@/lib/tz/date-only"; +import { isoDateTime } from "@/lib/validations/datetime"; export const measurementTypeEnum = z.enum([ "WEIGHT", @@ -551,7 +552,7 @@ export const createMeasurementSchema = z // 1900. Closes the data-portability gap where a hand-rolled POST could // backdate or forward-date a reading arbitrarily. measuredAt: validateEntryInstant( - z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + isoDateTime().transform((s) => new Date(s)), ), notes: z.string().max(MEASUREMENT_NOTES_MAX_LENGTH).optional(), // v1.10.0 QA — validate against the client-writable subset, not the full @@ -611,7 +612,7 @@ export const updateMeasurementSchema = z.object({ // v1.17 W1b — same plausibility bound on the edit path; an edit cannot // forward-date a reading into the future nor before 1900. measuredAt: validateEntryInstant( - z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + isoDateTime().transform((s) => new Date(s)), ).optional(), notes: z .string() @@ -645,12 +646,10 @@ export const listPersonalRecordsSchema = z.object({ export const listMeasurementsSchema = z .object({ type: measurementTypeEnum.optional(), - from: z.iso - .datetime({ offset: true }) + from: isoDateTime() .transform((s) => new Date(s)) .optional(), - to: z.iso - .datetime({ offset: true }) + to: isoDateTime() .transform((s) => new Date(s)) .optional(), // v1.4.28 FB-D2 — when the chart sends an explicit from/to window the diff --git a/src/lib/validations/medication/base.ts b/src/lib/validations/medication/base.ts index 6590fad67..3680e749c 100644 --- a/src/lib/validations/medication/base.ts +++ b/src/lib/validations/medication/base.ts @@ -3,6 +3,7 @@ import { z } from "zod/v4"; import { INJECTION_SITE_KEYS } from "@/lib/medications/injection-sites"; import { validateEntryInstant } from "@/lib/validations/entry-instant"; import { hhmmToMinutes } from "@/lib/medications/scheduling/hhmm"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * v1.8.5 — the eight injection-site enum values, mirrored from the @@ -244,7 +245,7 @@ export const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000; * 1900 floor. The 5-min clock-skew tolerance is the shared default. */ export const boundedTakenAtSchema = validateEntryInstant( - z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + isoDateTime().transform((s) => new Date(s)), { maxAgeMs: TAKEN_AT_MAX_AGE_MS, // Preserve the established wording so the iOS contract + tests stay stable. diff --git a/src/lib/validations/medication/intake.ts b/src/lib/validations/medication/intake.ts index 5f6eae4d2..e2d277103 100644 --- a/src/lib/validations/medication/intake.ts +++ b/src/lib/validations/medication/intake.ts @@ -2,6 +2,7 @@ import { z } from "zod/v4"; import { boundedTakenAtSchema, injectionSiteEnum } from "./base"; import { assertStableIdempotencyKey } from "@/lib/validations/external-id"; +import { isoDateTime } from "@/lib/validations/datetime"; export const intakeSchema = z .object({ @@ -11,8 +12,7 @@ export const intakeSchema = z .describe( "Server-narrowed from the URL path. The route layer overwrites whatever the body supplies before Zod parsing so a caller cannot log an intake against another medication.", ), - scheduledFor: z.iso - .datetime({ offset: true }) + scheduledFor: isoDateTime() .transform((s) => new Date(s)) .optional() .describe( @@ -60,8 +60,7 @@ export const intakeSchema = z * server validates it against the band anchors); an arbitrary instant is * rejected with 422. Absent → default band attribution. */ - forceSlotInstant: z.iso - .datetime({ offset: true }) + forceSlotInstant: isoDateTime() .transform((s) => new Date(s)) .optional() .describe( @@ -149,8 +148,7 @@ export const updateIntakeEventSchema = z .object({ takenAt: boundedTakenAtSchema.nullable().optional(), skipped: z.boolean().optional(), - scheduledFor: z.iso - .datetime({ offset: true }) + scheduledFor: isoDateTime() .transform((s) => new Date(s)) .optional(), /** @@ -165,8 +163,7 @@ export const updateIntakeEventSchema = z * resets to AUTO — the "Zuordnung lösen" path. A pin / unpin no longer * requires `takenAt` or `skipped` to change in the same request. */ - forceSlotInstant: z.iso - .datetime({ offset: true }) + forceSlotInstant: isoDateTime() .transform((s) => new Date(s)) .nullable() .optional() @@ -242,15 +239,13 @@ export const intakeStatusUpdateSchema = z.object({ .describe( "`snoozed` writes the deferral onto the MEDICATION row rather than the event, so it defers every pending dose of that medication and not just this one.", ), - takenAt: z.iso - .datetime({ offset: true }) + takenAt: isoDateTime() .transform((s) => new Date(s)) .optional() .describe( "When the dose was taken. Defaults to now. Ignored unless the status is `taken`.", ), - snoozedUntil: z.iso - .datetime({ offset: true }) + snoozedUntil: isoDateTime() .transform((s) => new Date(s)) .optional() .describe( @@ -261,8 +256,7 @@ export const intakeStatusUpdateSchema = z.object({ .describe( "Site recorded alongside a `taken` toggle, validated server-side against the medication's effective allowed set. Dropped for a non-injection medication, for one with site tracking off, and for a skip or snooze.", ), - forceSlotInstant: z.iso - .datetime({ offset: true }) + forceSlotInstant: isoDateTime() .transform((s) => new Date(s)) .optional() .describe( diff --git a/src/lib/validations/medication/inventory.ts b/src/lib/validations/medication/inventory.ts index acf0ff406..bd6abf9d9 100644 --- a/src/lib/validations/medication/inventory.ts +++ b/src/lib/validations/medication/inventory.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * v1.4.25 W19b — inventory (pen / vial) CRUD validators. @@ -47,13 +48,11 @@ export const createInventoryItemSchema = z .describe( "Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Defaults to OTHER. PEN and AMPOULE are the sealed reservoirs the first dose breaches, so they carry a 30-day post-opening window; every other kind expires only on its printed date.", ), - printedExpiry: z.iso - .datetime({ offset: true }) + printedExpiry: isoDateTime() .transform((s) => new Date(s)) .nullable() .optional(), - purchasedAt: z.iso - .datetime({ offset: true }) + purchasedAt: isoDateTime() .transform((s) => new Date(s)) .nullable() .optional(), @@ -85,8 +84,7 @@ export const createInventoryItemSchema = z export const updateInventoryItemSchema = z .object({ - markAsFirstUseAt: z.iso - .datetime({ offset: true }) + markAsFirstUseAt: isoDateTime() .transform((s) => new Date(s)) .nullable() .optional() @@ -99,8 +97,7 @@ export const updateInventoryItemSchema = z .describe( "Terminal override: zero the remaining units and mark the container USED_UP (physically discarded).", ), - printedExpiry: z.iso - .datetime({ offset: true }) + printedExpiry: isoDateTime() .transform((s) => new Date(s)) .nullable() .optional(), @@ -183,8 +180,7 @@ const MIN_EFFECTIVE_FROM = new Date("2020-01-01T00:00:00Z"); const FIVE_YEARS_MS = 5 * 365 * 24 * 60 * 60 * 1000; export const glp1DoseChangePostSchema = z.object({ - effectiveFrom: z.iso - .datetime({ offset: true }) + effectiveFrom: isoDateTime() .transform((s) => new Date(s)) .refine((d) => d.getTime() >= MIN_EFFECTIVE_FROM.getTime(), { message: "effectiveFrom must be on or after 2020-01-01", diff --git a/src/lib/validations/mental-health.ts b/src/lib/validations/mental-health.ts index 003f5f619..e153bd624 100644 --- a/src/lib/validations/mental-health.ts +++ b/src/lib/validations/mental-health.ts @@ -13,6 +13,7 @@ import { } from "@/lib/mental-health/instruments"; import { assertStableExternalId } from "@/lib/validations/external-id"; import { isValidTimezone } from "@/lib/tz/format"; +import { isoDateTime } from "@/lib/validations/datetime"; export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]); @@ -53,7 +54,7 @@ export const createAssessmentSchema = z /** Optional functional-impairment follow-up (not scored into the total). */ 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(), + takenAt: isoDateTime({ offset: false }).optional(), tz: z .string() .max(64) diff --git a/src/lib/validations/mood.ts b/src/lib/validations/mood.ts index d407d8e59..a9a4cc1bc 100644 --- a/src/lib/validations/mood.ts +++ b/src/lib/validations/mood.ts @@ -23,6 +23,7 @@ import { LEISURE_CATEGORY_KEYS, WORK_STATUS_KEYS, } from "@/lib/mood/context-vocabulary"; +import { isoDateTime } from "@/lib/validations/datetime"; // --- CRUD schemas for mood entries --- @@ -192,9 +193,7 @@ export const moodContextSchema = z.object({ .optional(), // The same plausibility bound the entry's own instant carries: no future // instants beyond the clock-skew tolerance, nothing before 1900. - eventAt: validateEntryInstant( - z.iso.datetime({ offset: true }).transform((s) => new Date(s)), - ) + eventAt: validateEntryInstant(isoDateTime().transform((s) => new Date(s))) .nullable() .optional(), // Capped like the entry note beside it. One note for the whole context. @@ -242,7 +241,7 @@ export const createMoodEntrySchema = z // future instants beyond a 5-min clock-skew tolerance, no instant before // 1900. Mirrors the measurement + medication-intake bound. moodLoggedAt: validateEntryInstant( - z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + isoDateTime().transform((s) => new Date(s)), ), source: moodSourceEnum.optional().default("MANUAL"), // v1.12.1 — optional source-stable id (e.g. an iOS SwiftData row UUID). @@ -285,7 +284,7 @@ export const updateMoodEntrySchema = z.object({ context: moodContextSchema.nullable().optional(), // v1.17 W1b — same plausibility bound on the edit path. moodLoggedAt: validateEntryInstant( - z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + isoDateTime().transform((s) => new Date(s)), ).optional(), }); diff --git a/src/lib/validations/nutrients.ts b/src/lib/validations/nutrients.ts index 2fad5c706..b0d2fb69f 100644 --- a/src/lib/validations/nutrients.ts +++ b/src/lib/validations/nutrients.ts @@ -14,6 +14,7 @@ import { NUTRIENT_CODES, SYNCABLE_NUTRIENT_CODES, } from "@/lib/nutrients/catalog"; +import { isoDateTime } from "@/lib/validations/datetime"; export const MAX_NUTRIENT_ENTRIES_PER_BATCH = 500; @@ -160,7 +161,7 @@ export const nutrientOverviewSchema = z ), lastAttempt: z .object({ - at: z.iso.datetime({ offset: true }), + at: isoDateTime(), topReason: z.string(), }) .nullable() diff --git a/src/lib/validations/schedule-revision.ts b/src/lib/validations/schedule-revision.ts index cec714987..6f0521fa2 100644 --- a/src/lib/validations/schedule-revision.ts +++ b/src/lib/validations/schedule-revision.ts @@ -14,6 +14,7 @@ * minter reads it exactly like a write-path archive. */ import { z } from "zod/v4"; +import { isoDateTime } from "@/lib/validations/datetime"; /** HH:mm wall-clock literal — mirrors `validations/medication.ts`. */ const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/; @@ -21,20 +22,18 @@ const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/; export const scheduleRevisionCreateSchema = z .object({ /** Inclusive start instant of the manual era. */ - validFrom: z.iso - .datetime({ offset: true }) - .describe("Inclusive start instant of the manual era (ISO 8601)."), + validFrom: isoDateTime().describe( + "Inclusive start instant of the manual era (ISO 8601).", + ), /** * Exclusive end instant — the moment the next plan took over. Must * lie after `validFrom` and at or before the start of the live * schedule era (the newest archived revision's `validUntil`, or * "now" when no revision exists yet). */ - validUntil: z.iso - .datetime({ offset: true }) - .describe( - "Exclusive end instant of the manual era (ISO 8601). Must precede the current plan.", - ), + validUntil: isoDateTime().describe( + "Exclusive end instant of the manual era (ISO 8601). Must precede the current plan.", + ), /** * Daily dose times the era ran at. Same cap as live schedules. * Duplicates collapse and the result sorts ascending — "07:00, diff --git a/src/lib/validations/series-batch.ts b/src/lib/validations/series-batch.ts index af60fa87b..b43a68b86 100644 --- a/src/lib/validations/series-batch.ts +++ b/src/lib/validations/series-batch.ts @@ -7,6 +7,7 @@ */ import { z } from "zod/v4"; import { measurementTypeEnum } from "@/lib/validations/measurement"; +import { isoDateTime } from "@/lib/validations/datetime"; /** Upper bound on the types per batch — the dashboard never asks for more. */ export const SERIES_BATCH_MAX_TYPES = 16; @@ -28,12 +29,10 @@ export const seriesBatchQuerySchema = z `${SERIES_BATCH_MAX_TYPES}). Each is read as a daily series through ` + "the rollup tier.", ), - from: z.iso - .datetime({ offset: true }) + from: isoDateTime() .transform((s) => new Date(s)) .describe("Window start (ISO-8601 instant)."), - to: z.iso - .datetime({ offset: true }) + to: isoDateTime() .transform((s) => new Date(s)) .describe("Window end (ISO-8601 instant)."), }) diff --git a/src/lib/validations/sleep-stage-entry.ts b/src/lib/validations/sleep-stage-entry.ts index 3949c5390..2559f9c2e 100644 --- a/src/lib/validations/sleep-stage-entry.ts +++ b/src/lib/validations/sleep-stage-entry.ts @@ -23,6 +23,7 @@ import { validateMeasurementRange, writableMeasurementSourceEnum, } from "./measurement"; +import { isoDateTime } from "@/lib/validations/datetime"; /** The stage names the `SleepStage` column stores, as a caller writes them. */ export const SLEEP_STAGE_NAMES = [ @@ -40,9 +41,7 @@ export type SleepStageName = (typeof SLEEP_STAGE_NAMES)[number]; const VALUE_TOLERANCE_MINUTES = 1; const instant = () => - validateEntryInstant( - z.iso.datetime({ offset: true }).transform((s) => new Date(s)), - ); + validateEntryInstant(isoDateTime().transform((s) => new Date(s))); export const sleepStageEntrySchema = z .object({ diff --git a/src/lib/validations/vaccinations.ts b/src/lib/validations/vaccinations.ts index 4155b7564..2ef829b05 100644 --- a/src/lib/validations/vaccinations.ts +++ b/src/lib/validations/vaccinations.ts @@ -29,6 +29,7 @@ import { ANTIGEN_SLUGS, VACCINE_CATALOG_SLUGS, } from "@/lib/vaccinations/vaccine-catalog"; +import { isoDateTime } from "@/lib/validations/datetime"; const KNOWN_SLUGS: ReadonlySet = new Set(VACCINE_CATALOG_SLUGS); @@ -43,7 +44,7 @@ const KNOWN_SLUGS: ReadonlySet = new Set(VACCINE_CATALOG_SLUGS); */ const DAY_SKEW_MS = 24 * 60 * 60 * 1000; -const doseInstant = z.iso.datetime({ offset: true }).refine( +const doseInstant = isoDateTime().refine( (value) => { const at = new Date(value).getTime(); if (Number.isNaN(at)) return false; @@ -191,8 +192,8 @@ export type VaccinationUpdate = z.infer; export const vaccinationListQuerySchema = z .object({ antigenSlug: z.string().min(1).max(64).optional(), - from: z.iso.datetime({ offset: true }).optional(), - to: z.iso.datetime({ offset: true }).optional(), + from: isoDateTime().optional(), + to: isoDateTime().optional(), limit: z.coerce.number().int().min(1).max(500).optional(), }) .strict() @@ -299,7 +300,7 @@ export type VaccinationBoosterInput = z.infer; */ export const vaccinationSuggestQuerySchema = z .object({ - anchor: z.iso.datetime({ offset: true }), + anchor: isoDateTime(), }) .strict(); diff --git a/src/lib/validations/workout.ts b/src/lib/validations/workout.ts index 95fa195bb..3174f1d10 100644 --- a/src/lib/validations/workout.ts +++ b/src/lib/validations/workout.ts @@ -1,5 +1,6 @@ import { z } from "zod/v4"; import { writableMeasurementSourceEnum } from "./measurement"; +import { isoDateTime } from "@/lib/validations/datetime"; /** * Workout sport-type union. @@ -84,7 +85,7 @@ export type GeoJsonLineString = z.infer; */ export const workoutRouteSamplesSchema = z.array( z.object({ - t: z.iso.datetime({ offset: true }), + t: isoDateTime(), speedMs: z.number().min(0).max(50).optional(), hr: z.number().int().min(20).max(300).optional(), }), @@ -122,7 +123,7 @@ export const MAX_WORKOUT_HR_SAMPLES = 30_000; export const workoutHrSamplesSchema = z .array( z.object({ - t: z.iso.datetime({ offset: true }), + t: isoDateTime(), hr: z.number().int().min(20).max(300).optional(), speedMs: z.number().min(0).max(50).optional(), power: z.number().min(0).max(3000).optional(), @@ -157,8 +158,8 @@ export type WorkoutHrSamples = z.infer; export const createWorkoutSchema = z .object({ sportType: workoutSportTypeEnum, - startedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), - endedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + startedAt: isoDateTime().transform((s) => new Date(s)), + endedAt: isoDateTime().transform((s) => new Date(s)), totalEnergyKcal: z.number().min(0).max(50000).optional(), totalDistanceM: z.number().min(0).max(1_000_000).optional(), avgHeartRate: z.number().int().min(20).max(300).optional(), diff --git a/src/lib/workouts/list-read.ts b/src/lib/workouts/list-read.ts index a9d637673..52aa9e32d 100644 --- a/src/lib/workouts/list-read.ts +++ b/src/lib/workouts/list-read.ts @@ -21,6 +21,7 @@ import { annotate } from "@/lib/logging/context"; import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows"; import { cached, caches } from "@/lib/cache/server-cache"; import { workoutSportTypeEnum } from "@/lib/validations/workout"; +import { isoDateTime } from "@/lib/validations/datetime"; function emptyQueryValueToUndefined(value: unknown): unknown { if (value == null) return undefined; @@ -32,8 +33,7 @@ function emptyQueryValueToUndefined(value: unknown): unknown { const normalizedTimestampFilter = z .preprocess( emptyQueryValueToUndefined, - z.iso - .datetime({ offset: true }) + isoDateTime() .transform((value) => new Date(value).toISOString()) .optional(), )