Files
HealthLog/src/lib/validations/nutrients.ts
T

275 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Zod schemas — nutrient intake sync (v1.28).
*
* Shared between the runtime request parsing
* (`src/app/api/nutrients/**`) and the OpenAPI route table
* (`src/lib/openapi/routes/nutrients.ts`) so the wire contract stays
* single-source. The closed `nutrient` code set comes from the
* code-side catalog (`src/lib/nutrients/catalog.ts`).
*/
import { z } from "zod/v4";
import {
NUTRIENT_CATALOG,
NUTRIENT_CODES,
SYNCABLE_NUTRIENT_CODES,
} from "@/lib/nutrients/catalog";
export const MAX_NUTRIENT_ENTRIES_PER_BATCH = 500;
export const nutrientCodeEnum = z.enum(NUTRIENT_CODES).meta({
id: "NutrientCode",
description:
"Closed nutrient catalog code — 24 HealthKit Dietary vitamin/mineral types plus water and caffeine, and (v1.43, responses only) `dha` and `epa`, which only a supplement's composition produces. Energy, macros and sodium/potassium are out of scope. Decode unknown values leniently.",
});
/**
* v1.43 — the codes the batch accepts: the catalogue without the
* supplement-only codes, so the HealthKit sync contract stays unchanged.
*/
export const syncableNutrientCodeEnum = z.enum(SYNCABLE_NUTRIENT_CODES).meta({
id: "SyncableNutrientCode",
description:
"A nutrient code HealthKit may send: the 24 Dietary vitamin/mineral types plus water and caffeine. Never `dha` or `epa`.",
});
/**
* One day-total entry. `day` is YYYY-MM-DD in the user's IANA timezone,
* computed client-side (the `stats:` day-key contract); the server
* trusts the string after regex + calendar sanity, no re-derivation.
* `unit` deliberately rides the wire even though the catalog pins it:
* a µg/mg confusion is a silent 1000× corruption and the one-string
* echo is the cheapest guard — a mismatch skips the entry.
*/
export const nutrientEntrySchema = z
.object({
day: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
nutrient: syncableNutrientCodeEnum,
unit: z.string().min(1).max(10),
amount: z.number().finite().min(0),
externalSourceVersion: z.string().min(1).max(120).optional(),
})
.meta({
id: "NutrientIntakeEntry",
description:
"One nutrient day total. day = YYYY-MM-DD in the user's IANA timezone; unit must equal the catalog's canonical unit for the code (mg | ug | ml) or the entry is skipped unit_mismatch.",
});
export const nutrientBatchSchema = z
.object({
entries: z
.array(nutrientEntrySchema)
.min(1)
.max(MAX_NUTRIENT_ENTRIES_PER_BATCH),
})
.meta({
id: "NutrientBatchRequest",
description:
"Bulk day-total upsert, max 500 entries. Upsert key is the composite primary key (userId, day, nutrient, source); this route always writes the APPLE_HEALTH source row, so a manual water entry is never clobbered. A re-post replaces the stored total (last-writer-wins day-total contract).",
});
/** Per-entry outcome — a re-post is by definition an update, never a duplicate. */
export const nutrientEntryResultSchema = z
.object({
index: z.number().int().min(0),
status: z.enum(["inserted", "updated", "skipped"]),
reason: z.string().optional(),
})
.meta({
id: "NutrientEntryResult",
description:
"Per-entry ingest outcome. skipped reasons: unit_mismatch | value_out_of_range | day_invalid | upsert_failed. The first three are verdicts on the entry itself and terminal: resending it unchanged is skipped again, so log and drop it. `upsert_failed` is a write failure on the server's side, not a verdict on the entry: nothing was stored for it, and it should be sent again on a later sync. A write failure takes out the whole group it was written in (every new day total, or every existing one), so several entries can carry it at once. A response carrying it is marked `Cache-Control: no-store` and is not kept for `Idempotency-Key` replay, so the retry writes again rather than replaying the failure.",
});
export const nutrientBatchResponseSchema = z
.object({
processed: z.number().int().min(0),
inserted: z.number().int().min(0),
updated: z.number().int().min(0),
skipped: z.array(
z.object({ index: z.number().int().min(0), reason: z.string() }),
),
entries: z.array(nutrientEntryResultSchema),
})
.meta({
id: "NutrientBatchResponse",
description:
"Always 200 when the envelope parses; per-entry failures surface as skipped entries, never a batch failure.",
});
/** `GET /api/nutrients` query — window size in days, default 14. */
export const nutrientOverviewQuerySchema = z.object({
days: z.coerce.number().int().min(1).max(365).default(14),
});
/**
* v1.43 — the tolerable upper intake level of a nutrient (EFSA), applied by
* the server to the day's total or to the supplemental share as `scope` says.
*/
export const nutrientUpperLimitSchema = z
.object({
value: z.number(),
unit: z.string(),
scope: z.enum(["total", "supplemental"]),
form: z
.enum(["retinol", "folic_acid", "magnesium_salts", "alpha_tocopherol"])
.nullable()
.describe(
"v1.43 — the chemical form the level is set for, where narrower than the nutrient: `retinol` (vitamin A: preformed retinol only, beta-carotene does not count), `folic_acid`, `magnesium_salts` (readily dissociable salts in supplements), `alpha_tocopherol`. `null` where the level covers the nutrient as such. Phrase it in the reader's language; skip an unknown value.",
),
source: z
.string()
.describe(
"The bare citation (EFSA overview and the opinion it rests on), not prose. Every level here is the adult one.",
),
})
.meta({
id: "NutrientUpperLimit",
description:
"The tolerable upper intake level, with the unit, whether it applies to the total intake or to the supplemental share only, and the cited source. `null` where no level is established.",
});
/** v1.43 — a day total split by where it came from. */
export const nutrientBySourceSchema = z
.object({
APPLE_HEALTH: z.number().optional(),
MANUAL: z.number().optional(),
SUPPLEMENT: z.number().optional(),
})
.meta({
id: "NutrientAmountBySource",
description:
"The amount split by source: synced from Apple Health, entered by hand, and counted from taken supplement doses. A source with nothing is absent.",
});
export const nutrientOverviewSchema = z
.object({
windowDays: z.number().int().min(1),
nutrients: z.array(
z.object({
nutrient: nutrientCodeEnum,
unit: z.string(),
latestDay: z.string(),
latestAmount: z.number(),
daysWithData: z.number().int().min(1),
latestBySource: nutrientBySourceSchema
.optional()
.describe("v1.43 — `latestAmount` split by source."),
upperLimit: nutrientUpperLimitSchema
.nullable()
.optional()
.describe("v1.43 — the nutrient's upper intake level, or null."),
latestAboveUpperLimit: z
.boolean()
.nullable()
.optional()
.describe(
"v1.43 — whether the latest day is above the upper limit; null without one.",
),
}),
),
lastAttempt: z
.object({
at: z.iso.datetime({ offset: true }),
topReason: z.string(),
})
.nullable()
.meta({
description:
"Non-null only when `nutrients` is empty AND the most recent nutrient-batch call landed nothing — the most common skip reason from that call, so the client can say a sync arrived and why instead of a bare empty state.",
}),
})
.meta({
id: "NutrientIntakeOverview",
description:
"Per-nutrient window summary in catalog order: latest synced day + total, and the count of days carrying data inside the window. Nutrients without data in the window are omitted.",
});
/**
* `POST /api/nutrients/water` body (v1.29) — manual water quick-add.
*
* Water only in slice 1 (manual vitamin entry is out of scope — see
* the design memo). `amountMl` is bounded to the catalog's own
* per-day plausibility cap for water; `mode: "add"` increments the
* MANUAL row for `day`, `mode: "set"` overwrites it (the "edit
* today's total" undo path). `day` defaults server-side to the
* caller's current local day when omitted.
*/
export const nutrientWaterWriteSchema = z
.object({
amountMl: z
.number()
.finite()
.min(1)
.max(NUTRIENT_CATALOG.water.plausibleDailyMax),
mode: z.enum(["add", "set"]),
day: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
})
.meta({
id: "NutrientWaterWriteRequest",
description:
"Manual water quick-add against the MANUAL source row. add increments today's manual total; set overwrites it. Never touches the APPLE_HEALTH row.",
});
export const nutrientWaterWriteResponseSchema = z
.object({
day: z.string(),
nutrient: z.literal("water"),
source: z.literal("MANUAL"),
amount: z.number(),
unit: z.string(),
})
.meta({
id: "NutrientWaterWriteResponse",
description: "The MANUAL water row after the add/set write.",
});
/** `GET /api/nutrients/daily` query — one nutrient's day-bucketed series. */
export const nutrientDailyQuerySchema = z.object({
nutrient: nutrientCodeEnum,
days: z.coerce.number().int().min(1).max(90).default(30),
});
const resolvedNutrientReferenceSchema = z.object({
kind: z.enum(["PRI", "AI", "safeLevel"]),
direction: z.enum(["target", "upperGuidance"]),
value: z.number(),
source: z.string(),
});
export const nutrientDailySeriesSchema = z
.object({
nutrient: nutrientCodeEnum,
unit: z.string(),
windowDays: z.number().int().min(1),
days: z.array(
z.object({
day: z.string(),
amount: z.number(),
bySource: nutrientBySourceSchema
.optional()
.describe("v1.43 — the day's amount split by source."),
aboveUpperLimit: z
.boolean()
.nullable()
.optional()
.describe(
"v1.43 — whether the day is above the upper limit; null without one.",
),
}),
),
reference: resolvedNutrientReferenceSchema.nullable(),
upperLimit: nutrientUpperLimitSchema
.nullable()
.optional()
.describe("v1.43 — the nutrient's upper intake level, or null."),
})
.meta({
id: "NutrientDailySeries",
description:
"Dense day-bucketed series (one entry per day in the window, 0 for a day with no data) summed across sources, plus the EFSA reference resolved against the caller's profile sex (null when sex is unknown on the profile).",
});