mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +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
@@ -24,17 +24,6 @@ updates:
|
||||
- "patch"
|
||||
- "minor"
|
||||
ignore:
|
||||
# zod 4.5 made `z.iso.datetime()` require seconds, so a request carrying
|
||||
# `2026-01-01T06:15Z` that passes today would be refused with a 422 on
|
||||
# every route that validates a timestamp, and 4.6 rewrites nullable and
|
||||
# primitive unions in the generated JSON Schema from `anyOf` to `type`
|
||||
# arrays, which reshapes the OpenAPI document and every MCP tool schema.
|
||||
# Both are contract changes for the web client, the iOS app and MCP
|
||||
# callers, not a routine bump, so the move past 4.4 gets its own change
|
||||
# with the timestamp producers audited. Patch releases on 4.4 still
|
||||
# arrive.
|
||||
- dependency-name: "zod"
|
||||
versions: [">=4.5.0"]
|
||||
# Next 16.4.0 breaks URL state after history traversal: with a sheet
|
||||
# opened through `?doc=`, Back leaves it open, and `[` / `]` sometimes
|
||||
# do not move `?day=`. Reproduced in three end-to-end journeys (3 of 40
|
||||
|
||||
+3309
-3280
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,98 @@
|
||||
# Upgrade stopped at migration 0409
|
||||
|
||||
v1.43 removes the readable location columns that v1.42 replaced with sealed
|
||||
copies (`users.home_lat/home_lon/home_label`,
|
||||
`environment_contexts.lat/lon/location_label`,
|
||||
`environment_travel_locations.lat/lon/label`). Before it drops anything,
|
||||
migration `0409_drop_plaintext_location` checks that the v1.42 encryption
|
||||
backfill has sealed every one of those values. If any row still holds a
|
||||
readable location, the migration stops and the app container does not start.
|
||||
|
||||
## What you see
|
||||
|
||||
The container exits during the migration step with:
|
||||
|
||||
```text
|
||||
A migration failed to apply. New migrations cannot be applied before the error is recovered from. ...
|
||||
Migration name: 0409_drop_plaintext_location
|
||||
Database error code: P0001
|
||||
Database error:
|
||||
ERROR: HealthLog migration 0409 stopped: 3 home location(s), 0 location period(s) and 12 daily location(s) are still stored in plaintext. ...
|
||||
```
|
||||
|
||||
On every later start, Prisma refuses with `P3009` ("migrate found failed
|
||||
migrations in the target database") until the failed attempt is cleared.
|
||||
|
||||
## What it means
|
||||
|
||||
Nothing was lost and nothing was dropped. The check runs before any other
|
||||
statement in the migration, so the location columns and their values are all
|
||||
still there. The migrations before 0409 in the same image did apply; they only
|
||||
add tables, columns and enum values, so the previous release runs on top of
|
||||
them unchanged.
|
||||
|
||||
The cause is a v1.42 instance that was not running long enough for its
|
||||
encryption backfill to reach every account, for example a host that went from
|
||||
v1.41 straight to v1.43, or one that was stopped soon after updating to v1.42.
|
||||
|
||||
## How to recover
|
||||
|
||||
The order matters: clear the failed attempt first, because the previous
|
||||
release runs `prisma migrate deploy` on start as well and refuses to boot
|
||||
while a failed migration is on record.
|
||||
|
||||
```bash
|
||||
# 1. Mark the failed 0409 attempt as rolled back. The entrypoint is replaced
|
||||
# so the container does not try to migrate first.
|
||||
docker compose run --rm --entrypoint sh app -c \
|
||||
'NODE_PATH=/opt/prisma-cli/node_modules node /opt/prisma-cli/node_modules/prisma/build/index.js migrate resolve --rolled-back 0409_drop_plaintext_location'
|
||||
|
||||
# 2. Start the previous release. Set this in the compose `.env` (or your
|
||||
# platform's environment) and recreate the container.
|
||||
# HEALTHLOG_IMAGE_REF=:v1.42.4
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
The previous release starts the encryption backfill about three minutes after
|
||||
boot and again once a day. It works through the accounts one at a time. Watch
|
||||
it with the three counts the migration checks; all three must reach 0:
|
||||
|
||||
```bash
|
||||
docker compose exec -T db psql -U healthlog healthlog -c "
|
||||
SELECT
|
||||
(SELECT count(*) FROM users WHERE home_lat IS NOT NULL) AS home,
|
||||
(SELECT count(*) FROM environment_travel_locations WHERE lat IS NOT NULL) AS periods,
|
||||
(SELECT count(*) FROM environment_contexts WHERE lat IS NOT NULL) AS days;"
|
||||
```
|
||||
|
||||
The app log shows each pass as
|
||||
`[free-text-encryption-backfill] user=... ` lines. If the counts stop going
|
||||
down, look for `[free-text-encryption-backfill] user=... failed` in the log;
|
||||
the error on the line after it names the cause.
|
||||
|
||||
Once all three counts are 0:
|
||||
|
||||
```bash
|
||||
# 3. Dump the database. This is the copy you go back to if anything else
|
||||
# goes wrong; keep it with your encryption key.
|
||||
docker compose exec -T db pg_dump -U healthlog healthlog \
|
||||
| gzip > healthlog-before-0409-$(date +%F).sql.gz
|
||||
|
||||
# 4. Upgrade again: remove the pin (or set it to the new version) and
|
||||
# recreate the container. Migration 0409 now runs through.
|
||||
docker compose pull app
|
||||
docker compose up -d app
|
||||
curl -s http://localhost:3000/api/version
|
||||
```
|
||||
|
||||
## What not to do
|
||||
|
||||
- Do not clear the readable columns by hand to get past the check. The
|
||||
check exists because those values have no sealed copy yet; setting them to
|
||||
`NULL` deletes the location for good.
|
||||
- Do not run `migrate resolve --applied 0409_drop_plaintext_location`. That
|
||||
records the migration as done while the columns are still there, and the
|
||||
v1.43 schema no longer matches the database.
|
||||
|
||||
Background: [`deploy.md`](deploy.md) for the general update and rollback
|
||||
order, [`backup-restore.md`](backup-restore.md) for restoring a dump.
|
||||
@@ -403,5 +403,9 @@ The line is a notice and does not pause the start: the migrations run right
|
||||
after it. So the dump belongs before step 2, not after you see the line; by
|
||||
then the database is already being migrated.
|
||||
|
||||
If an update to v1.43 stops at migration `0409_drop_plaintext_location`,
|
||||
nothing was changed; the recovery is in
|
||||
[`docs/ops/migration-0409-plaintext-location.md`](../ops/migration-0409-plaintext-location.md).
|
||||
|
||||
For the ordering on a host where you want the migration result before the
|
||||
new container takes traffic, see [`docs/ops/deploy.md`](../ops/deploy.md).
|
||||
+1
-1
@@ -103,7 +103,7 @@
|
||||
"undici": "^8.11.2",
|
||||
"web-push": "^3.6.7",
|
||||
"yaml": "^2.9.1",
|
||||
"zod": "^4.4.3",
|
||||
"zod": "^4.6.7",
|
||||
"zod-openapi": "^6.0.2",
|
||||
"zxcvbn-typescript": "^5.0.1"
|
||||
},
|
||||
|
||||
Generated
+22
-27
@@ -58,10 +58,10 @@ importers:
|
||||
version: 3.2.2(react@19.3.0)
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.9.1
|
||||
version: 5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.89.0(@types/react@19.3.0)(react@19.3.0))(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3)
|
||||
version: 5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.89.0(@types/react@19.3.0)(react@19.3.0))(valibot@1.4.2(typescript@6.0.3))(zod@4.6.7)
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.32.1
|
||||
version: 1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3)
|
||||
version: 1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.6.7)
|
||||
'@napi-rs/canvas':
|
||||
specifier: 1.0.10
|
||||
version: 1.0.10
|
||||
@@ -192,11 +192,11 @@ importers:
|
||||
specifier: ^2.9.1
|
||||
version: 2.9.1
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
specifier: ^4.6.7
|
||||
version: 4.6.7
|
||||
zod-openapi:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2(zod@4.4.3)
|
||||
version: 6.0.2(zod@4.6.7)
|
||||
zxcvbn-typescript:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1
|
||||
@@ -6918,11 +6918,8 @@ packages:
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
zod@4.6.5:
|
||||
resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==}
|
||||
zod@4.6.7:
|
||||
resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==}
|
||||
|
||||
zxcvbn-typescript@5.0.1:
|
||||
resolution: {integrity: sha512-If2a6jdm278dfWmn6NL7oC1lNYRHOthBw0C78LsKvtAHHFNsMudvFhDn2fF/b74txWtSosJJiuJ7jBPn/xP6HQ==}
|
||||
@@ -7507,7 +7504,7 @@ snapshots:
|
||||
dependencies:
|
||||
hono: 4.13.7
|
||||
|
||||
'@hookform/resolvers@5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.89.0(@types/react@19.3.0)(react@19.3.0))(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3)':
|
||||
'@hookform/resolvers@5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.89.0(@types/react@19.3.0)(react@19.3.0))(valibot@1.4.2(typescript@6.0.3))(zod@4.6.7)':
|
||||
dependencies:
|
||||
'@standard-schema/utils': 0.3.0
|
||||
react-hook-form: 7.89.0(@types/react@19.3.0)(react@19.3.0)
|
||||
@@ -7517,7 +7514,7 @@ snapshots:
|
||||
ajv-formats: 2.1.1(ajv@8.20.0)
|
||||
effect: 3.20.0
|
||||
valibot: 1.4.2(typescript@6.0.3)
|
||||
zod: 4.4.3
|
||||
zod: 4.6.7
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
dependencies:
|
||||
@@ -7738,7 +7735,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3)':
|
||||
'@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.6.7)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.17(hono@4.13.7)
|
||||
ajv: 8.20.0
|
||||
@@ -7755,8 +7752,8 @@ snapshots:
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
raw-body: 3.0.2
|
||||
zod: 4.4.3
|
||||
zod-to-json-schema: 3.25.2(zod@4.4.3)
|
||||
zod: 4.6.7
|
||||
zod-to-json-schema: 3.25.2(zod@4.6.7)
|
||||
optionalDependencies:
|
||||
'@cfworker/json-schema': 4.1.1
|
||||
transitivePeerDependencies:
|
||||
@@ -10899,8 +10896,8 @@ snapshots:
|
||||
'@babel/parser': 7.29.9
|
||||
eslint: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
|
||||
hermes-parser: 0.25.1
|
||||
zod: 4.6.5
|
||||
zod-validation-error: 4.0.2(zod@4.6.5)
|
||||
zod: 4.6.7
|
||||
zod-validation-error: 4.0.2(zod@4.6.7)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -11760,7 +11757,7 @@ snapshots:
|
||||
tinyglobby: 0.2.17
|
||||
unbash: 4.0.12
|
||||
yaml: 2.9.1
|
||||
zod: 4.6.5
|
||||
zod: 4.6.7
|
||||
|
||||
language-subtag-registry@0.3.23: {}
|
||||
|
||||
@@ -13895,26 +13892,24 @@ snapshots:
|
||||
|
||||
zlibjs@0.3.1: {}
|
||||
|
||||
zod-openapi@6.0.2(zod@4.4.3):
|
||||
zod-openapi@6.0.2(zod@4.6.7):
|
||||
dependencies:
|
||||
zod: 4.4.3
|
||||
zod: 4.6.7
|
||||
|
||||
zod-to-json-schema@3.25.2(zod@3.25.76):
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod-to-json-schema@3.25.2(zod@4.4.3):
|
||||
zod-to-json-schema@3.25.2(zod@4.6.7):
|
||||
dependencies:
|
||||
zod: 4.4.3
|
||||
zod: 4.6.7
|
||||
|
||||
zod-validation-error@4.0.2(zod@4.6.5):
|
||||
zod-validation-error@4.0.2(zod@4.6.7):
|
||||
dependencies:
|
||||
zod: 4.6.5
|
||||
zod: 4.6.7
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.4.3: {}
|
||||
|
||||
zod@4.6.5: {}
|
||||
zod@4.6.7: {}
|
||||
|
||||
zxcvbn-typescript@5.0.1: {}
|
||||
@@ -10,7 +10,8 @@
|
||||
-- It refuses to run while any row still holds a plaintext location: such a
|
||||
-- row has not been sealed yet, and dropping the column would lose it. Start
|
||||
-- the previous release, let the backfill finish (the three counts below must
|
||||
-- all be 0), then upgrade again.
|
||||
-- all be 0), then upgrade again. The operator steps, including clearing the
|
||||
-- failed attempt, are in docs/ops/migration-0409-plaintext-location.md.
|
||||
DO $$
|
||||
DECLARE
|
||||
home_rows BIGINT;
|
||||
@@ -21,7 +22,7 @@ BEGIN
|
||||
SELECT count(*) INTO travel_rows FROM "environment_travel_locations" WHERE "lat" IS NOT NULL;
|
||||
SELECT count(*) INTO day_rows FROM "environment_contexts" WHERE "lat" IS NOT NULL;
|
||||
IF home_rows > 0 OR travel_rows > 0 OR day_rows > 0 THEN
|
||||
RAISE EXCEPTION 'HealthLog migration 0409 stopped: % home location(s), % location period(s) and % daily location(s) are still stored in plaintext. Run the previous release until its free-text encryption backfill reports zero remaining rows, then upgrade again. Nothing was changed.', home_rows, travel_rows, day_rows;
|
||||
RAISE EXCEPTION 'HealthLog migration 0409 stopped: % home location(s), % location period(s) and % daily location(s) are still stored in plaintext. Run the previous release until its free-text encryption backfill reports zero remaining rows, then upgrade again. Nothing was changed. Recovery steps: docs/ops/migration-0409-plaintext-location.md', home_rows, travel_rows, day_rows;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Every ISO instant under `src/` goes through `isoDateTime()`.
|
||||
*
|
||||
* Since v1.43 a request instant must carry seconds, and the refusal is counted
|
||||
* (`validation.datetime.no_seconds`) so a client on a minute-precision encoder
|
||||
* shows up in the logs. Both live in `src/lib/validations/datetime.ts`. A
|
||||
* schema that calls `z.iso.datetime(` or `z.string().datetime(` directly gets
|
||||
* zod's default message and is invisible to the counter, so a direct call
|
||||
* anywhere else is refused here.
|
||||
*
|
||||
* The matcher tolerates whitespace and line breaks between `z`, `.iso` /
|
||||
* `.string()` and `.datetime(`, because prettier splits long chains, and it
|
||||
* also refuses a bare `.datetime(` chained off anything, so an alias such as
|
||||
* `const s = z.string(); s.datetime()` cannot route around it. Comments are
|
||||
* stripped first so prose about the old form does not trip it.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
const HELPER = "lib/validations/datetime.ts";
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
|
||||
}
|
||||
|
||||
function stripComments(text: string): string {
|
||||
return text
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
const DIRECT_DATETIME = /\.\s*datetime\s*\(/g;
|
||||
const HELPER_CALL = /\bisoDateTime\s*\(/g;
|
||||
|
||||
function directDatetimeCalls(text: string): number {
|
||||
return (stripComments(text).match(DIRECT_DATETIME) ?? []).length;
|
||||
}
|
||||
|
||||
describe("datetime helper guard", () => {
|
||||
const files = sourceFiles();
|
||||
|
||||
it("finds no direct zod datetime call outside the helper", () => {
|
||||
const offenders = files
|
||||
.filter((rel) => rel !== HELPER)
|
||||
.flatMap((rel) => {
|
||||
const n = directDatetimeCalls(readFileSync(join(SRC, rel), "utf8"));
|
||||
return n > 0 ? [`${rel} (${n})`] : [];
|
||||
});
|
||||
expect(
|
||||
offenders,
|
||||
"use isoDateTime() from @/lib/validations/datetime instead",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the helper itself is the one place that calls zod", () => {
|
||||
expect(directDatetimeCalls(readFileSync(join(SRC, HELPER), "utf8"))).toBe(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("actually sees the call sites it guards", () => {
|
||||
// An empty sweep agrees with an empty offender list. The helper has
|
||||
// hundreds of callers; a count far below that means the walk or the
|
||||
// matcher went blind, not that the tree is clean.
|
||||
const calls = files
|
||||
.filter((rel) => rel !== HELPER)
|
||||
.reduce(
|
||||
(sum, rel) =>
|
||||
sum +
|
||||
(readFileSync(join(SRC, rel), "utf8").match(HELPER_CALL) ?? [])
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
expect(calls).toBeGreaterThan(350);
|
||||
});
|
||||
|
||||
it("matches the forms prettier produces", () => {
|
||||
expect(directDatetimeCalls("z.iso.datetime({ offset: true })")).toBe(1);
|
||||
expect(directDatetimeCalls("z.iso\n .datetime({ offset: true })")).toBe(1);
|
||||
expect(directDatetimeCalls("z\n .string()\n .datetime()")).toBe(1);
|
||||
expect(directDatetimeCalls("s . datetime ()")).toBe(1);
|
||||
expect(directDatetimeCalls("// z.iso.datetime() in prose")).toBe(0);
|
||||
expect(directDatetimeCalls("/* z.string().datetime() */")).toBe(0);
|
||||
expect(directDatetimeCalls("isoDateTime()")).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -160,6 +160,10 @@ function zodObjectShape(path: string, schemaName: string): ZodObjectShape {
|
||||
let current = expression;
|
||||
while (ts.isCallExpression(current)) {
|
||||
const callee = current.expression;
|
||||
// The shared instant helper (`@/lib/validations/datetime`) is a leaf.
|
||||
if (ts.isIdentifier(callee) && callee.text === "isoDateTime") {
|
||||
return [prefix];
|
||||
}
|
||||
expect(
|
||||
ts.isPropertyAccessExpression(callee),
|
||||
`${prefix || schemaName} contains an unsupported Zod call: ${current.getText(file)}`,
|
||||
|
||||
@@ -535,6 +535,10 @@ function zodObjectShape(path: string, schemaName: string): ZodObjectShape {
|
||||
let current = expression;
|
||||
while (ts.isCallExpression(current)) {
|
||||
const callee = current.expression;
|
||||
// The shared instant helper (`@/lib/validations/datetime`) is a leaf.
|
||||
if (ts.isIdentifier(callee) && callee.text === "isoDateTime") {
|
||||
return [prefix];
|
||||
}
|
||||
expect(
|
||||
ts.isPropertyAccessExpression(callee),
|
||||
`${prefix || schemaName} contains an unsupported Zod call: ${current.getText(file)}`,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { redactSecrets } from "@/lib/logging/redact";
|
||||
import type { WideEvent } from "@/lib/logging/types";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -26,8 +27,8 @@ const querySchema = z.object({
|
||||
traceId: z.string().trim().min(1).max(200).optional(),
|
||||
level: z.enum(["debug", "info", "warn", "error"]).optional(),
|
||||
action: z.string().trim().min(1).max(200).optional(),
|
||||
since: z.string().datetime().optional(),
|
||||
until: z.string().datetime().optional(),
|
||||
since: isoDateTime({ offset: false }).optional(),
|
||||
until: isoDateTime({ offset: false }).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(PROBLEMS_BUFFER_MAX).default(200),
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { redactSecrets } from "@/lib/logging/redact";
|
||||
import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B4: parse the union of legacy + new query params. Legacy
|
||||
@@ -27,8 +28,8 @@ const querySchema = z.object({
|
||||
actor: z.string().trim().min(1).max(200).optional(),
|
||||
action: z.string().trim().min(1).max(200).optional(),
|
||||
target: z.string().trim().min(1).max(200).optional(),
|
||||
since: z.string().datetime().optional(),
|
||||
until: z.string().datetime().optional(),
|
||||
since: isoDateTime({ offset: false }).optional(),
|
||||
until: isoDateTime({ offset: false }).optional(),
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -41,6 +41,7 @@ import { moodDateKey } from "@/lib/mood/date-key";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { deriveA1 } from "@/lib/mood/level-a";
|
||||
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
// Derived from canonical enum so round-trip export → import covers every
|
||||
// type. Previous hardcoded subset silently dropped 4 of 11 types
|
||||
@@ -51,12 +52,12 @@ const measurementSchema = z
|
||||
value: z.number(),
|
||||
unit: z.string(),
|
||||
// v1.17.1 — share the canonical entry-instant bound used by the single
|
||||
// POST + edit paths (v1.17 W1b). Previously a bare `z.string().datetime()`
|
||||
// POST + edit paths (v1.17 W1b). Previously a bare datetime check
|
||||
// with no ceiling, so a crafted JSON import could forward-date a reading.
|
||||
// `validateEntryInstant` rejects any future instant beyond a 5-min skew
|
||||
// tolerance and any instant before 1900; the field transforms to a `Date`.
|
||||
measuredAt: validateEntryInstant(
|
||||
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
isoDateTime().transform((s) => new Date(s)),
|
||||
),
|
||||
glucoseContext: glucoseContextEnum.optional(),
|
||||
source: z.string().optional(),
|
||||
@@ -96,7 +97,7 @@ const moodEntrySchema = z
|
||||
// v1.17.1 — same entry-instant bound as measurements; an imported mood
|
||||
// log cannot be forward-dated. Optional + transforms to a `Date` when set.
|
||||
loggedAt: validateEntryInstant(
|
||||
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
isoDateTime().transform((s) => new Date(s)),
|
||||
).optional(),
|
||||
// v1.12.1 — optional source-stable id (e.g. a Daylio row id). When
|
||||
// present, the import upserts on `(userId, source, externalId)` so a
|
||||
|
||||
@@ -46,6 +46,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { persistEcgRecording } from "@/lib/ecg/persist-recording";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -96,7 +97,7 @@ const ingestSourceEnum = z.enum(["APPLE_HEALTH"]);
|
||||
const ecgIngestSchema = z
|
||||
.object({
|
||||
externalRecordingId: z.string().min(1).max(120),
|
||||
recordedAt: z.iso.datetime({ offset: true }),
|
||||
recordedAt: isoDateTime(),
|
||||
// HealthKit hands both of these over as doubles. A watch reports its
|
||||
// average heart rate with a fraction (64.7) often enough that requiring
|
||||
// an integer refused most new recordings while the old ones, synced when
|
||||
|
||||
@@ -112,6 +112,7 @@ import {
|
||||
type MeasurementSource,
|
||||
type MeasurementType,
|
||||
} from "@/generated/prisma/client";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
// v1.4.25 W16c — historical-backfill threshold for PR push
|
||||
// suppression. A batch larger than this fires the detection job with
|
||||
@@ -156,8 +157,8 @@ const batchEntrySchema = z.object({
|
||||
hkIdentifier: z.string().min(1).max(120),
|
||||
value: z.number().finite(),
|
||||
unit: z.string().min(1).max(60),
|
||||
startDate: z.iso.datetime({ offset: true }),
|
||||
endDate: z.iso.datetime({ offset: true }),
|
||||
startDate: isoDateTime(),
|
||||
endDate: isoDateTime(),
|
||||
sleepStage: z.number().int().min(0).max(20).optional(),
|
||||
// v1.10.0 — categorical events (WX-B). The integer `HKCategoryValue`
|
||||
// codepoint for an EVENT-class category sample (irregular-rhythm,
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { dueSchedules } from "@/lib/medications/intake-tracking";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -60,12 +61,10 @@ const RATE_WINDOW_MS = 60_000;
|
||||
|
||||
const querySchema = z
|
||||
.object({
|
||||
from: z.iso
|
||||
.datetime({ offset: true })
|
||||
from: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
to: z.iso
|
||||
.datetime({ offset: true })
|
||||
to: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
})
|
||||
|
||||
@@ -101,6 +101,7 @@ import {
|
||||
INTAKE_NOT_TRACKED_REASON,
|
||||
isRecordOnly,
|
||||
} from "@/lib/medications/intake-tracking";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
const MAX_ENTRIES_PER_BATCH = 500;
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
@@ -109,8 +110,7 @@ const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
const bulkEntrySchema = z
|
||||
.object({
|
||||
medicationId: z.string().min(1),
|
||||
scheduledFor: z.iso
|
||||
.datetime({ offset: true })
|
||||
scheduledFor: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
// v1.16.9 — shared plausibility bounds (no future beyond clock skew,
|
||||
@@ -135,8 +135,7 @@ const bulkEntrySchema = z
|
||||
// entry skipped (`force_slot_invalid`) per the bulk per-entry contract.
|
||||
// Ignored on non-taken entries (a pending echo / skip carries no take
|
||||
// to attribute).
|
||||
forceSlotInstant: z.iso
|
||||
.datetime({ offset: true })
|
||||
forceSlotInstant: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
// v1.16.4 — per-entry dose override, mirroring the single intake
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
droppedLinkKeysForWire,
|
||||
NO_DROPPED_LINK_KEYS,
|
||||
} from "@/lib/mood/tag-links";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
const MAX_ENTRIES_PER_BATCH = 500;
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
@@ -135,7 +136,7 @@ const bulkEntrySchema = z.object({
|
||||
* @per-entry-parse: moodContextSchema, in the loop below
|
||||
*/
|
||||
context: z.unknown().optional(),
|
||||
moodLoggedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
moodLoggedAt: isoDateTime().transform((s) => new Date(s)),
|
||||
source: moodSourceEnum.optional().default("MANUAL"),
|
||||
/**
|
||||
* Optional iOS-side identifier (e.g. SwiftData row UUID) that lets
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
TIMELINE_RANGE_MAX_DAYS,
|
||||
TIMELINE_ZOOMS,
|
||||
} from "./contract";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/* ─── Shared pieces ───────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -40,7 +41,7 @@ export const dateKeySchema = z
|
||||
.refine(isCalendarDateKey, { message: "Expected a YYYY-MM-DD calendar date" })
|
||||
.describe("A local calendar date, `YYYY-MM-DD`.");
|
||||
|
||||
const instant = z.iso.datetime({ offset: true });
|
||||
const instant = isoDateTime();
|
||||
|
||||
const messageParams = z
|
||||
.record(z.string(), z.union([z.string(), z.number()]))
|
||||
@@ -212,7 +213,7 @@ export const dayMovementSchema = z
|
||||
countryCode: z.string().nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
fetchedAt: z.iso.datetime({ offset: true }),
|
||||
fetchedAt: isoDateTime(),
|
||||
})
|
||||
.meta({
|
||||
id: "DayMovement",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { eventStorage } from "@/lib/logging/context";
|
||||
import { WideEventBuilder } from "@/lib/logging/event-builder";
|
||||
import {
|
||||
DATETIME_NO_SECONDS_COUNTER,
|
||||
isoDateTime,
|
||||
} from "@/lib/validations/datetime";
|
||||
|
||||
describe("validation.datetime.no_seconds", () => {
|
||||
it("counts each minute-precision refusal on the request's wide event", () => {
|
||||
const builder = new WideEventBuilder("http");
|
||||
eventStorage.run(builder, () => {
|
||||
const s = isoDateTime();
|
||||
s.safeParse("2026-10-15T06:15Z");
|
||||
s.safeParse("2026-10-15T07:15+02:00");
|
||||
s.safeParse("2026-10-15T07:15:00Z");
|
||||
s.safeParse("garbage");
|
||||
});
|
||||
expect(builder.toJSON().meta?.[DATETIME_NO_SECONDS_COUNTER]).toBe(2);
|
||||
});
|
||||
|
||||
it("leaves a request without such a refusal untouched", () => {
|
||||
const builder = new WideEventBuilder("http");
|
||||
eventStorage.run(builder, () => {
|
||||
isoDateTime().safeParse("2026-10-15T07:15:00Z");
|
||||
});
|
||||
expect(
|
||||
builder.toJSON().meta?.[DATETIME_NO_SECONDS_COUNTER],
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("is a no-op outside a request", () => {
|
||||
expect(() => isoDateTime().safeParse("2026-10-15T06:15Z")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { WideEvent } from "./types";
|
||||
import { WideEventBuilder } from "./event-builder";
|
||||
import { registerSourcePriorityParseObserver } from "@/lib/validations/source-priority";
|
||||
import {
|
||||
DATETIME_NO_SECONDS_COUNTER,
|
||||
registerDatetimeNoSecondsObserver,
|
||||
} from "@/lib/validations/datetime";
|
||||
|
||||
/** AsyncLocalStorage fuer den aktuellen Request-Kontext */
|
||||
export const eventStorage = new AsyncLocalStorage<WideEventBuilder>();
|
||||
@@ -37,3 +41,16 @@ export function annotate(fields: {
|
||||
registerSourcePriorityParseObserver((fields) => {
|
||||
annotate(fields);
|
||||
});
|
||||
|
||||
// v1.43 — count every request instant refused for lacking seconds, per wide
|
||||
// event, so a client still on a minute-precision encoder is visible in the
|
||||
// logs (`meta["validation.datetime.no_seconds"]`) before its users report
|
||||
// the 422. Same bundle-safe registration as the breadcrumb above.
|
||||
const noSecondsCounts = new WeakMap<WideEventBuilder, number>();
|
||||
registerDatetimeNoSecondsObserver(() => {
|
||||
const evt = getEvent();
|
||||
if (!evt) return;
|
||||
const next = (noSecondsCounts.get(evt) ?? 0) + 1;
|
||||
noSecondsCounts.set(evt, next);
|
||||
evt.addMeta(DATETIME_NO_SECONDS_COUNTER, next);
|
||||
});
|
||||
@@ -104,6 +104,7 @@ import {
|
||||
import type { McpAuthContext } from "./auth";
|
||||
import { dueSchedules } from "@/lib/medications/intake-tracking";
|
||||
import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* Tool annotations (MCP 2025-11-25). The cloud connectors REQUIRE these on every
|
||||
@@ -897,8 +898,8 @@ const coachReadOutput: z.ZodRawShape = {
|
||||
*/
|
||||
const dateRangeShape = z
|
||||
.object({
|
||||
from: z.iso.datetime({ offset: true }),
|
||||
to: z.iso.datetime({ offset: true }),
|
||||
from: isoDateTime(),
|
||||
to: isoDateTime(),
|
||||
})
|
||||
.describe(
|
||||
"An explicit inclusive date range. Use INSTEAD OF `window` for an arbitrary span (e.g. before vs after a date). Both bounds are ISO-8601 instants.",
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
checkMcpBloodPressure,
|
||||
resolveMcpMeasurementUnit,
|
||||
} from "./writes";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* Write-tool annotations (MCP 2025-11-25). NOT read-only and NOT destructive
|
||||
@@ -187,8 +188,7 @@ const WRITE_TOOL_DEFINITIONS: McpToolDefinition[] = [
|
||||
.describe(
|
||||
"Optional unit hint. Omit to use the type's canonical unit. A recognised alias (lb/lbs/pound(s), in/inch(es), °F, mmol/L) is converted to canonical; any other unit is refused with unsupported_unit — the value is never stored in a non-canonical unit.",
|
||||
),
|
||||
measuredAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
measuredAt: isoDateTime()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional ISO-8601 instant the reading was taken; defaults to now.",
|
||||
@@ -379,8 +379,7 @@ const WRITE_TOOL_DEFINITIONS: McpToolDefinition[] = [
|
||||
inputShape: {
|
||||
systolic: z.number().describe("Systolic (the higher number), in mmHg."),
|
||||
diastolic: z.number().describe("Diastolic (the lower number), in mmHg."),
|
||||
measuredAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
measuredAt: isoDateTime()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional ISO-8601 instant the reading was taken; defaults to now.",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
MedicationSideEffectCategory,
|
||||
MedicationSideEffectEntry,
|
||||
} from "@/generated/prisma/client";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* v1.4.25 W21 Fix-N (simp-M1) — these arrays used to restate the
|
||||
@@ -54,20 +55,17 @@ const SIDE_EFFECT_NOTES_MAX = 280;
|
||||
export const createSideEffectSchema = z.object({
|
||||
entry: z.nativeEnum(MedicationSideEffectEntry),
|
||||
severity: z.number().int().min(1).max(5),
|
||||
occurredAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
occurredAt: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
notes: z.string().max(SIDE_EFFECT_NOTES_MAX).nullable().optional(),
|
||||
});
|
||||
|
||||
export const listSideEffectsSchema = z.object({
|
||||
from: z.iso
|
||||
.datetime({ offset: true })
|
||||
from: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
to: z.iso
|
||||
.datetime({ offset: true })
|
||||
to: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import { z } from "zod/v4";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/** Terminal / running status of the module tour. */
|
||||
export const TOUR_PROGRESS_STATUSES = [
|
||||
@@ -33,7 +34,7 @@ export const tourProgressSchema = z.object({
|
||||
lastStopId: z.string().min(1).max(64).nullable(),
|
||||
completedStopIds: z.array(z.string().min(1).max(64)).max(64).default([]),
|
||||
status: z.enum(TOUR_PROGRESS_STATUSES),
|
||||
updatedAt: z.iso.datetime(),
|
||||
updatedAt: isoDateTime({ offset: false }),
|
||||
});
|
||||
|
||||
export type TourProgress = z.infer<typeof tourProgressSchema>;
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
|
||||
import { validateEntryInstant } from "@/lib/validations/entry-instant";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* Person-defined symptoms (v1.40): client-safe constants, wire types and the
|
||||
@@ -108,8 +109,7 @@ const intensitySchema = z
|
||||
.max(SYMPTOM_INTENSITY_MAX);
|
||||
|
||||
const occurredAtSchema = validateEntryInstant(
|
||||
z.iso
|
||||
.datetime({ offset: true })
|
||||
isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.pipe(z.date()),
|
||||
);
|
||||
@@ -141,8 +141,8 @@ export const symptomEventUpdateSchema = z
|
||||
);
|
||||
|
||||
export const symptomEventListQuerySchema = z.object({
|
||||
from: z.iso.datetime({ offset: true }).optional(),
|
||||
to: z.iso.datetime({ offset: true }).optional(),
|
||||
from: isoDateTime().optional(),
|
||||
to: isoDateTime().optional(),
|
||||
definitionId: z.string().min(1).max(40).optional(),
|
||||
episodeId: z.string().min(1).max(40).optional(),
|
||||
limit: z.coerce
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
DATETIME_NO_SECONDS_MESSAGE,
|
||||
isoDateTime,
|
||||
registerDatetimeNoSecondsObserver,
|
||||
} from "@/lib/validations/datetime";
|
||||
|
||||
describe("isoDateTime", () => {
|
||||
it("accepts an instant with seconds, with or without fraction and offset", () => {
|
||||
const s = isoDateTime();
|
||||
for (const v of [
|
||||
"2026-10-15T06:15:00Z",
|
||||
"2026-10-15T06:15:00.000Z",
|
||||
"2026-10-15T06:15:00.123456Z",
|
||||
"2026-10-15T06:15:00+02:00",
|
||||
]) {
|
||||
expect(s.safeParse(v).success, v).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a minute-precision instant and says seconds are missing", () => {
|
||||
for (const v of ["2026-10-15T06:15Z", "2026-10-15T06:15+02:00"]) {
|
||||
const r = isoDateTime().safeParse(v);
|
||||
expect(r.success, v).toBe(false);
|
||||
expect(r.error?.issues[0].message).toBe(DATETIME_NO_SECONDS_MESSAGE);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the caller's message for every other refusal", () => {
|
||||
const s = isoDateTime({ offset: false, message: "takenAt is bad" });
|
||||
expect(s.safeParse("yesterday").error?.issues[0].message).toBe(
|
||||
"takenAt is bad",
|
||||
);
|
||||
expect(s.safeParse("2026-10-15T06:15Z").error?.issues[0].message).toBe(
|
||||
DATETIME_NO_SECONDS_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it("offset: false accepts only Z and does not count an offset form", () => {
|
||||
const s = isoDateTime({ offset: false });
|
||||
expect(s.safeParse("2026-10-15T06:15:00Z").success).toBe(true);
|
||||
expect(s.safeParse("2026-10-15T06:15:00+02:00").success).toBe(false);
|
||||
expect(
|
||||
s.safeParse("2026-10-15T06:15+02:00").error?.issues[0].message,
|
||||
).not.toBe(DATETIME_NO_SECONDS_MESSAGE);
|
||||
});
|
||||
|
||||
it("tells the observer about minute-precision refusals only", () => {
|
||||
const seen = vi.fn();
|
||||
registerDatetimeNoSecondsObserver(seen);
|
||||
const s = isoDateTime();
|
||||
s.safeParse("2026-10-15T06:15Z");
|
||||
s.safeParse("not a date");
|
||||
s.safeParse("2026-10-15T06:15:00Z");
|
||||
expect(seen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* How long a value naming an account may be.
|
||||
@@ -41,7 +42,7 @@ export const inviteGrantSchema = z
|
||||
* ends it — the common household case, where an expiry the owner has to
|
||||
* remember to renew is worse than one that does not exist.
|
||||
*/
|
||||
expiresAt: z.iso.datetime({ offset: true }).nullable().optional(),
|
||||
expiresAt: isoDateTime().nullable().optional(),
|
||||
/**
|
||||
* What the invitation offers, and the ONLY place the level is defaulted.
|
||||
*
|
||||
|
||||
@@ -14,17 +14,19 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* Plausible-instant bound shared with the measurement / illness paths: no
|
||||
* future instant beyond the 5-min skew, no instant before 1900. Stays a
|
||||
* string on the wire (iOS reads it back verbatim).
|
||||
*/
|
||||
const boundedInstant = z.iso
|
||||
.datetime({ offset: true })
|
||||
.refine((s) => isPlausibleEntryInstant(new Date(s)), {
|
||||
const boundedInstant = isoDateTime().refine(
|
||||
(s) => isPlausibleEntryInstant(new Date(s)),
|
||||
{
|
||||
message: "must be a plausible instant (not future, not pre-1900)",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/* ── enums (mirror the Prisma enums) ─────────────────────────────── */
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { reportSelectionSchema } from "@/lib/report-selection/selection";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* The one leaf a share link may never carry. The share view has never
|
||||
@@ -56,8 +57,8 @@ export const SHARE_LINK_MAX_DOCUMENTS = 50;
|
||||
export const createShareLinkSchema = z
|
||||
.object({
|
||||
label: z.string().trim().min(1).max(120),
|
||||
rangeStart: z.iso.datetime({ offset: true }),
|
||||
rangeEnd: z.iso.datetime({ offset: true }).nullable().optional(),
|
||||
rangeStart: isoDateTime(),
|
||||
rangeEnd: isoDateTime().nullable().optional(),
|
||||
selection: reportSelectionSchema.optional(),
|
||||
documentIds: z
|
||||
.array(z.string().trim().min(1).max(64))
|
||||
@@ -72,8 +73,7 @@ export const createShareLinkSchema = z
|
||||
* server-side so a document link can never widen into a record.
|
||||
*/
|
||||
documentOnly: z.boolean().optional(),
|
||||
expiresAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
expiresAt: isoDateTime()
|
||||
.refine((v) => new Date(v).getTime() > Date.now(), {
|
||||
message: "expiresAt must be in the future",
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { COACH_PLAN_STATUSES } from "@/lib/ai/coach/plans";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* Optional `?scope=` filter on the list endpoint — a named status group so
|
||||
@@ -48,7 +49,7 @@ export const coachPlansListQuerySchema = z
|
||||
export const coachPlanPatchSchema = z
|
||||
.object({
|
||||
status: z.enum(COACH_PLAN_STATUSES).optional(),
|
||||
reviewDate: z.iso.datetime({ offset: true }).nullable().optional(),
|
||||
reviewDate: isoDateTime().nullable().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* v1.4.40 SB-10 — AI consent receipts (App-Store Guideline 5.1.2(i) +
|
||||
@@ -51,7 +52,7 @@ export const consentPostBody = z.object({
|
||||
.refine((value) => Buffer.byteLength(value, "utf8") <= ARTEFACT_MAX_BYTES, {
|
||||
message: "artefact exceeds 64 KB cap (UTF-8 byte length)",
|
||||
}),
|
||||
signedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
signedAt: isoDateTime().transform((s) => new Date(s)),
|
||||
});
|
||||
|
||||
export type ConsentPostBody = z.infer<typeof consentPostBody>;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { validateEntryInstant } from "@/lib/validations/entry-instant";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/** Trimmed, bounded free-text field with a min-length guard. */
|
||||
const requiredText = (max: number) => z.string().trim().min(1).max(max);
|
||||
@@ -98,10 +99,10 @@ const ENTRY_MAX_AGE_MS = 50 * 365.25 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Shared `measuredAt` field: ISO string → Date, plausibility-bounded. */
|
||||
const measuredAtField = validateEntryInstant(
|
||||
z
|
||||
.string()
|
||||
.datetime({ message: "measuredAt must be an ISO 8601 datetime" })
|
||||
.transform((s) => new Date(s)),
|
||||
isoDateTime({
|
||||
offset: false,
|
||||
message: "measuredAt must be an ISO 8601 datetime",
|
||||
}).transform((s) => new Date(s)),
|
||||
{
|
||||
maxAgeMs: ENTRY_MAX_AGE_MS,
|
||||
pastMessage: "Measurement date is too far in the past",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { z } from "zod/v4";
|
||||
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
|
||||
import { assertStableExternalId } from "@/lib/validations/external-id";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* v1.17 W1b — `loggedAt` instant on the cycle wire stays a string (the iOS
|
||||
@@ -21,11 +22,12 @@ import { assertStableExternalId } from "@/lib/validations/external-id";
|
||||
* the measurement / mood / medication-intake paths use: no future instant
|
||||
* beyond the 5-min skew, no instant before 1900.
|
||||
*/
|
||||
const boundedLoggedAt = z.iso
|
||||
.datetime({ offset: true })
|
||||
.refine((s) => isPlausibleEntryInstant(new Date(s)), {
|
||||
const boundedLoggedAt = isoDateTime().refine(
|
||||
(s) => isPlausibleEntryInstant(new Date(s)),
|
||||
{
|
||||
message: "loggedAt must be a plausible instant (not future, not pre-1900)",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/* ── enums (mirror the Prisma enums) ─────────────────────────────── */
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* The one ISO 8601 instant validator every schema uses.
|
||||
*
|
||||
* Since v1.43 an instant must carry seconds: `2026-10-15T06:15:00Z`,
|
||||
* `2026-10-15T06:15:00.000Z` and `2026-10-15T06:15:00+02:00` pass,
|
||||
* `2026-10-15T06:15Z` is refused. zod 4.5 made that the behaviour of
|
||||
* `z.iso.datetime()`; routing every call site through this helper keeps the
|
||||
* rule in one place, gives the refusal a message that says what is missing,
|
||||
* and counts each refusal so a client that still sends minute-precision
|
||||
* instants shows up in the logs before its users do.
|
||||
*
|
||||
* `src/__tests__/datetime-helper-guard.test.ts` forbids a direct
|
||||
* `z.iso.datetime(` or `z.string().datetime(` anywhere else under `src/`.
|
||||
*
|
||||
* Client-safe: the counter goes through an observer the server logging module
|
||||
* registers on import (the same pattern as `registerSourcePriorityParseObserver`),
|
||||
* so a form that validates with these schemas never pulls `node:async_hooks`
|
||||
* into the browser bundle.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/** Wide-event meta key: how many instants this request refused for lacking seconds. */
|
||||
export const DATETIME_NO_SECONDS_COUNTER = "validation.datetime.no_seconds";
|
||||
|
||||
/** Message for an instant that would be valid if it carried seconds. */
|
||||
export const DATETIME_NO_SECONDS_MESSAGE =
|
||||
"Invalid ISO datetime: seconds are required (YYYY-MM-DDTHH:mm:ssZ)";
|
||||
|
||||
// Date, hours and minutes, then an optional zone and nothing else: the shape a
|
||||
// minute-precision encoder produces. Only this shape counts, so the counter
|
||||
// measures exactly the clients the seconds rule turned away and not every
|
||||
// malformed string. A field that requires `Z` does not count an offset form,
|
||||
// which it would refuse with seconds too.
|
||||
const MINUTE_PRECISION_OFFSET =
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?:Z|[+-]\d{2}:\d{2})$/;
|
||||
const MINUTE_PRECISION_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/;
|
||||
|
||||
type NoSecondsObserver = () => void;
|
||||
let noSecondsObserver: NoSecondsObserver = () => {};
|
||||
|
||||
/**
|
||||
* Server-side opt-in for the refusal counter. Called once from
|
||||
* `@/lib/logging/context`; the browser keeps the no-op default.
|
||||
*/
|
||||
export function registerDatetimeNoSecondsObserver(
|
||||
observer: NoSecondsObserver,
|
||||
): void {
|
||||
noSecondsObserver = observer;
|
||||
}
|
||||
|
||||
export interface IsoDateTimeOptions {
|
||||
/**
|
||||
* Accept a numeric UTC offset (`+02:00`) besides `Z`. Default `true`;
|
||||
* `false` keeps the handful of fields that have always required `Z`.
|
||||
*/
|
||||
offset?: boolean;
|
||||
/** Message for every other refusal; zod's default when omitted. */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An ISO 8601 instant with seconds. Use it for every timestamp a schema
|
||||
* accepts or publishes; chain `.optional()`, `.nullable()`, `.describe()` and
|
||||
* `.meta()` as on any zod string.
|
||||
*/
|
||||
export function isoDateTime(options: IsoDateTimeOptions = {}) {
|
||||
const { offset = true, message } = options;
|
||||
const minutePrecision = offset
|
||||
? MINUTE_PRECISION_OFFSET
|
||||
: MINUTE_PRECISION_UTC;
|
||||
return (
|
||||
z.iso
|
||||
.datetime({
|
||||
offset,
|
||||
// zod builds issue messages lazily, when a caller first reads them,
|
||||
// so this callback only words the refusal; it cannot count it.
|
||||
error: (issue) =>
|
||||
typeof issue.input === "string" && minutePrecision.test(issue.input)
|
||||
? DATETIME_NO_SECONDS_MESSAGE
|
||||
: message,
|
||||
})
|
||||
// The count runs here instead: a refinement runs on every parse of a
|
||||
// string, even after the format check failed, and adds no issue.
|
||||
.refine((value) => {
|
||||
if (minutePrecision.test(value)) noSecondsObserver();
|
||||
return true;
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
* park a row a century out.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/** Ten years out. Long enough for any real appointment, short enough that a
|
||||
* mistyped year lands as a 422 rather than as a row nobody sees again. */
|
||||
@@ -30,7 +31,7 @@ const MAX_FUTURE_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
||||
* five-minute clock skew. That bound is right for a measurement, which can only
|
||||
* describe something that already happened, and wrong here.
|
||||
*/
|
||||
const visitInstant = z.iso.datetime({ offset: true }).refine(
|
||||
const visitInstant = isoDateTime().refine(
|
||||
(value) => {
|
||||
const at = new Date(value).getTime();
|
||||
if (Number.isNaN(at)) return false;
|
||||
@@ -150,8 +151,8 @@ export type EncounterUpdate = z.infer<typeof encounterUpdateSchema>;
|
||||
*/
|
||||
export const encounterListQuerySchema = z
|
||||
.object({
|
||||
from: z.iso.datetime({ offset: true }).optional(),
|
||||
to: z.iso.datetime({ offset: true }).optional(),
|
||||
from: isoDateTime().optional(),
|
||||
to: isoDateTime().optional(),
|
||||
status: encounterStatusEnum.optional(),
|
||||
practitionerId: id.optional(),
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
export const HEALTH_PROFILE_AI_SECTIONS = [
|
||||
"ABOUT_ME",
|
||||
@@ -116,11 +117,11 @@ export const healthProfileFactDtoSchema = z.object({
|
||||
kind: healthProfileFactKindSchema,
|
||||
value: z.string().nullable(),
|
||||
unreadable: z.boolean(),
|
||||
validFrom: z.iso.datetime({ offset: true }),
|
||||
validUntil: z.iso.datetime({ offset: true }).nullable(),
|
||||
validFrom: isoDateTime(),
|
||||
validUntil: isoDateTime().nullable(),
|
||||
provenance: z.enum(["USER_REPORTED", "USER_CORRECTION"]),
|
||||
supersededByRevisionId: z.string().nullable(),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
createdAt: isoDateTime(),
|
||||
});
|
||||
|
||||
export const healthProfileFactsResponseSchema = z.object({
|
||||
|
||||
@@ -22,14 +22,15 @@ import { z } from "zod/v4";
|
||||
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import { reportSelectionSchema } from "@/lib/report-selection/selection";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
export const exportSelectionSchema = z
|
||||
.object({
|
||||
format: z.enum(["pdf", "fhir", "package"]),
|
||||
range: z
|
||||
.object({
|
||||
startDate: z.iso.datetime({ offset: true }).optional(),
|
||||
endDate: z.iso.datetime({ offset: true }).optional(),
|
||||
startDate: isoDateTime().optional(),
|
||||
endDate: isoDateTime().optional(),
|
||||
days: z.number().int().min(1).max(365).optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -16,17 +16,19 @@
|
||||
import { z } from "zod/v4";
|
||||
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
|
||||
import { lateralityEnum } from "@/lib/validations/encounters";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* Plausible-instant bound shared with the measurement / mood / cycle
|
||||
* paths: no future instant beyond the 5-min skew, no instant before 1900.
|
||||
* Stays a string on the wire (iOS reads it back verbatim).
|
||||
*/
|
||||
const boundedInstant = z.iso
|
||||
.datetime({ offset: true })
|
||||
.refine((s) => isPlausibleEntryInstant(new Date(s)), {
|
||||
const boundedInstant = isoDateTime().refine(
|
||||
(s) => isPlausibleEntryInstant(new Date(s)),
|
||||
{
|
||||
message: "must be a plausible instant (not future, not pre-1900)",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const dateString = z
|
||||
.string()
|
||||
@@ -141,8 +143,8 @@ export const illnessEpisodeListQuerySchema = z.object({
|
||||
* episodes around a visit and pages older or newer from there.
|
||||
*/
|
||||
offset: z.coerce.number().int().min(0).max(100_000).optional(),
|
||||
from: z.iso.datetime({ offset: true }).optional(),
|
||||
to: z.iso.datetime({ offset: true }).optional(),
|
||||
from: isoDateTime().optional(),
|
||||
to: isoDateTime().optional(),
|
||||
/** Onset order; newest first by default. */
|
||||
sortDir: z.enum(["asc", "desc"]).optional(),
|
||||
/** Case-insensitive substring over the episode's label. */
|
||||
|
||||
@@ -22,6 +22,7 @@ import { z } from "zod/v4";
|
||||
import type { WrittenOutcome } from "@/lib/outcome/written-outcome";
|
||||
|
||||
import { validateEntryInstant } from "@/lib/validations/entry-instant";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/** Max rows a single scan / commit may carry — a dense panel is ~30 analytes. */
|
||||
export const OCR_MAX_ROWS = 100;
|
||||
@@ -34,10 +35,10 @@ const LAB_TAKEN_AT_MAX_AGE_MS = 50 * 365.25 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Shared committed-row `takenAt`: ISO string → Date, plausibility-bounded. */
|
||||
const takenAtField = validateEntryInstant(
|
||||
z
|
||||
.string()
|
||||
.datetime({ message: "takenAt must be an ISO 8601 datetime" })
|
||||
.transform((s) => new Date(s)),
|
||||
isoDateTime({
|
||||
offset: false,
|
||||
message: "takenAt must be an ISO 8601 datetime",
|
||||
}).transform((s) => new Date(s)),
|
||||
{
|
||||
maxAgeMs: LAB_TAKEN_AT_MAX_AGE_MS,
|
||||
pastMessage: "Sample date is too far in the past",
|
||||
|
||||
@@ -16,6 +16,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { SOURCE_REFERENCE_TEXT_MAX } from "@/lib/labs/parse-reference-range";
|
||||
import { validateEntryInstant } from "@/lib/validations/entry-instant";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* Far-past floor for a lab sample. A self-hoster legitimately backdates an
|
||||
@@ -26,10 +27,10 @@ const LAB_TAKEN_AT_MAX_AGE_MS = 50 * 365.25 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Shared `takenAt` field: ISO string → Date, plausibility-bounded. */
|
||||
const takenAtField = validateEntryInstant(
|
||||
z
|
||||
.string()
|
||||
.datetime({ message: "takenAt must be an ISO 8601 datetime" })
|
||||
.transform((s) => new Date(s)),
|
||||
isoDateTime({
|
||||
offset: false,
|
||||
message: "takenAt must be an ISO 8601 datetime",
|
||||
}).transform((s) => new Date(s)),
|
||||
{
|
||||
maxAgeMs: LAB_TAKEN_AT_MAX_AGE_MS,
|
||||
pastMessage: "Sample date is too far in the past",
|
||||
@@ -289,14 +290,10 @@ export const listLabResultsSchema = z.object({
|
||||
* picker can find one result in a long history without paging to it.
|
||||
*/
|
||||
q: z.string().trim().min(1).max(120).optional(),
|
||||
from: z
|
||||
.string()
|
||||
.datetime()
|
||||
from: isoDateTime({ offset: false })
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
to: z
|
||||
.string()
|
||||
.datetime()
|
||||
to: isoDateTime({ offset: false })
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).default(100),
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SMOKING_STATUS_VALUES,
|
||||
isHealthProfileFactValue,
|
||||
} from "@/lib/validations/health-profile-facts";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
// ── Fact kinds and their values ──────────────────────────────────────────────
|
||||
|
||||
@@ -189,7 +190,7 @@ export type LifeAiSection = (typeof LIFE_AI_SECTIONS)[number];
|
||||
export const lifeAiSectionsPatchSchema = z
|
||||
.strictObject({
|
||||
sections: z.partialRecord(z.enum(LIFE_AI_SECTIONS), z.boolean()),
|
||||
baseUpdatedAt: z.iso.datetime({ offset: true }).optional(),
|
||||
baseUpdatedAt: isoDateTime().optional(),
|
||||
})
|
||||
.refine((value) => Object.keys(value.sections).length > 0, {
|
||||
path: ["sections"],
|
||||
@@ -212,7 +213,7 @@ const auditCItem = z.number().int().min(0).max(4);
|
||||
export const auditCRequestSchema = z
|
||||
.strictObject({
|
||||
items: z.tuple([auditCItem, auditCItem, auditCItem]),
|
||||
takenAt: z.iso.datetime({ offset: true }).optional(),
|
||||
takenAt: isoDateTime().optional(),
|
||||
tz: z.string().min(1).max(64).optional(),
|
||||
locale: z.string().min(2).max(16).optional(),
|
||||
source: z.enum(["WEB", "IOS"]),
|
||||
@@ -343,7 +344,7 @@ export type LifeFactValue =
|
||||
export const removedFactSchema = z.object({
|
||||
id: z.string(),
|
||||
kind: z.enum(STORED_FACT_KINDS),
|
||||
removedAt: z.iso.datetime({ offset: true }),
|
||||
removedAt: isoDateTime(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -362,10 +363,10 @@ export const lifeFactRevisionSchema = z.object({
|
||||
unreadable: z
|
||||
.boolean()
|
||||
.describe("The sealed value could not be opened on this host."),
|
||||
validFrom: z.iso.datetime({ offset: true }),
|
||||
validFrom: isoDateTime(),
|
||||
validUntil: z.null(),
|
||||
provenance: z.enum(["USER_REPORTED", "USER_CORRECTION"]),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
createdAt: isoDateTime(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { isValidTimezone } from "@/lib/tz/format";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* The record's sex, as `User.gender` already stores it.
|
||||
@@ -84,6 +85,6 @@ export const updateManagedProfileSchema = z
|
||||
export const inviteManagedProfileGuardianSchema = z
|
||||
.object({
|
||||
identifier: z.string().trim().min(1).max(255),
|
||||
expiresAt: z.iso.datetime({ offset: true }).nullable().optional(),
|
||||
expiresAt: isoDateTime().nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -13,6 +13,7 @@
|
||||
* recomputes).
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* The auto-resolve target types. Kept as an explicit allow-list rather
|
||||
@@ -147,10 +148,7 @@ const cadenceRefinement = (
|
||||
* The first-due date: a calendar date (`YYYY-MM-DD`, read as that day in the
|
||||
* user's zone) or a date-time (the instant it names).
|
||||
*/
|
||||
const anchorDateField = z.union([
|
||||
z.iso.date(),
|
||||
z.iso.datetime({ offset: true }),
|
||||
]);
|
||||
const anchorDateField = z.union([z.iso.date(), isoDateTime()]);
|
||||
|
||||
export const createMeasurementReminderSchema = z
|
||||
.object({
|
||||
@@ -212,19 +210,19 @@ export const measurementReminderDto = z
|
||||
measurementType: measurementReminderTypeEnum.nullable(),
|
||||
intervalDays: z.number().int().nullable(),
|
||||
rrule: z.string().nullable(),
|
||||
anchorDate: z.iso.datetime({ offset: true }).nullable(),
|
||||
endsOn: z.iso.datetime({ offset: true }).nullable(),
|
||||
anchorDate: isoDateTime().nullable(),
|
||||
endsOn: isoDateTime().nullable(),
|
||||
origin: z.enum(["VORSORGE", "COACH"]),
|
||||
notifyHour: z.number().int(),
|
||||
location: z.string().nullable(),
|
||||
nextDueAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
lastSatisfiedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
snoozedUntil: z.iso.datetime({ offset: true }).nullable(),
|
||||
lastSkippedAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
nextDueAt: isoDateTime().nullable(),
|
||||
lastSatisfiedAt: isoDateTime().nullable(),
|
||||
snoozedUntil: isoDateTime().nullable(),
|
||||
lastSkippedAt: isoDateTime().nullable(),
|
||||
skipCount: z.number().int(),
|
||||
enabled: z.boolean(),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
updatedAt: z.iso.datetime({ offset: true }),
|
||||
createdAt: isoDateTime(),
|
||||
updatedAt: isoDateTime(),
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementReminderDTO",
|
||||
@@ -310,7 +308,7 @@ export const measurementReminderEventDto = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
kind: z.enum(["SATISFIED", "SKIPPED"]),
|
||||
occurredAt: z.iso.datetime({ offset: true }),
|
||||
occurredAt: isoDateTime(),
|
||||
onTime: z.boolean(),
|
||||
source: z.enum([
|
||||
"manual",
|
||||
@@ -321,7 +319,7 @@ export const measurementReminderEventDto = z
|
||||
"encounter",
|
||||
"skip",
|
||||
]),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
createdAt: isoDateTime(),
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementReminderEvent",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod/v4";
|
||||
import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source";
|
||||
import { validateEntryInstant } from "./entry-instant";
|
||||
import { isCalendarDateKey } from "@/lib/tz/date-only";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
export const measurementTypeEnum = z.enum([
|
||||
"WEIGHT",
|
||||
@@ -551,7 +552,7 @@ export const createMeasurementSchema = z
|
||||
// 1900. Closes the data-portability gap where a hand-rolled POST could
|
||||
// backdate or forward-date a reading arbitrarily.
|
||||
measuredAt: validateEntryInstant(
|
||||
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
isoDateTime().transform((s) => new Date(s)),
|
||||
),
|
||||
notes: z.string().max(MEASUREMENT_NOTES_MAX_LENGTH).optional(),
|
||||
// v1.10.0 QA — validate against the client-writable subset, not the full
|
||||
@@ -611,7 +612,7 @@ export const updateMeasurementSchema = z.object({
|
||||
// v1.17 W1b — same plausibility bound on the edit path; an edit cannot
|
||||
// forward-date a reading into the future nor before 1900.
|
||||
measuredAt: validateEntryInstant(
|
||||
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
isoDateTime().transform((s) => new Date(s)),
|
||||
).optional(),
|
||||
notes: z
|
||||
.string()
|
||||
@@ -645,12 +646,10 @@ export const listPersonalRecordsSchema = z.object({
|
||||
export const listMeasurementsSchema = z
|
||||
.object({
|
||||
type: measurementTypeEnum.optional(),
|
||||
from: z.iso
|
||||
.datetime({ offset: true })
|
||||
from: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
to: z.iso
|
||||
.datetime({ offset: true })
|
||||
to: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
// v1.4.28 FB-D2 — when the chart sends an explicit from/to window the
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod/v4";
|
||||
import { INJECTION_SITE_KEYS } from "@/lib/medications/injection-sites";
|
||||
import { validateEntryInstant } from "@/lib/validations/entry-instant";
|
||||
import { hhmmToMinutes } from "@/lib/medications/scheduling/hhmm";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* v1.8.5 — the eight injection-site enum values, mirrored from the
|
||||
@@ -244,7 +245,7 @@ export const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000;
|
||||
* 1900 floor. The 5-min clock-skew tolerance is the shared default.
|
||||
*/
|
||||
export const boundedTakenAtSchema = validateEntryInstant(
|
||||
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
isoDateTime().transform((s) => new Date(s)),
|
||||
{
|
||||
maxAgeMs: TAKEN_AT_MAX_AGE_MS,
|
||||
// Preserve the established wording so the iOS contract + tests stay stable.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { boundedTakenAtSchema, injectionSiteEnum } from "./base";
|
||||
import { assertStableIdempotencyKey } from "@/lib/validations/external-id";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
export const intakeSchema = z
|
||||
.object({
|
||||
@@ -11,8 +12,7 @@ export const intakeSchema = z
|
||||
.describe(
|
||||
"Server-narrowed from the URL path. The route layer overwrites whatever the body supplies before Zod parsing so a caller cannot log an intake against another medication.",
|
||||
),
|
||||
scheduledFor: z.iso
|
||||
.datetime({ offset: true })
|
||||
scheduledFor: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional()
|
||||
.describe(
|
||||
@@ -60,8 +60,7 @@ export const intakeSchema = z
|
||||
* server validates it against the band anchors); an arbitrary instant is
|
||||
* rejected with 422. Absent → default band attribution.
|
||||
*/
|
||||
forceSlotInstant: z.iso
|
||||
.datetime({ offset: true })
|
||||
forceSlotInstant: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional()
|
||||
.describe(
|
||||
@@ -149,8 +148,7 @@ export const updateIntakeEventSchema = z
|
||||
.object({
|
||||
takenAt: boundedTakenAtSchema.nullable().optional(),
|
||||
skipped: z.boolean().optional(),
|
||||
scheduledFor: z.iso
|
||||
.datetime({ offset: true })
|
||||
scheduledFor: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
/**
|
||||
@@ -165,8 +163,7 @@ export const updateIntakeEventSchema = z
|
||||
* resets to AUTO — the "Zuordnung lösen" path. A pin / unpin no longer
|
||||
* requires `takenAt` or `skipped` to change in the same request.
|
||||
*/
|
||||
forceSlotInstant: z.iso
|
||||
.datetime({ offset: true })
|
||||
forceSlotInstant: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional()
|
||||
@@ -242,15 +239,13 @@ export const intakeStatusUpdateSchema = z.object({
|
||||
.describe(
|
||||
"`snoozed` writes the deferral onto the MEDICATION row rather than the event, so it defers every pending dose of that medication and not just this one.",
|
||||
),
|
||||
takenAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
takenAt: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional()
|
||||
.describe(
|
||||
"When the dose was taken. Defaults to now. Ignored unless the status is `taken`.",
|
||||
),
|
||||
snoozedUntil: z.iso
|
||||
.datetime({ offset: true })
|
||||
snoozedUntil: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional()
|
||||
.describe(
|
||||
@@ -261,8 +256,7 @@ export const intakeStatusUpdateSchema = z.object({
|
||||
.describe(
|
||||
"Site recorded alongside a `taken` toggle, validated server-side against the medication's effective allowed set. Dropped for a non-injection medication, for one with site tracking off, and for a skip or snooze.",
|
||||
),
|
||||
forceSlotInstant: z.iso
|
||||
.datetime({ offset: true })
|
||||
forceSlotInstant: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.optional()
|
||||
.describe(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* v1.4.25 W19b — inventory (pen / vial) CRUD validators.
|
||||
@@ -47,13 +48,11 @@ export const createInventoryItemSchema = z
|
||||
.describe(
|
||||
"Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Defaults to OTHER. PEN and AMPOULE are the sealed reservoirs the first dose breaches, so they carry a 30-day post-opening window; every other kind expires only on its printed date.",
|
||||
),
|
||||
printedExpiry: z.iso
|
||||
.datetime({ offset: true })
|
||||
printedExpiry: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
purchasedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
purchasedAt: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
@@ -85,8 +84,7 @@ export const createInventoryItemSchema = z
|
||||
|
||||
export const updateInventoryItemSchema = z
|
||||
.object({
|
||||
markAsFirstUseAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
markAsFirstUseAt: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional()
|
||||
@@ -99,8 +97,7 @@ export const updateInventoryItemSchema = z
|
||||
.describe(
|
||||
"Terminal override: zero the remaining units and mark the container USED_UP (physically discarded).",
|
||||
),
|
||||
printedExpiry: z.iso
|
||||
.datetime({ offset: true })
|
||||
printedExpiry: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.nullable()
|
||||
.optional(),
|
||||
@@ -183,8 +180,7 @@ const MIN_EFFECTIVE_FROM = new Date("2020-01-01T00:00:00Z");
|
||||
const FIVE_YEARS_MS = 5 * 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const glp1DoseChangePostSchema = z.object({
|
||||
effectiveFrom: z.iso
|
||||
.datetime({ offset: true })
|
||||
effectiveFrom: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.refine((d) => d.getTime() >= MIN_EFFECTIVE_FROM.getTime(), {
|
||||
message: "effectiveFrom must be on or after 2020-01-01",
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@/lib/mental-health/instruments";
|
||||
import { assertStableExternalId } from "@/lib/validations/external-id";
|
||||
import { isValidTimezone } from "@/lib/tz/format";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]);
|
||||
|
||||
@@ -53,7 +54,7 @@ export const createAssessmentSchema = z
|
||||
/** Optional functional-impairment follow-up (not scored into the total). */
|
||||
functionalDifficulty: z.number().int().min(0).max(3).optional(),
|
||||
/** ISO instant the screener was taken; defaults to now server-side. */
|
||||
takenAt: z.string().datetime().optional(),
|
||||
takenAt: isoDateTime({ offset: false }).optional(),
|
||||
tz: z
|
||||
.string()
|
||||
.max(64)
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
LEISURE_CATEGORY_KEYS,
|
||||
WORK_STATUS_KEYS,
|
||||
} from "@/lib/mood/context-vocabulary";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
// --- CRUD schemas for mood entries ---
|
||||
|
||||
@@ -192,9 +193,7 @@ export const moodContextSchema = z.object({
|
||||
.optional(),
|
||||
// The same plausibility bound the entry's own instant carries: no future
|
||||
// instants beyond the clock-skew tolerance, nothing before 1900.
|
||||
eventAt: validateEntryInstant(
|
||||
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
)
|
||||
eventAt: validateEntryInstant(isoDateTime().transform((s) => new Date(s)))
|
||||
.nullable()
|
||||
.optional(),
|
||||
// Capped like the entry note beside it. One note for the whole context.
|
||||
@@ -242,7 +241,7 @@ export const createMoodEntrySchema = z
|
||||
// future instants beyond a 5-min clock-skew tolerance, no instant before
|
||||
// 1900. Mirrors the measurement + medication-intake bound.
|
||||
moodLoggedAt: validateEntryInstant(
|
||||
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
isoDateTime().transform((s) => new Date(s)),
|
||||
),
|
||||
source: moodSourceEnum.optional().default("MANUAL"),
|
||||
// v1.12.1 — optional source-stable id (e.g. an iOS SwiftData row UUID).
|
||||
@@ -285,7 +284,7 @@ export const updateMoodEntrySchema = z.object({
|
||||
context: moodContextSchema.nullable().optional(),
|
||||
// v1.17 W1b — same plausibility bound on the edit path.
|
||||
moodLoggedAt: validateEntryInstant(
|
||||
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
isoDateTime().transform((s) => new Date(s)),
|
||||
).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
NUTRIENT_CODES,
|
||||
SYNCABLE_NUTRIENT_CODES,
|
||||
} from "@/lib/nutrients/catalog";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
export const MAX_NUTRIENT_ENTRIES_PER_BATCH = 500;
|
||||
|
||||
@@ -170,7 +171,7 @@ export const nutrientOverviewSchema = z
|
||||
),
|
||||
lastAttempt: z
|
||||
.object({
|
||||
at: z.iso.datetime({ offset: true }),
|
||||
at: isoDateTime(),
|
||||
topReason: z.string(),
|
||||
})
|
||||
.nullable()
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* minter reads it exactly like a write-path archive.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/** HH:mm wall-clock literal — mirrors `validations/medication.ts`. */
|
||||
const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
@@ -21,20 +22,18 @@ const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
export const scheduleRevisionCreateSchema = z
|
||||
.object({
|
||||
/** Inclusive start instant of the manual era. */
|
||||
validFrom: z.iso
|
||||
.datetime({ offset: true })
|
||||
.describe("Inclusive start instant of the manual era (ISO 8601)."),
|
||||
validFrom: isoDateTime().describe(
|
||||
"Inclusive start instant of the manual era (ISO 8601).",
|
||||
),
|
||||
/**
|
||||
* Exclusive end instant — the moment the next plan took over. Must
|
||||
* lie after `validFrom` and at or before the start of the live
|
||||
* schedule era (the newest archived revision's `validUntil`, or
|
||||
* "now" when no revision exists yet).
|
||||
*/
|
||||
validUntil: z.iso
|
||||
.datetime({ offset: true })
|
||||
.describe(
|
||||
"Exclusive end instant of the manual era (ISO 8601). Must precede the current plan.",
|
||||
),
|
||||
validUntil: isoDateTime().describe(
|
||||
"Exclusive end instant of the manual era (ISO 8601). Must precede the current plan.",
|
||||
),
|
||||
/**
|
||||
* Daily dose times the era ran at. Same cap as live schedules.
|
||||
* Duplicates collapse and the result sorts ascending — "07:00,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/** Upper bound on the types per batch — the dashboard never asks for more. */
|
||||
export const SERIES_BATCH_MAX_TYPES = 16;
|
||||
@@ -28,12 +29,10 @@ export const seriesBatchQuerySchema = z
|
||||
`${SERIES_BATCH_MAX_TYPES}). Each is read as a daily series through ` +
|
||||
"the rollup tier.",
|
||||
),
|
||||
from: z.iso
|
||||
.datetime({ offset: true })
|
||||
from: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.describe("Window start (ISO-8601 instant)."),
|
||||
to: z.iso
|
||||
.datetime({ offset: true })
|
||||
to: isoDateTime()
|
||||
.transform((s) => new Date(s))
|
||||
.describe("Window end (ISO-8601 instant)."),
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
validateMeasurementRange,
|
||||
writableMeasurementSourceEnum,
|
||||
} from "./measurement";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/** The stage names the `SleepStage` column stores, as a caller writes them. */
|
||||
export const SLEEP_STAGE_NAMES = [
|
||||
@@ -40,9 +41,7 @@ export type SleepStageName = (typeof SLEEP_STAGE_NAMES)[number];
|
||||
const VALUE_TOLERANCE_MINUTES = 1;
|
||||
|
||||
const instant = () =>
|
||||
validateEntryInstant(
|
||||
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
);
|
||||
validateEntryInstant(isoDateTime().transform((s) => new Date(s)));
|
||||
|
||||
export const sleepStageEntrySchema = z
|
||||
.object({
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
ANTIGEN_SLUGS,
|
||||
VACCINE_CATALOG_SLUGS,
|
||||
} from "@/lib/vaccinations/vaccine-catalog";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
const KNOWN_SLUGS: ReadonlySet<string> = new Set(VACCINE_CATALOG_SLUGS);
|
||||
|
||||
@@ -43,7 +44,7 @@ const KNOWN_SLUGS: ReadonlySet<string> = new Set(VACCINE_CATALOG_SLUGS);
|
||||
*/
|
||||
const DAY_SKEW_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const doseInstant = z.iso.datetime({ offset: true }).refine(
|
||||
const doseInstant = isoDateTime().refine(
|
||||
(value) => {
|
||||
const at = new Date(value).getTime();
|
||||
if (Number.isNaN(at)) return false;
|
||||
@@ -191,8 +192,8 @@ export type VaccinationUpdate = z.infer<typeof vaccinationUpdateSchema>;
|
||||
export const vaccinationListQuerySchema = z
|
||||
.object({
|
||||
antigenSlug: z.string().min(1).max(64).optional(),
|
||||
from: z.iso.datetime({ offset: true }).optional(),
|
||||
to: z.iso.datetime({ offset: true }).optional(),
|
||||
from: isoDateTime().optional(),
|
||||
to: isoDateTime().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).optional(),
|
||||
})
|
||||
.strict()
|
||||
@@ -299,7 +300,7 @@ export type VaccinationBoosterInput = z.infer<typeof vaccinationBoosterSchema>;
|
||||
*/
|
||||
export const vaccinationSuggestQuerySchema = z
|
||||
.object({
|
||||
anchor: z.iso.datetime({ offset: true }),
|
||||
anchor: isoDateTime(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod/v4";
|
||||
import { writableMeasurementSourceEnum } from "./measurement";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* Workout sport-type union.
|
||||
@@ -84,7 +85,7 @@ export type GeoJsonLineString = z.infer<typeof geoJsonLineStringSchema>;
|
||||
*/
|
||||
export const workoutRouteSamplesSchema = z.array(
|
||||
z.object({
|
||||
t: z.iso.datetime({ offset: true }),
|
||||
t: isoDateTime(),
|
||||
speedMs: z.number().min(0).max(50).optional(),
|
||||
hr: z.number().int().min(20).max(300).optional(),
|
||||
}),
|
||||
@@ -122,7 +123,7 @@ export const MAX_WORKOUT_HR_SAMPLES = 30_000;
|
||||
export const workoutHrSamplesSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
t: z.iso.datetime({ offset: true }),
|
||||
t: isoDateTime(),
|
||||
hr: z.number().int().min(20).max(300).optional(),
|
||||
speedMs: z.number().min(0).max(50).optional(),
|
||||
power: z.number().min(0).max(3000).optional(),
|
||||
@@ -157,8 +158,8 @@ export type WorkoutHrSamples = z.infer<typeof workoutHrSamplesSchema>;
|
||||
export const createWorkoutSchema = z
|
||||
.object({
|
||||
sportType: workoutSportTypeEnum,
|
||||
startedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
endedAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
startedAt: isoDateTime().transform((s) => new Date(s)),
|
||||
endedAt: isoDateTime().transform((s) => new Date(s)),
|
||||
totalEnergyKcal: z.number().min(0).max(50000).optional(),
|
||||
totalDistanceM: z.number().min(0).max(1_000_000).optional(),
|
||||
avgHeartRate: z.number().int().min(20).max(300).optional(),
|
||||
|
||||
@@ -21,6 +21,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
|
||||
import { cached, caches } from "@/lib/cache/server-cache";
|
||||
import { workoutSportTypeEnum } from "@/lib/validations/workout";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
function emptyQueryValueToUndefined(value: unknown): unknown {
|
||||
if (value == null) return undefined;
|
||||
@@ -32,8 +33,7 @@ function emptyQueryValueToUndefined(value: unknown): unknown {
|
||||
const normalizedTimestampFilter = z
|
||||
.preprocess(
|
||||
emptyQueryValueToUndefined,
|
||||
z.iso
|
||||
.datetime({ offset: true })
|
||||
isoDateTime()
|
||||
.transform((value) => new Date(value).toISOString())
|
||||
.optional(),
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* - Without readable values it drops the nine columns and the three
|
||||
* indexes, and the sealed copies are untouched.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
@@ -177,6 +177,19 @@ describe("migration 0409 (drop the readable location columns)", () => {
|
||||
expect(error?.message).toMatch(
|
||||
/1 home location\(s\), 0 location period\(s\) and 0 daily location\(s\)/,
|
||||
);
|
||||
// The operator reads this line in the container log; it points at the
|
||||
// recovery steps, which exist.
|
||||
expect(error?.message).toMatch(
|
||||
/docs\/ops\/migration-0409-plaintext-location\.md/,
|
||||
);
|
||||
expect(
|
||||
existsSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
"docs/ops/migration-0409-plaintext-location.md",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// Nothing was dropped and the value is where it was.
|
||||
expect(await columnCount(tx)).toBe(9);
|
||||
|
||||
@@ -416,7 +416,15 @@ describe("MCP tool outputs against their advertised schemas", () => {
|
||||
.filter((name) => !(name in CASES));
|
||||
expect(uncovered, "add every new tool to CASES").toEqual([]);
|
||||
|
||||
const ajv = new Ajv2020({ strict: true, allErrors: true });
|
||||
// `allowUnionTypes`: zod 4.6 writes a nullable or primitive union as a
|
||||
// `type` array (`["string", "null"]`), which is plain 2020-12. Strict
|
||||
// mode otherwise refuses that spelling as an authoring style, not as an
|
||||
// invalid schema; every other strict rule stays on.
|
||||
const ajv = new Ajv2020({
|
||||
strict: true,
|
||||
allowUnionTypes: true,
|
||||
allErrors: true,
|
||||
});
|
||||
const failures: string[] = [];
|
||||
let validated = 0;
|
||||
// Tools that answered with data, not only a miss: a run where every
|
||||
|
||||
@@ -418,8 +418,9 @@ describe("GET /api/mood/prognosis", () => {
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
// Asserted on the bytes, not on a parsed field: the point is that not one
|
||||
// figure of the owner's reached the response.
|
||||
const raw = await res.text();
|
||||
// figure of the owner's reached the response. The error's random request
|
||||
// id is taken out first, since its hex digits can spell any small count.
|
||||
const raw = (await res.text()).replace(/"requestId":"[^"]*"/, "");
|
||||
expect(raw).not.toContain("predicted");
|
||||
expect(raw).not.toContain("ciLow");
|
||||
expect(raw).not.toContain("dimension:a2");
|
||||
|
||||
Reference in new issue
Block a user