Files
HealthLog/src/lib/validations/cycle.ts
T
Marc-André Bombeck 2ab66ab2e3 fix(ingest): refuse an external id that cannot be a stable identity
An external id is half of every ingest dedup key — (userId, source,
externalId), (userId, externalSource, externalId), (userId, type,
source, externalId). Re-posting the same triple is idempotent and
returns the existing row, which holds only while the id is stable
across client launches. A client that serialised an opaque platform
object with its default object description sent a per-allocation
memory address instead, so no sweep ever matched its own earlier row
and each one minted a fresh record: a live instance grew 23 phantom
medications in a day, none of which could collect a dose, because the
dose side resolved through the same rotating string.

Add one shared classifier for identifiers that provably cannot be an
identity — an angle-bracketed object description carrying a hex
pointer, a bare 0x address, and a blank value — and wire it at every
surface that accepts a client-supplied externalId.

Single-entry creates refuse through the schema, so the client gets the
standard 422 multi-issue envelope with a message naming the shape it
sent and what to send instead. Batch and bulk surfaces classify per
entry and report the offending row as skipped with reason
unstable_external_id, leaving the rest of the payload to land: one bad
row must not stop a sync. Every refusal carries pinned wide-event meta
(count, shape, surface) and never the id itself.

A structural guard freezes both routes into the floor: a request-side
externalId declaration either applies the field check or carries a
marker naming the file that checks it per entry, and the guard verifies
that file really does. The backup-restore payload is the one exemption,
with its reason recorded — it replays ids the server itself wrote.
2026-07-25 17:21:00 +02:00

249 lines
9.0 KiB
TypeScript

/**
* Cycle-tracking request/response validation (v1.15.0).
*
* Source of truth for the `/api/cycle/*` wire contract (ios-contract §2).
* The Zod schemas here are reused by the OpenAPI registry so the spec
* stays single-source. `userId` is NEVER a body field — it is narrowed
* from the session/Bearer in every route.
*
* Dates are `YYYY-MM-DD` strings (the MoodEntry tz-anchored convention);
* instants are ISO-8601 with offset.
*/
import { z } from "zod/v4";
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
import { assertStableExternalId } from "@/lib/validations/external-id";
/**
* v1.17 W1b — `loggedAt` instant on the cycle wire stays a string (the iOS
* contract reads it back verbatim), so the plausibility bound is applied as
* a `.refine` over the parsed instant rather than the date-typed
* `validateEntryInstant` wrapper. Same `isPlausibleEntryInstant` predicate
* 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)), {
message: "loggedAt must be a plausible instant (not future, not pre-1900)",
});
/* ── enums (mirror the Prisma enums) ─────────────────────────────── */
export const flowLevelEnum = z.enum([
"NONE",
"SPOTTING",
"LIGHT",
"MEDIUM",
"HEAVY",
]);
export const ovulationTestEnum = z.enum([
"NEGATIVE",
"POSITIVE_LH_SURGE",
"ESTROGEN_SURGE",
"INDETERMINATE",
]);
export const cervicalMucusEnum = z.enum([
"DRY",
"STICKY",
"CREAMY",
"WATERY",
"EGG_WHITE",
]);
/** Symptothermal secondary-symptom choice (mirrors the Prisma enum). */
export const secondarySymptomEnum = z.enum(["MUCUS", "CERVIX"]);
/** The three Sensiplan cervix signs (mirror the Prisma enums). */
export const cervixPositionEnum = z.enum(["LOW", "HIGH"]);
export const cervixFirmnessEnum = z.enum(["FIRM", "SOFT"]);
export const cervixOpeningEnum = z.enum(["CLOSED", "OPEN"]);
export const homeTestResultEnum = z.enum([
"NEGATIVE",
"POSITIVE",
"INDETERMINATE",
]);
export const contraceptiveKindEnum = z.enum([
"NONE",
"UNSPECIFIED",
"IMPLANT",
"INJECTION",
"IUD",
"INTRAVAGINAL_RING",
"ORAL",
"PATCH",
"EMERGENCY",
]);
export const cycleTrackingGoalEnum = z.enum([
"GENERAL_HEALTH",
"AVOID_PREGNANCY",
"TRYING_TO_CONCEIVE",
"PERIMENOPAUSE",
"OFF",
]);
/**
* Day-log source. MANUAL (web / single POST) or APPLE_HEALTH (the
* HealthKit batch ingest). Mirrors `MeasurementSource`; the wire spelling
* is APPLE_HEALTH, not "HEALTHKIT".
*/
export const cycleSourceEnum = z.enum(["MANUAL", "APPLE_HEALTH"]);
const dateString = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Expected a YYYY-MM-DD date");
/** The single-day read query: `GET /api/cycle/day-logs?date=YYYY-MM-DD`. */
export const cycleDayLogQuerySchema = z.object({ date: dateString });
/* ── day-log capture (single + bulk shared input) ────────────────── */
/**
* The canonical day-log input. Shared by the single POST, the PATCH, and
* the bulk drain. `note` is encrypted at rest (`notesEncrypted`); every
* other field is queryable plaintext (feeds rollups / correlation).
*/
export const cycleDayLogInputSchema = z.object({
date: dateString,
flow: flowLevelEnum.optional(),
intermenstrualBleeding: z.boolean().optional(),
basalBodyTempC: z.number().min(30).max(45).optional(),
// Marks the day's BBT as disturbed (fever / illness / late reading): the
// symptothermal engine drops it from the cover line + shift evaluation.
temperatureExcluded: z.boolean().optional(),
ovulationTest: ovulationTestEnum.optional(),
cervicalMucus: cervicalMucusEnum.optional(),
// Cervix observation — the symptothermal secondary indicator when the
// profile's secondarySymptom is CERVIX.
cervixPosition: cervixPositionEnum.optional(),
cervixFirmness: cervixFirmnessEnum.optional(),
cervixOpening: cervixOpeningEnum.optional(),
sexualActivity: z.boolean().optional(),
protectedSex: z.boolean().nullable().optional(),
pregnancyTest: homeTestResultEnum.optional(),
progesteroneTest: homeTestResultEnum.optional(),
contraceptive: contraceptiveKindEnum.optional(),
symptoms: z
.array(
z.object({
key: z.string().min(1).max(80),
severity: z.number().int().min(1).max(4).optional(),
}),
)
.max(40)
.optional(),
note: z.string().max(500).optional(),
loggedAt: boundedLoggedAt,
source: cycleSourceEnum.optional().default("MANUAL"),
// Shared with the bulk ingest, which enforces external-id stability per
// entry rather than at the field (a schema-level refusal would fail the
// whole batch on one bad row). The single-entry POST parses through
// `cycleDayLogCreateSchema` below, which adds the field check.
// @external-id-checked-per-entry: src/app/api/cycle/day-logs/bulk/route.ts
externalId: z.string().min(1).max(120).optional(),
});
export type CycleDayLogInput = z.infer<typeof cycleDayLogInputSchema>;
/**
* Single-entry POST body. Same shape as the bulk entry plus the
* external-id stability floor: a rotating id defeats the
* `(userId, source, externalId)` upsert and mints a fresh day log on every
* re-post. The bulk path deliberately keeps the permissive entry schema —
* it reports the offending entry as `skipped` rather than failing the
* whole batch — and applies the same rule per entry in the route.
*/
export const cycleDayLogCreateSchema = cycleDayLogInputSchema.superRefine(
assertStableExternalId,
);
/** PATCH body — every field optional; `date`/`source`/`externalId` are immutable on update. */
export const cycleDayLogPatchSchema = z.object({
flow: flowLevelEnum.nullable().optional(),
intermenstrualBleeding: z.boolean().optional(),
basalBodyTempC: z.number().min(30).max(45).nullable().optional(),
temperatureExcluded: z.boolean().optional(),
ovulationTest: ovulationTestEnum.nullable().optional(),
cervicalMucus: cervicalMucusEnum.nullable().optional(),
cervixPosition: cervixPositionEnum.nullable().optional(),
cervixFirmness: cervixFirmnessEnum.nullable().optional(),
cervixOpening: cervixOpeningEnum.nullable().optional(),
sexualActivity: z.boolean().optional(),
protectedSex: z.boolean().nullable().optional(),
pregnancyTest: homeTestResultEnum.nullable().optional(),
progesteroneTest: homeTestResultEnum.nullable().optional(),
contraceptive: contraceptiveKindEnum.nullable().optional(),
symptoms: z
.array(
z.object({
key: z.string().min(1).max(80),
severity: z.number().int().min(1).max(4).optional(),
}),
)
.max(40)
.optional(),
note: z.string().max(500).nullable().optional(),
loggedAt: boundedLoggedAt.optional(),
});
export const MAX_CYCLE_BULK_ENTRIES = 500;
export const cycleBulkSchema = z.object({
entries: z.array(cycleDayLogInputSchema).min(1).max(MAX_CYCLE_BULK_ENTRIES),
});
/* ── period-boundary shortcut ────────────────────────────────────── */
export const cyclePeriodSchema = z
.object({
action: z.enum(["start", "end"]),
date: dateString,
externalId: z.string().min(1).max(120).optional(),
loggedAt: boundedLoggedAt,
})
.superRefine(assertStableExternalId);
/* ── calendar + history queries ──────────────────────────────────── */
export const cycleCalendarQuerySchema = z.object({
from: dateString.optional(),
to: dateString.optional(),
});
export const cycleHistoryQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(60).optional(),
});
/* ── cycle preferences (PATCH /api/auth/me/cycle-prefs) ──────────── */
/**
* Partial deep-merge body for the cycle-prefs PATCH. Mirrors the
* notification-prefs precedent: an omitted field is left untouched.
*/
export const cyclePrefsSchema = z
.object({
enabled: z.boolean().optional(),
goal: cycleTrackingGoalEnum.optional(),
// Symptothermal secondary symptom — advanced cycle setting (MUCUS default
// | CERVIX). Drives which second sign the double-check uses.
secondarySymptom: secondarySymptomEnum.optional(),
rawChartMode: z.boolean().optional(),
predictionEnabled: z.boolean().optional(),
discreetNotifications: z.boolean().optional(),
sensitiveCategoryEncryption: z.boolean().optional(),
typicalCycleLength: z.number().int().min(15).max(60).nullable().optional(),
typicalPeriodLength: z.number().int().min(1).max(15).nullable().optional(),
// Bound matches the engine clamp [LUTEAL_MIN, LUTEAL_MAX] (§4) so the
// stored value never silently snaps in the predictor (QA HIGH).
lutealPhaseLength: z.number().int().min(10).max(16).nullable().optional(),
})
.refine((v) => Object.keys(v).length > 0, {
message: "At least one preference field is required",
});
export type CyclePrefsInput = z.infer<typeof cyclePrefsSchema>;