mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +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.
711 lines
23 KiB
TypeScript
711 lines
23 KiB
TypeScript
/**
|
||
* OpenAPI route table — cycle tracking (day logs, periods, calendar, insights, prefs).
|
||
*
|
||
* Part of the OpenAPI route table; aggregated in `./index.ts`.
|
||
* Schemas come from `src/lib/validations/*` where shared with the
|
||
* runtime request parsing, so the wire contract stays single-source.
|
||
*/
|
||
import { z } from "zod/v4";
|
||
import type { ZodOpenApiObject } from "zod-openapi";
|
||
import {
|
||
flowLevelEnum,
|
||
ovulationTestEnum,
|
||
cervicalMucusEnum,
|
||
secondarySymptomEnum,
|
||
cervixPositionEnum,
|
||
cervixFirmnessEnum,
|
||
cervixOpeningEnum,
|
||
homeTestResultEnum,
|
||
cycleTrackingGoalEnum,
|
||
cycleDayLogInputSchema,
|
||
cycleDayLogPatchSchema,
|
||
cycleDayLogQuerySchema,
|
||
cycleBulkSchema,
|
||
cyclePeriodSchema,
|
||
cyclePrefsSchema,
|
||
} from "@/lib/validations/cycle";
|
||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||
|
||
// ── Cycle tracking (v1.15.0) ─────────────────────────────────────────
|
||
// The `/api/cycle/*` capture / calendar / history / settings surface +
|
||
// the cycle-prefs PATCH. Request bodies come from the Zod validation
|
||
// module so the spec stays single-source; response DTOs are declared
|
||
// here mirroring `src/lib/cycle/dto.ts`. Every `/api/cycle/*` route also
|
||
// 403s `{ errorCode:"cycle.disabled" }` when the feature gate is off.
|
||
|
||
const predictionMethodEnumOpenapi = z
|
||
.enum(["CALENDAR", "SYMPTOTHERMAL", "TEMPERATURE_TREND", "BLENDED"])
|
||
.meta({ id: "CyclePredictionMethod" });
|
||
|
||
const cyclePhaseEnumOpenapi = z
|
||
.enum(["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"])
|
||
.meta({ id: "CyclePhase" });
|
||
|
||
flowLevelEnum.meta({
|
||
id: "FlowLevel",
|
||
description: "Menstrual-flow intensity.",
|
||
});
|
||
ovulationTestEnum.meta({
|
||
id: "OvulationTest",
|
||
description: "Ovulation predictor-kit (OPK) reading.",
|
||
});
|
||
cervicalMucusEnum.meta({
|
||
id: "CervicalMucus",
|
||
description: "Cervical-mucus quality.",
|
||
});
|
||
homeTestResultEnum.meta({
|
||
id: "HomeTestResult",
|
||
description: "At-home test result (pregnancy / progesterone).",
|
||
});
|
||
cycleTrackingGoalEnum.meta({
|
||
id: "CycleTrackingGoal",
|
||
description: "Drives cycle copy + fertile-window gating.",
|
||
});
|
||
|
||
cycleDayLogInputSchema.meta({
|
||
id: "CycleDayLogInput",
|
||
description:
|
||
"One day's cycle capture. `note` is encrypted at rest; every other field is queryable plaintext. UPSERT key: `(userId, source, externalId)` when externalId present, else `(userId, date)`. Shared by the single POST, the bulk drain, and the period shortcut.",
|
||
});
|
||
|
||
cycleBulkSchema.meta({
|
||
id: "CycleDayLogBulkRequest",
|
||
description:
|
||
"Outbox / HealthKit drain. Up to 500 entries per call; wrapped in `withIdempotency`; rate-limited 60/min. Each entry upserts per the day-log key.",
|
||
});
|
||
|
||
cyclePeriodSchema.meta({
|
||
id: "CyclePeriodRequest",
|
||
description:
|
||
"One-tap period boundary. `start` opens a new cycle (closing the prior), `end` stamps the current cycle's periodEndDate; both write a boundary day-log.",
|
||
});
|
||
|
||
cyclePrefsSchema.meta({
|
||
id: "CyclePrefsRequest",
|
||
description:
|
||
"Partial cycle-preferences deep-merge. `enabled` flips the feature gate (`cycleTrackingEnabled`). Omitted fields are left untouched.",
|
||
});
|
||
|
||
cycleDayLogPatchSchema.meta({
|
||
id: "CycleDayLogPatchRequest",
|
||
description:
|
||
"Partial day-log edit. Every field optional; `note` re-encrypts (explicit null clears it). `date` / `source` / `externalId` are immutable on update.",
|
||
});
|
||
|
||
const cycleSymptomDto = z.object({
|
||
key: z.string(),
|
||
severity: z.number().int().min(1).max(4).nullable(),
|
||
});
|
||
|
||
const cycleDayLogDto = z
|
||
.object({
|
||
id: z.string(),
|
||
date: z.string(),
|
||
cycleId: z.string().nullable(),
|
||
flow: flowLevelEnum.nullable(),
|
||
intermenstrualBleeding: z.boolean(),
|
||
basalBodyTempC: z.number().nullable(),
|
||
temperatureExcluded: z.boolean(),
|
||
ovulationTest: ovulationTestEnum.nullable(),
|
||
cervicalMucus: cervicalMucusEnum.nullable(),
|
||
cervixPosition: cervixPositionEnum.nullable(),
|
||
cervixFirmness: cervixFirmnessEnum.nullable(),
|
||
cervixOpening: cervixOpeningEnum.nullable(),
|
||
sexualActivity: z.boolean(),
|
||
protectedSex: z.boolean().nullable(),
|
||
pregnancyTest: homeTestResultEnum.nullable(),
|
||
progesteroneTest: homeTestResultEnum.nullable(),
|
||
contraceptive: z.string().nullable(),
|
||
symptoms: z.array(cycleSymptomDto),
|
||
note: z.string().nullable(),
|
||
source: z.string(),
|
||
externalId: z.string().nullable(),
|
||
syncVersion: z.number().int(),
|
||
updatedAt: z.iso.datetime({ offset: true }),
|
||
deletedAt: z.iso.datetime({ offset: true }).nullable(),
|
||
})
|
||
.meta({
|
||
id: "CycleDayLogDTO",
|
||
description:
|
||
"The canonical day-log row iOS mirrors. `note` is decrypted on read. Soft-deleted rows ride `/api/sync/changes` as tombstones.",
|
||
});
|
||
|
||
const menstrualCycleDto = z
|
||
.object({
|
||
id: z.string(),
|
||
startDate: z.string(),
|
||
endDate: z.string().nullable(),
|
||
periodEndDate: z.string().nullable(),
|
||
lengthDays: z.number().int().nullable(),
|
||
ovulationDate: z.string().nullable(),
|
||
ovulationConfirmed: z.boolean(),
|
||
isPredicted: z.boolean(),
|
||
syncVersion: z.number().int(),
|
||
updatedAt: z.iso.datetime({ offset: true }),
|
||
})
|
||
.meta({
|
||
id: "MenstrualCycleDTO",
|
||
description: "One menstrual cycle (observed or forward-predicted).",
|
||
});
|
||
|
||
const cyclePredictionDto = z
|
||
.object({
|
||
method: predictionMethodEnumOpenapi,
|
||
nextPeriodStart: z.string(),
|
||
nextPeriodStartLow: z.string(),
|
||
nextPeriodStartHigh: z.string(),
|
||
fertileWindowStart: z.string().nullable(),
|
||
fertileWindowEnd: z.string().nullable(),
|
||
predictedOvulation: z.string().nullable(),
|
||
ovulationConfirmed: z.boolean(),
|
||
confidence: z.number(),
|
||
cyclesObserved: z.number().int(),
|
||
stillLearning: z.boolean(),
|
||
disclaimer: z.string(),
|
||
})
|
||
.meta({
|
||
id: "CyclePredictionDTO",
|
||
description:
|
||
"The materialised forecast. Fertile-window fields (and predictedOvulation/ovulationConfirmed) are server-suppressed (null/false) unless the goal is TRYING_TO_CONCEIVE or AVOID_PREGNANCY.",
|
||
});
|
||
|
||
const cycleCalendarDayDto = z.object({
|
||
date: z.string(),
|
||
phase: cyclePhaseEnumOpenapi.nullable(),
|
||
isPredictedPeriod: z.boolean(),
|
||
isFertileWindow: z.boolean(),
|
||
isPredictedOvulation: z.boolean(),
|
||
isPeriodLogged: z.boolean(),
|
||
flow: flowLevelEnum.nullable(),
|
||
hasSymptoms: z.boolean(),
|
||
confidence: z.number(),
|
||
// v1.15.0 — logged basal-body-temperature + fertility-sign markers, surfaced
|
||
// so the web BBT chart renders from the calendar read (the values are already
|
||
// loaded server-side for the symptothermal layer; no extra query).
|
||
basalBodyTempC: z.number().nullable(),
|
||
temperatureExcluded: z.boolean(),
|
||
ovulationTest: ovulationTestEnum.nullable(),
|
||
cervicalMucus: cervicalMucusEnum.nullable(),
|
||
cervixPosition: cervixPositionEnum.nullable(),
|
||
cervixFirmness: cervixFirmnessEnum.nullable(),
|
||
cervixOpening: cervixOpeningEnum.nullable(),
|
||
});
|
||
|
||
const cycleProfileDto = z
|
||
.object({
|
||
goal: cycleTrackingGoalEnum,
|
||
cycleTrackingEnabled: z.boolean(),
|
||
secondarySymptom: secondarySymptomEnum,
|
||
rawChartMode: z.boolean(),
|
||
predictionEnabled: z.boolean(),
|
||
discreetNotifications: z.boolean(),
|
||
sensitiveCategoryEncryption: z.boolean(),
|
||
typicalCycleLength: z.number().int().nullable(),
|
||
typicalPeriodLength: z.number().int().nullable(),
|
||
lutealPhaseLength: z.number().int().nullable(),
|
||
updatedAt: z.iso.datetime({ offset: true }),
|
||
})
|
||
.meta({
|
||
id: "CycleProfileDTO",
|
||
description: "The full per-user cycle settings row.",
|
||
});
|
||
|
||
const cycleCalendarResponse = z.object({
|
||
profile: z.object({
|
||
goal: cycleTrackingGoalEnum,
|
||
rawChartMode: z.boolean(),
|
||
predictionEnabled: z.boolean(),
|
||
cyclesObserved: z.number().int(),
|
||
}),
|
||
prediction: cyclePredictionDto.nullable(),
|
||
// Cold-start gate (mirrors `prediction.stillLearning`): true while < 3 cycles
|
||
// are observed. When set, the `days` grid carries no fertile window, no
|
||
// predicted-ovulation dot, and no phase band (those would rest on a
|
||
// population prior) — the client shows a calm "learning your cycle" state.
|
||
// Additive + back-compatible.
|
||
stillLearning: z.boolean(),
|
||
days: z.array(cycleCalendarDayDto),
|
||
meta: z.object({ generatedAt: z.iso.datetime({ offset: true }) }),
|
||
});
|
||
|
||
const cycleHistoryResponse = z.object({
|
||
cycles: z.array(menstrualCycleDto),
|
||
stats: z.object({
|
||
avgLengthDays: z.number().int().nullable(),
|
||
lengthVariabilityDays: z.number().nullable(),
|
||
avgPeriodLengthDays: z.number().int().nullable(),
|
||
regularity: z.enum(["REGULAR", "IRREGULAR", "LEARNING"]),
|
||
}),
|
||
});
|
||
|
||
const cyclePeriodResponse = z.object({
|
||
cycle: menstrualCycleDto.nullable(),
|
||
dayLog: cycleDayLogDto.nullable(),
|
||
});
|
||
|
||
const cyclePhaseCrosstabRow = z
|
||
.object({
|
||
metricKey: z.enum([
|
||
"restingHeartRate",
|
||
"heartRateVariability",
|
||
"sleepDuration",
|
||
"steps",
|
||
"weight",
|
||
"basalBodyTemp",
|
||
"wristTemperature",
|
||
"skinTemperature",
|
||
"bloodGlucose",
|
||
"mood",
|
||
]),
|
||
display: z.enum([
|
||
"hours",
|
||
"steps",
|
||
"bpm",
|
||
"ms",
|
||
"kg",
|
||
"celsius",
|
||
"glucose",
|
||
"mood",
|
||
]),
|
||
lutealDays: z.number().int(),
|
||
follicularDays: z.number().int(),
|
||
lutealAvg: z.number(),
|
||
follicularAvg: z.number(),
|
||
delta: z.number(),
|
||
pValue: z.number(),
|
||
qValue: z.number(),
|
||
confidence: z.enum(["low", "medium", "high"]),
|
||
})
|
||
.meta({
|
||
id: "CyclePhaseCrosstabRow",
|
||
description:
|
||
"One FDR-surviving luteal-vs-follicular contrast for an outcome metric. `delta = lutealAvg − follicularAvg`. Observational, never causal.",
|
||
});
|
||
|
||
const cyclePhaseLaggedPair = z
|
||
.object({
|
||
behaviour: z.string(),
|
||
outcome: z.string(),
|
||
n: z.number().int(),
|
||
r: z.number(),
|
||
pValue: z.number(),
|
||
qValue: z.number(),
|
||
interpretation: z.string(),
|
||
lagDays: z.number().int(),
|
||
})
|
||
.meta({
|
||
id: "CyclePhaseLaggedPair",
|
||
description:
|
||
"One FDR-surviving lagged-Pearson pair from the continuous CYCLE_PHASE ordinal × outcome matrix (mechanism B). Descriptive, never causal.",
|
||
});
|
||
|
||
const cyclePhaseEnumForCount = z.enum([
|
||
"MENSTRUAL",
|
||
"FOLLICULAR",
|
||
"OVULATORY",
|
||
"LUTEAL",
|
||
]);
|
||
|
||
const cycleSymptomPhaseRow = z
|
||
.object({
|
||
symptomKey: z.string(),
|
||
counts: z.object({
|
||
MENSTRUAL: z.number().int(),
|
||
FOLLICULAR: z.number().int(),
|
||
OVULATORY: z.number().int(),
|
||
LUTEAL: z.number().int(),
|
||
}),
|
||
total: z.number().int(),
|
||
topPhase: cyclePhaseEnumForCount,
|
||
topShare: z.number(),
|
||
})
|
||
.meta({
|
||
id: "CycleSymptomPhaseRow",
|
||
description:
|
||
"Where a logged symptom clusters across the cycle phases. Surfaced only once logged on ≥3 phase-labelled days. Observational, never causal.",
|
||
});
|
||
|
||
const cycleInsightsResponse = z.object({
|
||
rows: z.array(cyclePhaseCrosstabRow),
|
||
headline: cyclePhaseCrosstabRow.nullable(),
|
||
lagged: z.object({
|
||
discovered: z.array(cyclePhaseLaggedPair),
|
||
pairsTested: z.number().int(),
|
||
fdrQ: z.number(),
|
||
minPairs: z.number().int(),
|
||
}),
|
||
symptomPatterns: z.array(cycleSymptomPhaseRow),
|
||
contrast: z.object({
|
||
high: z.literal("LUTEAL"),
|
||
low: z.literal("FOLLICULAR"),
|
||
}),
|
||
windowDays: z.number().int(),
|
||
cyclesObserved: z.number().int(),
|
||
});
|
||
|
||
const cycleBulkEntryResult = z.object({
|
||
index: z.number().int(),
|
||
status: z.enum(["inserted", "duplicate", "updated", "skipped"]),
|
||
id: z.string().optional(),
|
||
externalId: z.string().optional(),
|
||
reason: z.string().optional(),
|
||
});
|
||
|
||
const cycleBulkResponse = z.object({
|
||
processed: z.number().int(),
|
||
inserted: z.number().int(),
|
||
updated: z.number().int(),
|
||
duplicates: z.number().int(),
|
||
skipped: z.number().int(),
|
||
entries: z.array(cycleBulkEntryResult),
|
||
});
|
||
|
||
// A reusable 403 the cycle routes carry (the feature gate).
|
||
const cycleDisabledResponse = {
|
||
"403": {
|
||
description:
|
||
"Cycle tracking is not enabled for this account (errorCode `cycle.disabled`).",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
} as const;
|
||
|
||
export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||
"/api/cycle/day-logs": {
|
||
get: {
|
||
tags: ["Cycle"],
|
||
summary: "Read a single day's cycle day-log (v1.15.0)",
|
||
description:
|
||
"Returns the full `CycleDayLogDTO` for the tz-anchored `date`, or `null` when nothing is logged that day. Lets a client pre-fill an edit sheet. Gated; owner-scoped; soft-deleted rows excluded.",
|
||
requestParams: { query: cycleDayLogQuerySchema },
|
||
responses: {
|
||
"200": {
|
||
description: "The day-log for that date, or null.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
cycleDayLogDto.nullable(),
|
||
"CycleDayLogReadEnvelope",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...cycleDisabledResponse,
|
||
...stdResponses,
|
||
},
|
||
},
|
||
post: {
|
||
tags: ["Cycle"],
|
||
summary: "Capture a single cycle day-log (v1.15.0)",
|
||
description:
|
||
"Upserts on `(userId, source, externalId)` when externalId present, else `(userId, date)`. `note` encrypts at rest. 201 on insert, 200 on update. Gated: `cycle.disabled` 403 when the feature is off.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: cycleDayLogInputSchema } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Existing day-log updated.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(cycleDayLogDto, "CycleDayLogEnvelope"),
|
||
},
|
||
},
|
||
},
|
||
"201": {
|
||
description: "New day-log created.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
cycleDayLogDto,
|
||
"CycleDayLogCreatedEnvelope",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...cycleDisabledResponse,
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/cycle/day-logs/{id}": {
|
||
patch: {
|
||
tags: ["Cycle"],
|
||
summary: "Edit a single cycle day-log (v1.15.0)",
|
||
description:
|
||
"Partial edit; an omitted field is left untouched. Owner-scoped (a cross-user id 404s). Gated.",
|
||
requestParams: { path: z.object({ id: z.string() }) },
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: cycleDayLogPatchSchema } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Day-log updated.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(cycleDayLogDto, "CycleDayLogPatchEnvelope"),
|
||
},
|
||
},
|
||
},
|
||
"404": {
|
||
description: "Day-log not found / not owned.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
...cycleDisabledResponse,
|
||
...stdResponses,
|
||
},
|
||
},
|
||
delete: {
|
||
tags: ["Cycle"],
|
||
summary: "Soft-delete a cycle day-log (v1.15.0)",
|
||
description:
|
||
"Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. 204. Idempotent.",
|
||
requestParams: { path: z.object({ id: z.string() }) },
|
||
responses: {
|
||
"204": { description: "Soft-deleted (no body)." },
|
||
"404": {
|
||
description: "Day-log not found / not owned.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
...cycleDisabledResponse,
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/cycle/day-logs/bulk": {
|
||
post: {
|
||
tags: ["Cycle"],
|
||
summary: "Bulk drain cycle day-logs (Outbox / HealthKit) (v1.15.0)",
|
||
description:
|
||
"Up to 500 entries; `withIdempotency`; rate-limited `cycle:day-logs:bulk:<userId>` 60/min. Per-entry status: inserted | duplicate | updated | skipped. Always 200.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: cycleBulkSchema } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Batch processed (per-entry results).",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(cycleBulkResponse, "CycleBulkEnvelope"),
|
||
},
|
||
},
|
||
},
|
||
...cycleDisabledResponse,
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/cycle/period": {
|
||
post: {
|
||
tags: ["Cycle"],
|
||
summary: "Period-boundary shortcut (v1.15.0)",
|
||
description:
|
||
"One-tap started/ended period. `start` opens a new cycle (closing the prior); `end` stamps periodEndDate. Writes the boundary day-log. Gated.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: cyclePeriodSchema } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Cycle + boundary day-log.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(cyclePeriodResponse, "CyclePeriodEnvelope"),
|
||
},
|
||
},
|
||
},
|
||
...cycleDisabledResponse,
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/cycle/calendar": {
|
||
get: {
|
||
tags: ["Cycle"],
|
||
summary: "Predicted cycle calendar (v1.15.0)",
|
||
description:
|
||
"Runs the deterministic engine to build `{ profile, prediction, days }`. Fertile-window fields are server-suppressed unless goal is TRYING_TO_CONCEIVE. Default range: today − 90d … +180d. Gated.",
|
||
requestParams: {
|
||
query: z.object({
|
||
from: z.string().optional(),
|
||
to: z.string().optional(),
|
||
}),
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Calendar grid + forecast.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
cycleCalendarResponse,
|
||
"CycleCalendarEnvelope",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...cycleDisabledResponse,
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/cycle/cycles": {
|
||
get: {
|
||
tags: ["Cycle"],
|
||
summary: "Cycle history + stats (v1.15.0)",
|
||
description:
|
||
"Most-recent cycles (newest first) + `{ avgLengthDays, lengthVariabilityDays (MAD), avgPeriodLengthDays, regularity }`. `limit` default 24. Gated.",
|
||
requestParams: {
|
||
query: z.object({
|
||
limit: z.coerce.number().int().min(1).max(60).optional(),
|
||
}),
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Cycle history.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
cycleHistoryResponse,
|
||
"CycleHistoryEnvelope",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...cycleDisabledResponse,
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/cycle/insights": {
|
||
get: {
|
||
tags: ["Cycle"],
|
||
summary: "Cycle-phase correlation insights (v1.15.0)",
|
||
description:
|
||
"FDR-guarded luteal-vs-follicular phase contrast per outcome metric (RHR / HRV / sleep / steps / weight / temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off.",
|
||
responses: {
|
||
"200": {
|
||
description: "Phase-correlation rows + headline.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
cycleInsightsResponse,
|
||
"CycleInsightsEnvelope",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...cycleDisabledResponse,
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/cycle/cycles/{id}": {
|
||
delete: {
|
||
tags: ["Cycle"],
|
||
summary: "Soft-delete a menstrual cycle (v1.15.0)",
|
||
description:
|
||
"Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. 204. Idempotent.",
|
||
requestParams: { path: z.object({ id: z.string() }) },
|
||
responses: {
|
||
"204": { description: "Soft-deleted (no body)." },
|
||
"404": {
|
||
description: "Cycle not found / not owned.",
|
||
content: { "application/json": { schema: errorEnvelope } },
|
||
},
|
||
...cycleDisabledResponse,
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/cycle/all": {
|
||
delete: {
|
||
tags: ["Cycle"],
|
||
summary: "Hard-purge all cycle data (v1.15.0)",
|
||
description:
|
||
"One-click privacy purge: HARD-deletes every cycle day-log (+ symptom links by cascade), menstrual cycle, prediction, the cycle audit trail, and the cycle reminder rows in the push-attempts ledger — no dated reproductive trace survives. The CycleProfile row is left in place. Gated + owner-scoped + audited.",
|
||
responses: {
|
||
"200": {
|
||
description: "Purge counts.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(
|
||
z.object({
|
||
purged: z.boolean(),
|
||
dayLogs: z.number().int(),
|
||
predictions: z.number().int(),
|
||
cycles: z.number().int(),
|
||
auditRows: z.number().int(),
|
||
pushRows: z.number().int(),
|
||
}),
|
||
"CyclePurgeEnvelope",
|
||
),
|
||
},
|
||
},
|
||
},
|
||
...cycleDisabledResponse,
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/cycle/profile": {
|
||
get: {
|
||
tags: ["Cycle"],
|
||
summary: "Read the full cycle profile (v1.15.0)",
|
||
description: "Returns the resolved CycleProfileDTO. Gated.",
|
||
responses: {
|
||
"200": {
|
||
description: "Cycle profile.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(cycleProfileDto, "CycleProfileEnvelope"),
|
||
},
|
||
},
|
||
},
|
||
...cycleDisabledResponse,
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
"/api/auth/me/cycle-prefs": {
|
||
get: {
|
||
tags: ["Cycle"],
|
||
summary: "Read cycle preferences (v1.15.0)",
|
||
description:
|
||
"Returns the resolved CycleProfileDTO. NOT gated — this is the surface that flips the gate.",
|
||
responses: {
|
||
"200": {
|
||
description: "Cycle preferences.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(cycleProfileDto, "CyclePrefsGetEnvelope"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
patch: {
|
||
tags: ["Cycle"],
|
||
summary: "Update cycle preferences (v1.15.0)",
|
||
description:
|
||
"Deep-merges the supplied fields. `enabled` flips `cycleTrackingEnabled`. Returns the merged CycleProfileDTO. NOT gated.",
|
||
requestBody: {
|
||
required: true,
|
||
content: { "application/json": { schema: cyclePrefsSchema } },
|
||
},
|
||
responses: {
|
||
"200": {
|
||
description: "Merged cycle preferences.",
|
||
content: {
|
||
"application/json": {
|
||
schema: dataEnvelope(cycleProfileDto, "CyclePrefsPatchEnvelope"),
|
||
},
|
||
},
|
||
},
|
||
...stdResponses,
|
||
},
|
||
},
|
||
},
|
||
};
|