mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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.
249 lines
9.0 KiB
TypeScript
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>;
|