mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Merge branch 'release/v1.43.0' into v143/fixui
This commit is contained in:
commit
beb7ef4ccc
93 files changed
+4369
-3956
No files matched your search
@@ -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: `<app>/claim/<token>`."),
|
||||
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
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
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<ZodOpenApiObject["paths"]> = {
|
||||
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<ZodOpenApiObject["paths"]> = {
|
||||
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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
z
|
||||
.object({
|
||||
method: z.enum(["password", "totp", "webauthn", "passkey"]),
|
||||
verifiedAt: z.string().datetime(),
|
||||
verifiedAt: isoDateTime({ offset: false }),
|
||||
})
|
||||
.meta({ id: "ReproofResponse" }),
|
||||
"ReproofEnvelope",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
"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<ZodOpenApiObject["paths"]> = {
|
||||
"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<ZodOpenApiObject["paths"]> = {
|
||||
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<ZodOpenApiObject["paths"]> = {
|
||||
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<ZodOpenApiObject["paths"]> = {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
createdAt: isoDateTime(),
|
||||
}),
|
||||
"CoachMessageFeedbackResponse",
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
ok: z.literal(true),
|
||||
lastSyncedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
lastSyncedAt: isoDateTime().nullable(),
|
||||
latencyMs: z.number().int(),
|
||||
}),
|
||||
"DawarichProbeEnvelope",
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`.");
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
.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.",
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
createdAt: isoDateTime(),
|
||||
}),
|
||||
"InsightsFeedbackCreatedEnvelope",
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -49,6 +49,7 @@ import { whoopCredentialsSchema } from "@/lib/validations/whoop";
|
||||
import { nightscoutConnectSchema } from "@/lib/validations/nightscout";
|
||||
import { withingsCredentialsSchema } from "@/lib/validations/withings";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
// Mirror the route's `directionEnum` — per-metric sync direction.
|
||||
const healthKitDirectionEnum = z
|
||||
@@ -74,6 +75,18 @@ const healthKitEntry = z
|
||||
"One resolved HealthKit metric mapping (defaults merged with the user's stored overrides).",
|
||||
});
|
||||
|
||||
// The health-shaped fields that predate `health` (v1.42.4). They stay on the
|
||||
// wire through v1.43 so App 1.2.0 keeps decoding, are marked deprecated in the
|
||||
// document, and go in v1.44.
|
||||
const LEGACY_HEALTH_FIELD = "Deprecated: removed in v1.44, read `health`.";
|
||||
|
||||
function legacy(text?: string): { deprecated: true; description: string } {
|
||||
return {
|
||||
deprecated: true,
|
||||
description: text ? `${LEGACY_HEALTH_FIELD} ${text}` : LEGACY_HEALTH_FIELD,
|
||||
};
|
||||
}
|
||||
|
||||
// Mirrors `src/lib/integrations/sync-verdict.ts` — the server-resolved liveness
|
||||
// verdict. Apple Health is push-based, so only the data-age arms apply.
|
||||
const syncHealth = z
|
||||
@@ -92,8 +105,7 @@ const syncHealth = z
|
||||
.describe(
|
||||
"Liveness verdict. For Apple Health: `fresh` when data arrived within the last 7 days, `stale` when it did not, `pending_first_sync` when none ever arrived.",
|
||||
),
|
||||
since: z.iso
|
||||
.datetime({ offset: true })
|
||||
since: isoDateTime()
|
||||
.nullable()
|
||||
.describe("The instant that triggered the verdict; null when none did."),
|
||||
})
|
||||
@@ -117,8 +129,7 @@ export const connectionHealth = z
|
||||
.describe(
|
||||
"Why the state is what it is. ok: `data_arriving`, `first_sync_pending`. limited: `plan_limited`, `permission_limited`. degraded: `provider_errors`, `sync_stalled`, `no_new_data`, `unexpected_silence` (the integration watchdog saw a source that normally delivers daily go quiet). action_required: `reauth_required`, `paused_after_errors`. Pick the sentence from this code alone.",
|
||||
),
|
||||
since: z.iso
|
||||
.datetime({ offset: true })
|
||||
since: isoDateTime()
|
||||
.nullable()
|
||||
.describe(
|
||||
"When the current degraded / action_required condition started. Null for ok and limited.",
|
||||
@@ -143,14 +154,12 @@ export const connectionHealth = z
|
||||
.describe(
|
||||
"Data types this connection cannot deliver. Present in every state, empty when nothing is withheld.",
|
||||
),
|
||||
lastSuccessAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
lastSuccessAt: isoDateTime()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Last successful sync. For Apple Health, the last accepted batch.",
|
||||
),
|
||||
lastDataAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
lastDataAt: isoDateTime()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Newest data this source delivered: for polled providers when the newest reading or workout was taken, for Apple Health when a sync last brought a new sample. Null when nothing has arrived.",
|
||||
@@ -162,8 +171,8 @@ export const connectionHealth = z
|
||||
.nullable()
|
||||
.describe("The stored sync-ledger state. Null for Apple Health."),
|
||||
failingLegs: z.array(z.string()),
|
||||
failingSince: z.iso.datetime({ offset: true }).nullable(),
|
||||
lastAttemptAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
failingSince: isoDateTime().nullable(),
|
||||
lastAttemptAt: isoDateTime().nullable(),
|
||||
verdict: z
|
||||
.string()
|
||||
.describe("The `SyncHealth` verdict the state was derived from."),
|
||||
@@ -176,7 +185,7 @@ export const connectionHealth = z
|
||||
"Sync leg name (`sleep`, `measures`, `sync`, `token_refresh`), or `batch:<TYPE>` for Apple Health. Never a URL.",
|
||||
),
|
||||
status: z.enum(RECENT_CALL_STATUSES),
|
||||
at: z.iso.datetime({ offset: true }),
|
||||
at: isoDateTime(),
|
||||
errorCode: z
|
||||
.string()
|
||||
.nullable()
|
||||
@@ -205,9 +214,9 @@ const metricFreshnessEntry = z
|
||||
.describe(
|
||||
"The measurement type (e.g. `RESPIRATORY_RATE`), `WORKOUTS` for the workout leg, or (Apple Health only, since v1.42.4) `MEDICATION_DOSE` for medication doses imported from Apple Health. Clients should show or skip a type they do not know rather than fail.",
|
||||
),
|
||||
lastSeenAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.describe("Newest recorded reading for this type from this source."),
|
||||
lastSeenAt: isoDateTime().describe(
|
||||
"Newest recorded reading for this type from this source.",
|
||||
),
|
||||
stale: z
|
||||
.boolean()
|
||||
.describe(
|
||||
@@ -225,8 +234,7 @@ const metricFreshnessEntry = z
|
||||
// `HealthKitFreshnessEntry` in `src/lib/integrations/healthkit-type-sync.ts`.
|
||||
const appleHealthMetricFreshnessEntry = metricFreshnessEntry
|
||||
.extend({
|
||||
lastReceivedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
lastReceivedAt: isoDateTime()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
@@ -238,22 +246,19 @@ const appleHealthMetricFreshnessEntry = metricFreshnessEntry
|
||||
.describe(
|
||||
"The `syncTrigger` of that batch. Null when the client reported none. A type whose trigger is only ever `manual` reaches the server only through a user-started full sync. Same omission rules as `lastReceivedAt`.",
|
||||
),
|
||||
lastNewSampleAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
lastNewSampleAt: isoDateTime()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"When a live sync batch last brought a new or updated sample of this type, as opposed to duplicates only. Null when none has. Same omission rules as `lastReceivedAt`.",
|
||||
),
|
||||
lastBackgroundAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
lastBackgroundAt: isoDateTime()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'When a batch with `syncTrigger: "background"` last carried this type (since v1.42.4). A later `foreground`, `push` or `manual` batch moves `lastReceivedAt` and `lastTrigger` but leaves this standing, so opening the app does not hide an earlier background arrival. Null when the type has never arrived in the background as far as the server recorded: before v1.42.4 only the latest arrival was kept, so a type whose latest arrival then was a background one starts with that date and every other type starts null. Same omission rules as `lastReceivedAt`.',
|
||||
),
|
||||
lastBackfillAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
lastBackfillAt: isoDateTime()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
@@ -277,8 +282,7 @@ const appleHealthSyncProgress = z
|
||||
.describe(
|
||||
"Measurement + workout rows carrying the `APPLE_HEALTH` source (live batch ingest and the one-shot export import both write it). Soft-deleted measurement rows are excluded.",
|
||||
),
|
||||
oldestMeasuredAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
oldestMeasuredAt: isoDateTime()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Earliest instant reached across those rows (`measuredAt` for measurements, `startedAt` for workouts); null when nothing has arrived. During a first-run backfill this walks backwards as history lands.",
|
||||
@@ -293,8 +297,7 @@ const appleHealthSyncProgress = z
|
||||
const healthKitConfigResponse = z
|
||||
.object({
|
||||
entries: z.array(healthKitEntry),
|
||||
lastSyncedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
lastSyncedAt: isoDateTime()
|
||||
.nullable()
|
||||
.describe(
|
||||
"When HealthKit last synced for this user; null when never (and always null on the PATCH echo).",
|
||||
@@ -305,8 +308,7 @@ const healthKitConfigResponse = z
|
||||
.describe(
|
||||
"What the client declared triggered the most recent accepted batch. Null when the client sent no trigger — an older build, or a batch that predates the field. Present on the GET read; omitted from the PATCH echo.",
|
||||
),
|
||||
lastBackgroundSyncAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
lastBackgroundSyncAt: isoDateTime()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
@@ -314,7 +316,7 @@ const healthKitConfigResponse = z
|
||||
),
|
||||
syncHealth: syncHealth
|
||||
.optional()
|
||||
.describe("Present on the GET read; omitted from the PATCH echo."),
|
||||
.meta(legacy("Present on the GET read; omitted from the PATCH echo.")),
|
||||
metricFreshness: z
|
||||
.array(appleHealthMetricFreshnessEntry)
|
||||
.optional()
|
||||
@@ -515,17 +517,19 @@ const nightscoutStatusResponse = z
|
||||
.describe(
|
||||
"The operator has approved this user's private-origin (RFC 1918 / loopback / link-local) Nightscout instance. False for every public instance.",
|
||||
),
|
||||
state: integrationLedgerState.optional(),
|
||||
lastSuccessAt: z.iso.datetime({ offset: true }).nullable().optional(),
|
||||
lastAttemptAt: z.iso.datetime({ offset: true }).nullable().optional(),
|
||||
state: integrationLedgerState.optional().meta(legacy()),
|
||||
lastSuccessAt: isoDateTime().nullable().optional(),
|
||||
lastAttemptAt: isoDateTime().nullable().optional(),
|
||||
lastError: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Last recorded failure message, decrypted from the ledger. Null when the last attempt succeeded or none has run.",
|
||||
.meta(
|
||||
legacy(
|
||||
"Last recorded failure message, decrypted from the ledger. Null when the last attempt succeeded or none has run.",
|
||||
),
|
||||
),
|
||||
syncHealth: syncHealth.optional(),
|
||||
syncHealth: syncHealth.optional().meta(legacy()),
|
||||
health: connectionHealth
|
||||
.nullable()
|
||||
.optional()
|
||||
@@ -581,22 +585,22 @@ const whoopStatusResponse = z
|
||||
configured: z
|
||||
.boolean()
|
||||
.describe("Per-user BYO-key client id AND secret are both stored."),
|
||||
lastSyncedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
lastSyncedAt: isoDateTime()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("Null when no sync has completed yet."),
|
||||
connectedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
connectedAt: isoDateTime()
|
||||
.optional()
|
||||
.describe("When the connection row was created."),
|
||||
tokenExpired: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"The stored access token's expiry is in the past. Read straight off the row — unlike the Withings status read, this endpoint does not refresh; the sync path refreshes lazily instead.",
|
||||
.meta(
|
||||
legacy(
|
||||
"The stored access token's expiry is in the past. Read straight off the row — unlike the Withings status read, this endpoint does not refresh; the sync path refreshes lazily instead.",
|
||||
),
|
||||
),
|
||||
tokenExpiresAt: z.iso.datetime({ offset: true }).optional(),
|
||||
tokenExpiresAt: isoDateTime().optional(),
|
||||
backfillCompleted: z
|
||||
.boolean()
|
||||
.optional()
|
||||
@@ -629,13 +633,15 @@ const withingsStatusResponse = z
|
||||
configured: z
|
||||
.boolean()
|
||||
.describe("Per-user client id AND secret are both stored."),
|
||||
lastSyncedAt: z.iso.datetime({ offset: true }).nullable().optional(),
|
||||
connectedAt: z.iso.datetime({ offset: true }).optional(),
|
||||
lastSyncedAt: isoDateTime().nullable().optional(),
|
||||
connectedAt: isoDateTime().optional(),
|
||||
tokenExpired: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"True after a refresh attempt that did not produce a live token.",
|
||||
.meta(
|
||||
legacy(
|
||||
"True after a refresh attempt that did not produce a live token.",
|
||||
),
|
||||
),
|
||||
tokenRefreshFailed: z
|
||||
.boolean()
|
||||
@@ -643,8 +649,7 @@ const withingsStatusResponse = z
|
||||
.describe(
|
||||
"The read tried to refresh an expired token and could not — either the per-user credentials are gone or Withings refused. A permanently revoked grant additionally parks the ledger at `error_reauth` as a side effect of this read.",
|
||||
),
|
||||
tokenExpiresAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
tokenExpiresAt: isoDateTime()
|
||||
.optional()
|
||||
.describe(
|
||||
"Post-refresh expiry when the read refreshed, the stored expiry otherwise.",
|
||||
@@ -659,8 +664,10 @@ const withingsStatusResponse = z
|
||||
hasActivityScope: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Pre-computed convenience flag the reconnect banner reads. A null `scope` counts as missing activity.",
|
||||
.meta(
|
||||
legacy(
|
||||
"Pre-computed convenience flag the reconnect banner reads. A null `scope` counts as missing activity.",
|
||||
),
|
||||
),
|
||||
health: connectionHealth
|
||||
.nullable()
|
||||
@@ -785,8 +792,7 @@ const providerProbeResponse = z
|
||||
const integrationProbeResponse = z
|
||||
.object({
|
||||
ok: z.literal(true),
|
||||
lastSyncedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
lastSyncedAt: isoDateTime()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The connection row's last successful sync; null when it has never synced. Present here and absent from the `/api/<provider>/test` family — the one shape difference between the two probe families.",
|
||||
@@ -929,15 +935,17 @@ function connectionStatusShape() {
|
||||
configured: z
|
||||
.boolean()
|
||||
.describe("Per-user BYO-key client id AND secret are both stored."),
|
||||
lastSyncedAt: z.iso.datetime({ offset: true }).nullable().optional(),
|
||||
connectedAt: z.iso.datetime({ offset: true }).optional(),
|
||||
lastSyncedAt: isoDateTime().nullable().optional(),
|
||||
connectedAt: isoDateTime().optional(),
|
||||
tokenExpired: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Read straight off the row — this endpoint does not refresh; the sync path refreshes lazily.",
|
||||
.meta(
|
||||
legacy(
|
||||
"Read straight off the row — this endpoint does not refresh; the sync path refreshes lazily.",
|
||||
),
|
||||
),
|
||||
tokenExpiresAt: z.iso.datetime({ offset: true }).optional(),
|
||||
tokenExpiresAt: isoDateTime().optional(),
|
||||
backfillCompleted: z
|
||||
.boolean()
|
||||
.optional()
|
||||
@@ -970,8 +978,10 @@ const googleHealthStatusResponse = z
|
||||
needsReauth: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"The stored refresh token was rejected with `invalid_grant` — Google expires it after 7 days while the OAuth client is in Testing publishing mode, and a user-revoked grant lands here too. Distinct from `parked` and from `disconnected`: the connection still exists, it just cannot mint a token until the user reconnects. Only Google Health has this field.",
|
||||
.meta(
|
||||
legacy(
|
||||
"The stored refresh token was rejected with `invalid_grant` — Google expires it after 7 days while the OAuth client is in Testing publishing mode, and a user-revoked grant lands here too. Distinct from `parked` and from `disconnected`: the connection still exists, it just cannot mint a token until the user reconnects. Only Google Health has this field.",
|
||||
),
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
@@ -1003,11 +1013,11 @@ const oauthProviderStatusResponse = z
|
||||
hasOwnCredentials: z
|
||||
.boolean()
|
||||
.describe("The user has stored their own BYO client id and secret."),
|
||||
state: integrationLedgerState.optional(),
|
||||
lastSuccessAt: z.iso.datetime({ offset: true }).nullable().optional(),
|
||||
lastAttemptAt: z.iso.datetime({ offset: true }).nullable().optional(),
|
||||
lastError: z.string().nullable().optional(),
|
||||
syncHealth: syncHealth.optional(),
|
||||
state: integrationLedgerState.optional().meta(legacy()),
|
||||
lastSuccessAt: isoDateTime().nullable().optional(),
|
||||
lastAttemptAt: isoDateTime().nullable().optional(),
|
||||
lastError: z.string().nullable().optional().meta(legacy()),
|
||||
syncHealth: syncHealth.optional().meta(legacy()),
|
||||
health: connectionHealth
|
||||
.nullable()
|
||||
.optional()
|
||||
@@ -1030,8 +1040,10 @@ const consecutiveFailuresByKind = z
|
||||
persistent: z.number().int(),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Per-kind consecutive-failure counters, or null when the row has never written a bucket payload. Compare against the top-level `threshold` to know how close the provider is to the operator alert.",
|
||||
.meta(
|
||||
legacy(
|
||||
"Per-kind consecutive-failure counters, or null when the row has never written a bucket payload. Compare against the top-level `threshold` to know how close the provider is to the operator alert.",
|
||||
),
|
||||
);
|
||||
|
||||
const integrationEnvelopeEntry = z
|
||||
@@ -1049,12 +1061,12 @@ const integrationEnvelopeEntry = z
|
||||
"dawarich",
|
||||
])
|
||||
.describe("Which provider this entry describes."),
|
||||
state: integrationLedgerState,
|
||||
lastSuccessAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
lastAttemptAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
lastError: z.string().nullable(),
|
||||
state: integrationLedgerState.meta(legacy()),
|
||||
lastSuccessAt: isoDateTime().nullable(),
|
||||
lastAttemptAt: isoDateTime().nullable(),
|
||||
lastError: z.string().nullable().meta(legacy()),
|
||||
consecutiveFailuresByKind,
|
||||
syncHealth,
|
||||
syncHealth: syncHealth.meta(legacy()),
|
||||
metricFreshness: z
|
||||
.array(metricFreshnessEntry)
|
||||
.describe(
|
||||
@@ -1062,30 +1074,31 @@ const integrationEnvelopeEntry = z
|
||||
),
|
||||
connected: z.boolean(),
|
||||
configured: z.boolean(),
|
||||
connectedAt: z.iso.datetime({ offset: true }).nullable().optional(),
|
||||
legacyLastSyncedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
connectedAt: isoDateTime().nullable().optional(),
|
||||
legacyLastSyncedAt: isoDateTime()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"The connection row's own last-sync instant, for legacy connections established before the ledger existed. Not present on the three user-row OAuth providers or on Nightscout.",
|
||||
),
|
||||
tokenExpiresAt: z.iso.datetime({ offset: true }).nullable().optional(),
|
||||
tokenExpiresAt: isoDateTime().nullable().optional(),
|
||||
tokenExpired: z
|
||||
.boolean()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("Null — not false — when there is no connection to judge."),
|
||||
.meta(legacy("Null — not false — when there is no connection to judge.")),
|
||||
backfillCompleted: z.boolean().nullable().optional(),
|
||||
scope: z.string().nullable().optional().describe("Withings only."),
|
||||
hasActivityScope: z.boolean().optional().describe("Withings only."),
|
||||
hasActivityScope: z.boolean().optional().meta(legacy("Withings only.")),
|
||||
planUnavailable: z
|
||||
.array(z.enum(WITHINGS_PLAN_FEATURES))
|
||||
.optional()
|
||||
.describe(
|
||||
"Withings only. The readings this connection's Withings API plan withholds (status 277 since the 2026-10-12 plan split), deduplicated and in display order. Empty when the plan refuses nothing. The sync skips these and keeps importing everything else; the list refreshes when the weekly re-probe asks again.",
|
||||
.meta(
|
||||
legacy(
|
||||
"Withings only. The readings this connection's Withings API plan withholds (status 277 since the 2026-10-12 plan split), deduplicated and in display order. Empty when the plan refuses nothing. The sync skips these and keeps importing everything else; the list refreshes when the weekly re-probe asks again.",
|
||||
),
|
||||
),
|
||||
needsReauth: z.boolean().optional().describe("Google Health only."),
|
||||
needsReauth: z.boolean().optional().meta(legacy("Google Health only.")),
|
||||
available: z.boolean().optional().describe("Oura / Polar / Strava only."),
|
||||
hasOwnCredentials: z
|
||||
.boolean()
|
||||
@@ -1094,15 +1107,17 @@ const integrationEnvelopeEntry = z
|
||||
scopeReconnect: z
|
||||
.array(z.enum(OURA_SCOPE_GATED_COLLECTIONS))
|
||||
.optional()
|
||||
.describe(
|
||||
"Oura only. Collections the stored grant does not cover: they answered 401 while sibling collections succeeded with the same token. Not a failure and not counted toward the reconnect threshold; one reconnect asks for the missing scopes. Empty when the grant covers everything HealthLog reads.",
|
||||
.meta(
|
||||
legacy(
|
||||
"Oura only. Collections the stored grant does not cover: they answered 401 while sibling collections succeeded with the same token. Not a failure and not counted toward the reconnect threshold; one reconnect asks for the missing scopes. Empty when the grant covers everything HealthLog reads.",
|
||||
),
|
||||
),
|
||||
hasToken: z.boolean().optional().describe("Nightscout only."),
|
||||
allowPrivateHost: z.boolean().optional().describe("Nightscout only."),
|
||||
health: connectionHealth
|
||||
.nullable()
|
||||
.describe(
|
||||
"The unified connection state, null when the provider is not connected. It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry, which go once the settings surface renders `health` (no other client reads this envelope).",
|
||||
"The unified connection state, null when the provider is not connected. It supersedes `state`, `lastError`, `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`, `hasActivityScope`, `planUnavailable` and `scopeReconnect` on this entry; those nine are deprecated and removed in v1.44.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
@@ -1127,7 +1142,7 @@ const integrationStatusEnvelope = z
|
||||
integrations: z
|
||||
.array(integrationEnvelopeEntry)
|
||||
.describe(
|
||||
"All nine providers, always, in the fixed order withings, whoop, fitbit, google-health, polar, oura, strava, nightscout, dawarich. A provider the user has never touched still appears, with `connected: false` and a synthesised `connected` ledger state — absence from this list never means anything, because nothing is ever absent from it.",
|
||||
"All nine providers, always, in the fixed order withings, whoop, fitbit, google-health, polar, oura, strava, nightscout, dawarich. A provider the user has never touched still appears, with `connected: false` and the ledger state `unknown` — absence from this list never means anything, because nothing is ever absent from it.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
@@ -2672,7 +2687,7 @@ export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
z
|
||||
.object({
|
||||
probe: z.literal("structure"),
|
||||
probedAt: z.iso.datetime({ offset: true }),
|
||||
probedAt: isoDateTime(),
|
||||
types: z
|
||||
.record(z.string(), z.unknown())
|
||||
.describe(
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
z.enum(LIFE_AI_SECTIONS),
|
||||
z.boolean(),
|
||||
),
|
||||
updatedAt: z.iso.datetime({ offset: true }),
|
||||
updatedAt: isoDateTime(),
|
||||
}),
|
||||
"LifeAiSectionsEnvelope",
|
||||
),
|
||||
@@ -251,7 +252,7 @@ export const lifeCircumstancesPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
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<ZodOpenApiObject["paths"]> = {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
removedAt: z.iso.datetime({ offset: true }),
|
||||
removedAt: isoDateTime(),
|
||||
}),
|
||||
"AuditCRemovedEnvelope",
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:<wake-day>` for a sleep night; `day:<local-day>` for a day bucket (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation` beyond 90 days); `hour:<ISO start>` 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()
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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."),
|
||||
})
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
"image/png",
|
||||
"image/webp",
|
||||
]),
|
||||
updatedAt: z.iso.datetime({ offset: true }),
|
||||
updatedAt: isoDateTime(),
|
||||
}),
|
||||
"UploadAvatarResponse",
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
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(),
|
||||
}),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user