mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Let a user pick the Sensiplan double-check's second indicator — cervical mucus (default) or cervix observation — mirroring the published NFP standard where the temperature primary sign pairs with one chosen secondary sign. - `CycleProfile.secondarySymptom` (MUCUS default | CERVIX) drives which second sign the engine confirms against. Casual users keep temperature + mucus untouched; the choice lives behind the advanced cycle settings. - `CycleDayLog` gains the three standard cervix signs — position (LOW/HIGH), firmness (FIRM/SOFT), opening/os (CLOSED/OPEN) — as nullable columns (migration 0157), wired through Zod, the day-log upsert, the DTO, the sync feed, backup/restore and OpenAPI. Additive and back-compatible: existing profiles backfill MUCUS, existing day-logs keep NULL cervix signs, iOS inherits the fields. - The engine's `confirmSymptothermal` takes the secondary-symptom choice; on CERVIX it confirms against `detectCervixPeak` (last fertile-cervix day followed by three closed days — the cervix-closure rule) instead of the mucus peak. The double-check still requires BOTH the temperature shift AND the secondary peak: a single cervix sign never confirms, and the still-learning gate and conservative posture are unchanged. Engine derived clean-room from the Arbeitsgruppe-NFP / Raith-Paula & Frank-Herrmann standard.
229 lines
8.0 KiB
TypeScript
229 lines
8.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";
|
|
|
|
/**
|
|
* 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"),
|
|
externalId: z.string().min(1).max(120).optional(),
|
|
});
|
|
|
|
export type CycleDayLogInput = z.infer<typeof cycleDayLogInputSchema>;
|
|
|
|
/** 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,
|
|
});
|
|
|
|
/* ── 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>;
|