diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4b2c1c1db..6ae909ef1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -37,9 +37,664 @@ tags: - name: Devices - name: Export - name: Sync + - name: Cycle - name: Admin - name: Meta paths: + /api/cycle/day-logs: + 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: + type: object + properties: + date: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + flow: + type: string + enum: + - NONE + - SPOTTING + - LIGHT + - MEDIUM + - HEAVY + intermenstrualBleeding: + type: boolean + basalBodyTempC: + type: number + minimum: 30 + maximum: 45 + ovulationTest: + type: string + enum: + - NEGATIVE + - POSITIVE_LH_SURGE + - ESTROGEN_SURGE + - INDETERMINATE + cervicalMucus: + type: string + enum: + - DRY + - STICKY + - CREAMY + - WATERY + - EGG_WHITE + sexualActivity: + type: boolean + protectedSex: + anyOf: + - type: boolean + - type: "null" + pregnancyTest: + type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + progesteroneTest: + type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + contraceptive: + type: string + enum: + - NONE + - UNSPECIFIED + - IMPLANT + - INJECTION + - IUD + - INTRAVAGINAL_RING + - ORAL + - PATCH + - EMERGENCY + symptoms: + maxItems: 40 + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + maxLength: 80 + severity: + type: integer + minimum: 1 + maximum: 4 + required: + - key + note: + type: string + maxLength: 500 + loggedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + source: + default: MANUAL + type: string + enum: + - MANUAL + - APPLE_HEALTH + externalId: + type: string + minLength: 1 + maxLength: 120 + required: + - date + - loggedAt + responses: + "200": + description: Existing day-log updated. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleDayLogEnvelope" + "201": + description: New day-log created. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleDayLogCreatedEnvelope" + "401": &a1 + description: Authentication required or invalid credentials. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "403": &a2 + description: Cycle tracking is not enabled for this account (errorCode `cycle.disabled`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": &a3 + description: Request validation failed. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": &a4 + description: Rate limit exceeded. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /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. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + flow: + anyOf: + - type: string + enum: + - NONE + - SPOTTING + - LIGHT + - MEDIUM + - HEAVY + - type: "null" + intermenstrualBleeding: + type: boolean + basalBodyTempC: + anyOf: + - type: number + minimum: 30 + maximum: 45 + - type: "null" + ovulationTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE_LH_SURGE + - ESTROGEN_SURGE + - INDETERMINATE + - type: "null" + cervicalMucus: + anyOf: + - type: string + enum: + - DRY + - STICKY + - CREAMY + - WATERY + - EGG_WHITE + - type: "null" + sexualActivity: + type: boolean + protectedSex: + anyOf: + - type: boolean + - type: "null" + pregnancyTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + - type: "null" + progesteroneTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + - type: "null" + contraceptive: + anyOf: + - type: string + enum: + - NONE + - UNSPECIFIED + - IMPLANT + - INJECTION + - IUD + - INTRAVAGINAL_RING + - ORAL + - PATCH + - EMERGENCY + - type: "null" + symptoms: + maxItems: 40 + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + maxLength: 80 + severity: + type: integer + minimum: 1 + maximum: 4 + required: + - key + note: + anyOf: + - type: string + maxLength: 500 + - type: "null" + loggedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + responses: + "200": + description: Day-log updated. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleDayLogPatchEnvelope" + "401": *a1 + "403": *a2 + "404": + description: Day-log not found / not owned. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + 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. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "204": + description: Soft-deleted (no body). + "401": *a1 + "403": *a2 + "404": + description: Day-log not found / not owned. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /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:` 60/min. Per-entry + status: inserted | duplicate | updated | skipped. Always 200." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + entries: + minItems: 1 + maxItems: 500 + type: array + items: + type: object + properties: + date: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + flow: + type: string + enum: + - NONE + - SPOTTING + - LIGHT + - MEDIUM + - HEAVY + intermenstrualBleeding: + type: boolean + basalBodyTempC: + type: number + minimum: 30 + maximum: 45 + ovulationTest: + type: string + enum: + - NEGATIVE + - POSITIVE_LH_SURGE + - ESTROGEN_SURGE + - INDETERMINATE + cervicalMucus: + type: string + enum: + - DRY + - STICKY + - CREAMY + - WATERY + - EGG_WHITE + sexualActivity: + type: boolean + protectedSex: + anyOf: + - type: boolean + - type: "null" + pregnancyTest: + type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + progesteroneTest: + type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + contraceptive: + type: string + enum: + - NONE + - UNSPECIFIED + - IMPLANT + - INJECTION + - IUD + - INTRAVAGINAL_RING + - ORAL + - PATCH + - EMERGENCY + symptoms: + maxItems: 40 + type: array + items: + type: object + properties: + key: + type: string + minLength: 1 + maxLength: 80 + severity: + type: integer + minimum: 1 + maximum: 4 + required: + - key + note: + type: string + maxLength: 500 + loggedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + source: + default: MANUAL + type: string + enum: + - MANUAL + - APPLE_HEALTH + externalId: + type: string + minLength: 1 + maxLength: 120 + required: + - date + - loggedAt + required: + - entries + responses: + "200": + description: Batch processed (per-entry results). + content: + application/json: + schema: + $ref: "#/components/schemas/CycleBulkEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 + /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: + type: object + properties: + action: + type: string + enum: + - start + - end + date: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + externalId: + type: string + minLength: 1 + maxLength: 120 + loggedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - action + - date + - loggedAt + responses: + "200": + description: Cycle + boundary day-log. + content: + application/json: + schema: + $ref: "#/components/schemas/CyclePeriodEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 + /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." + parameters: + - in: query + name: from + schema: + type: string + - in: query + name: to + schema: + type: string + responses: + "200": + description: Calendar grid + forecast. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleCalendarEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 + /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. + parameters: + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 60 + responses: + "200": + description: Cycle history. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleHistoryEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 + /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. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "204": + description: Soft-deleted (no body). + "401": *a1 + "403": *a2 + "404": + description: Cycle not found / not owned. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /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: + $ref: "#/components/schemas/CycleProfileEnvelope" + "401": *a1 + "403": *a2 + "422": *a3 + "429": *a4 + /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: + $ref: "#/components/schemas/CyclePrefsGetEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + 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: + type: object + properties: + enabled: + type: boolean + goal: + type: string + enum: + - GENERAL_HEALTH + - AVOID_PREGNANCY + - TRYING_TO_CONCEIVE + - PERIMENOPAUSE + - OFF + rawChartMode: + type: boolean + predictionEnabled: + type: boolean + discreetNotifications: + type: boolean + sensitiveCategoryEncryption: + type: boolean + typicalCycleLength: + anyOf: + - type: integer + minimum: 15 + maximum: 60 + - type: "null" + typicalPeriodLength: + anyOf: + - type: integer + minimum: 1 + maximum: 15 + - type: "null" + lutealPhaseLength: + anyOf: + - type: integer + minimum: 8 + maximum: 20 + - type: "null" + responses: + "200": + description: Merged cycle preferences. + content: + application/json: + schema: + $ref: "#/components/schemas/CyclePrefsPatchEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/meta/capabilities: get: tags: @@ -57,24 +712,9 @@ paths: application/json: schema: $ref: "#/components/schemas/CapabilitiesEnvelope" - "401": &a1 - description: Authentication required or invalid credentials. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "422": &a2 - description: Request validation failed. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" - "429": &a3 - description: Rate limit exceeded. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/export/health-record: post: tags: @@ -207,8 +847,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/share-links: post: tags: @@ -323,8 +963,8 @@ paths: schema: $ref: "#/components/schemas/ShareLinkCreated" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 get: tags: - Export @@ -339,8 +979,8 @@ paths: schema: $ref: "#/components/schemas/ShareLinkList" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/share-links/{id}: delete: tags: @@ -368,8 +1008,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/fhir/metadata: get: tags: @@ -387,8 +1027,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/fhir/Patient: get: tags: @@ -414,8 +1054,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/fhir/Observation: get: tags: @@ -441,8 +1081,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/fhir/MedicationStatement: get: tags: @@ -468,8 +1108,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/fhir/MedicationAdministration: get: tags: @@ -495,8 +1135,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/fhir/$everything: get: tags: @@ -523,8 +1163,8 @@ paths: type: string format: binary "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/auth/login: post: tags: @@ -556,8 +1196,8 @@ paths: schema: $ref: "#/components/schemas/LoginResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/auth/passkey/login-verify: post: tags: @@ -577,8 +1217,8 @@ paths: schema: $ref: "#/components/schemas/PasskeyLoginVerifyResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/auth/refresh: post: tags: @@ -608,8 +1248,8 @@ paths: schema: $ref: "#/components/schemas/RefreshResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/sync/state: get: tags: @@ -626,8 +1266,8 @@ paths: schema: $ref: "#/components/schemas/SyncStateEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/sync/changes: get: tags: @@ -663,8 +1303,8 @@ paths: schema: $ref: "#/components/schemas/SyncChangesEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/measurements: get: tags: @@ -819,8 +1459,8 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 post: tags: - Measurements @@ -940,8 +1580,8 @@ paths: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/measurements/batch: post: tags: @@ -965,8 +1605,8 @@ paths: schema: $ref: "#/components/schemas/BatchMeasurementsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/measurements/by-external-ids: delete: tags: @@ -988,8 +1628,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementsDeleteByExternalIdsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/sleep/night: get: tags: @@ -1017,8 +1657,8 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/workouts: get: tags: @@ -1065,8 +1705,8 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/workouts/{id}: get: tags: @@ -1095,8 +1735,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/workouts/batch: post: tags: @@ -1310,8 +1950,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/devices: post: tags: @@ -1339,8 +1979,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/medications: get: tags: @@ -1358,8 +1998,8 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 post: tags: - Medications @@ -1381,8 +2021,8 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/medications/{id}: get: tags: @@ -1410,8 +2050,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 put: tags: - Medications @@ -1446,8 +2086,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 delete: tags: - Medications @@ -1474,8 +2114,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/medications/{id}/intake: post: tags: @@ -1517,8 +2157,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/medications/extract: post: tags: @@ -1543,8 +2183,8 @@ paths: schema: $ref: "#/components/schemas/MedicationExtractResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 "502": description: Upstream provider returned an empty, unparseable, or off-schema reply. content: @@ -1594,8 +2234,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/medications/{id}/compliance: get: tags: @@ -1626,8 +2266,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/auth/me/coach-prefs: get: tags: @@ -1643,8 +2283,8 @@ paths: schema: $ref: "#/components/schemas/GetCoachPrefsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 put: tags: - Auth @@ -1725,8 +2365,8 @@ paths: schema: $ref: "#/components/schemas/PutCoachPrefsResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/auth/me/disable-coach: get: tags: @@ -1742,8 +2382,8 @@ paths: schema: $ref: "#/components/schemas/GetDisableCoachResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 patch: tags: - Auth @@ -1764,8 +2404,8 @@ paths: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/auth/me/source-priority: get: tags: @@ -1781,8 +2421,8 @@ paths: schema: $ref: "#/components/schemas/GetSourcePriorityResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 put: tags: - Auth @@ -2278,8 +2918,8 @@ paths: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/user/avatar: post: tags: @@ -2322,8 +2962,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 delete: tags: - Auth @@ -2334,8 +2974,8 @@ paths: "204": description: Avatar cleared (or already empty). "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/user/avatar/{id}: get: tags: @@ -2381,8 +3021,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/user/profile: get: tags: @@ -2398,8 +3038,8 @@ paths: schema: $ref: "#/components/schemas/GetProfileResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 patch: tags: - Auth @@ -2421,8 +3061,8 @@ paths: schema: $ref: "#/components/schemas/PatchProfileResponse" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/layout: get: tags: @@ -2438,8 +3078,8 @@ paths: schema: $ref: "#/components/schemas/InsightsLayout" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 put: tags: - Insights @@ -2460,8 +3100,8 @@ paths: schema: $ref: "#/components/schemas/InsightsLayoutSaved" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 delete: tags: - Insights @@ -2475,8 +3115,8 @@ paths: schema: $ref: "#/components/schemas/InsightsLayoutReset" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/coach/facts: get: tags: @@ -2494,8 +3134,8 @@ paths: schema: $ref: "#/components/schemas/CoachFactsList" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 delete: tags: - Insights @@ -2510,8 +3150,8 @@ paths: schema: $ref: "#/components/schemas/CoachFactsCleared" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/coach/facts/{id}: delete: tags: @@ -2528,8 +3168,8 @@ paths: schema: $ref: "#/components/schemas/CoachFactDeleted" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/chat/messages/{id}/feedback: post: tags: @@ -2564,8 +3204,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/admin/notifications/diagnostic: get: tags: @@ -2588,8 +3228,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/dashboard/snapshot: get: tags: @@ -2606,8 +3246,8 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/comprehensive: get: tags: @@ -2623,8 +3263,8 @@ paths: schema: $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/pregenerate: post: tags: @@ -2650,8 +3290,8 @@ paths: schema: $ref: "#/components/schemas/InsightsPregenerateResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/analytics/range: get: tags: @@ -2756,8 +3396,8 @@ paths: schema: $ref: "#/components/schemas/AnalyticsRangeResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/blood-pressure-status: get: tags: @@ -2784,8 +3424,8 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/pulse-status: get: tags: @@ -2812,8 +3452,8 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/weight-status: get: tags: @@ -2840,8 +3480,8 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/bmi-status: get: tags: @@ -2868,8 +3508,8 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/mood-status: get: tags: @@ -2896,8 +3536,8 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/medication-compliance-status: get: tags: @@ -2924,8 +3564,8 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/metric-status: get: tags: @@ -3003,8 +3643,8 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/derived: get: tags: @@ -3075,8 +3715,8 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/derived/batch: get: tags: @@ -3109,8 +3749,8 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 /api/insights/correlations: get: tags: @@ -3129,8 +3769,8 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 - "422": *a2 - "429": *a3 + "422": *a3 + "429": *a4 components: schemas: PasskeyLoginVerifyRequest: @@ -4063,6 +4703,699 @@ components: primitives are mutually exclusive — enforced by the Zod refine on writes, the route layer, and a DB CHECK constraint (`medication_schedules_rrule_xor_rolling`). v1.7.0 adds `scheduleType` (SCHEDULED / PRN / CYCLIC) and the cyclic on/off-week fields. + CycleDayLogEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleDayLogDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleDayLogDTO: + type: object + properties: + id: + type: string + date: + type: string + cycleId: + anyOf: + - type: string + - type: "null" + flow: + anyOf: + - type: string + enum: + - NONE + - SPOTTING + - LIGHT + - MEDIUM + - HEAVY + - type: "null" + intermenstrualBleeding: + type: boolean + basalBodyTempC: + anyOf: + - type: number + - type: "null" + ovulationTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE_LH_SURGE + - ESTROGEN_SURGE + - INDETERMINATE + - type: "null" + cervicalMucus: + anyOf: + - type: string + enum: + - DRY + - STICKY + - CREAMY + - WATERY + - EGG_WHITE + - type: "null" + sexualActivity: + type: boolean + protectedSex: + anyOf: + - type: boolean + - type: "null" + pregnancyTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + - type: "null" + progesteroneTest: + anyOf: + - type: string + enum: + - NEGATIVE + - POSITIVE + - INDETERMINATE + - type: "null" + contraceptive: + anyOf: + - type: string + - type: "null" + symptoms: + type: array + items: + type: object + properties: + key: + type: string + severity: + anyOf: + - type: integer + minimum: 1 + maximum: 4 + - type: "null" + required: + - key + - severity + additionalProperties: false + note: + anyOf: + - type: string + - type: "null" + source: + type: string + externalId: + anyOf: + - type: string + - type: "null" + syncVersion: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + deletedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + required: + - id + - date + - cycleId + - flow + - intermenstrualBleeding + - basalBodyTempC + - ovulationTest + - cervicalMucus + - sexualActivity + - protectedSex + - pregnancyTest + - progesteroneTest + - contraceptive + - symptoms + - note + - source + - externalId + - syncVersion + - updatedAt + - deletedAt + additionalProperties: false + description: The canonical day-log row iOS mirrors. `note` is decrypted on read. Soft-deleted rows ride + `/api/sync/changes` as tombstones. + CycleDayLogCreatedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleDayLogDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ErrorEnvelope: + type: object + properties: + data: + type: "null" + error: + type: string + meta: + type: object + properties: + requestId: + type: string + errorCode: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + description: "Standard error response: data is null, error is human prose." + CycleDayLogPatchEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleDayLogDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleBulkEnvelope: + type: object + properties: + data: + type: object + properties: + processed: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + inserted: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + updated: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + duplicates: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + skipped: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + entries: + type: array + items: + type: object + properties: + index: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + status: + type: string + enum: + - inserted + - duplicate + - updated + - skipped + id: + type: string + externalId: + type: string + reason: + type: string + required: + - index + - status + additionalProperties: false + required: + - processed + - inserted + - updated + - duplicates + - skipped + - entries + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CyclePeriodEnvelope: + type: object + properties: + data: + type: object + properties: + cycle: + anyOf: + - $ref: "#/components/schemas/MenstrualCycleDTO" + - type: "null" + dayLog: + anyOf: + - $ref: "#/components/schemas/CycleDayLogDTO" + - type: "null" + required: + - cycle + - dayLog + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MenstrualCycleDTO: + type: object + properties: + id: + type: string + startDate: + type: string + endDate: + anyOf: + - type: string + - type: "null" + periodEndDate: + anyOf: + - type: string + - type: "null" + lengthDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + ovulationDate: + anyOf: + - type: string + - type: "null" + ovulationConfirmed: + type: boolean + isPredicted: + type: boolean + syncVersion: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - startDate + - endDate + - periodEndDate + - lengthDays + - ovulationDate + - ovulationConfirmed + - isPredicted + - syncVersion + - updatedAt + additionalProperties: false + description: One menstrual cycle (observed or forward-predicted). + CycleCalendarEnvelope: + type: object + properties: + data: + type: object + properties: + profile: + type: object + properties: + goal: + type: string + enum: + - GENERAL_HEALTH + - AVOID_PREGNANCY + - TRYING_TO_CONCEIVE + - PERIMENOPAUSE + - OFF + rawChartMode: + type: boolean + predictionEnabled: + type: boolean + cyclesObserved: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - goal + - rawChartMode + - predictionEnabled + - cyclesObserved + additionalProperties: false + prediction: + anyOf: + - $ref: "#/components/schemas/CyclePredictionDTO" + - type: "null" + days: + type: array + items: + type: object + properties: + date: + type: string + phase: + anyOf: + - $ref: "#/components/schemas/CyclePhase" + - type: "null" + isPredictedPeriod: + type: boolean + isFertileWindow: + type: boolean + isPredictedOvulation: + type: boolean + isPeriodLogged: + type: boolean + flow: + anyOf: + - type: string + enum: + - NONE + - SPOTTING + - LIGHT + - MEDIUM + - HEAVY + - type: "null" + hasSymptoms: + type: boolean + confidence: + type: number + required: + - date + - phase + - isPredictedPeriod + - isFertileWindow + - isPredictedOvulation + - isPeriodLogged + - flow + - hasSymptoms + - confidence + additionalProperties: false + meta: + type: object + properties: + generatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - generatedAt + additionalProperties: false + required: + - profile + - prediction + - days + - meta + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CyclePredictionDTO: + type: object + properties: + method: + $ref: "#/components/schemas/CyclePredictionMethod" + nextPeriodStart: + type: string + nextPeriodStartLow: + type: string + nextPeriodStartHigh: + type: string + fertileWindowStart: + anyOf: + - type: string + - type: "null" + fertileWindowEnd: + anyOf: + - type: string + - type: "null" + predictedOvulation: + anyOf: + - type: string + - type: "null" + confidence: + type: number + cyclesObserved: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + stillLearning: + type: boolean + disclaimer: + type: string + required: + - method + - nextPeriodStart + - nextPeriodStartLow + - nextPeriodStartHigh + - fertileWindowStart + - fertileWindowEnd + - predictedOvulation + - confidence + - cyclesObserved + - stillLearning + - disclaimer + additionalProperties: false + description: The materialised forecast. Fertile-window fields are server-suppressed (null) unless the goal is + TRYING_TO_CONCEIVE. + CyclePredictionMethod: + type: string + enum: + - CALENDAR + - SYMPTOTHERMAL + - TEMPERATURE_TREND + - BLENDED + CyclePhase: + type: string + enum: + - MENSTRUAL + - FOLLICULAR + - OVULATORY + - LUTEAL + CycleHistoryEnvelope: + type: object + properties: + data: + type: object + properties: + cycles: + type: array + items: + $ref: "#/components/schemas/MenstrualCycleDTO" + stats: + type: object + properties: + avgLengthDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + lengthVariabilityDays: + anyOf: + - type: number + - type: "null" + avgPeriodLengthDays: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + regularity: + type: string + enum: + - REGULAR + - IRREGULAR + - LEARNING + required: + - avgLengthDays + - lengthVariabilityDays + - avgPeriodLengthDays + - regularity + additionalProperties: false + required: + - cycles + - stats + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleProfileEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleProfileDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleProfileDTO: + type: object + properties: + goal: + type: string + enum: + - GENERAL_HEALTH + - AVOID_PREGNANCY + - TRYING_TO_CONCEIVE + - PERIMENOPAUSE + - OFF + cycleTrackingEnabled: + type: boolean + rawChartMode: + type: boolean + predictionEnabled: + type: boolean + discreetNotifications: + type: boolean + sensitiveCategoryEncryption: + type: boolean + typicalCycleLength: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + typicalPeriodLength: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + lutealPhaseLength: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - goal + - cycleTrackingEnabled + - rawChartMode + - predictionEnabled + - discreetNotifications + - sensitiveCategoryEncryption + - typicalCycleLength + - typicalPeriodLength + - lutealPhaseLength + - updatedAt + additionalProperties: false + description: The full per-user cycle settings row. + CyclePrefsGetEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleProfileDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CyclePrefsPatchEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CycleProfileDTO" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false CapabilitiesEnvelope: type: object properties: @@ -4237,26 +5570,6 @@ components: additionalProperties: false description: Live id vocabularies + contract version. Every list is derived server-side from the canonical registry it documents, so it cannot drift from the values the routes actually accept/emit. - ErrorEnvelope: - type: object - properties: - data: - type: "null" - error: - type: string - meta: - type: object - properties: - requestId: - type: string - errorCode: - type: string - additionalProperties: false - required: - - data - - error - additionalProperties: false - description: "Standard error response: data is null, error is human prose." ShareLinkCreated: type: object properties: diff --git a/messages/de.json b/messages/de.json index 29aa90b09..d0a4d488b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4890,6 +4890,9 @@ "nausea": "Übelkeit", "diarrhea": "Durchfall", "constipation": "Verstopfung" + }, + "prediction": { + "disclaimer": "Vorhersagen sind Schätzungen aus deinen erfassten Daten, keine medizinische Beratung und keine Verhütung. Die Genauigkeit steigt, je mehr Zyklen du erfasst." } } } diff --git a/messages/en.json b/messages/en.json index 0d19a389f..c46256bba 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4890,6 +4890,9 @@ "nausea": "Nausea", "diarrhea": "Diarrhea", "constipation": "Constipation" + }, + "prediction": { + "disclaimer": "Predictions are estimates from your logged data, not medical advice or contraception. Accuracy improves as you log more cycles." } } } diff --git a/messages/es.json b/messages/es.json index 9eb7616a7..3df96040b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4890,6 +4890,9 @@ "nausea": "Náuseas", "diarrhea": "Diarrea", "constipation": "Estreñimiento" + }, + "prediction": { + "disclaimer": "Las predicciones son estimaciones basadas en tus datos registrados, no son consejo médico ni un método anticonceptivo. La precisión mejora a medida que registras más ciclos." } } } diff --git a/messages/fr.json b/messages/fr.json index 4274933f8..85653fb21 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4890,6 +4890,9 @@ "nausea": "Nausées", "diarrhea": "Diarrhée", "constipation": "Constipation" + }, + "prediction": { + "disclaimer": "Les prévisions sont des estimations issues de tes données enregistrées, et non un avis médical ou un moyen de contraception. La précision s'améliore à mesure que tu enregistres des cycles." } } } diff --git a/messages/it.json b/messages/it.json index 420309e26..ebdcd177d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4890,6 +4890,9 @@ "nausea": "Nausea", "diarrhea": "Diarrea", "constipation": "Stitichezza" + }, + "prediction": { + "disclaimer": "Le previsioni sono stime basate sui dati registrati, non sono un consiglio medico né un metodo contraccettivo. La precisione migliora man mano che registri più cicli." } } } diff --git a/messages/pl.json b/messages/pl.json index a4475a321..2b1eea8b6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4890,6 +4890,9 @@ "nausea": "Nudności", "diarrhea": "Biegunka", "constipation": "Zaparcia" + }, + "prediction": { + "disclaimer": "Prognozy to szacunki na podstawie zapisanych danych, a nie porada medyczna ani metoda antykoncepcji. Dokładność rośnie wraz z liczbą zapisanych cykli." } } } diff --git a/prisma/migrations/0132_v1150_cycle_enable_flag/migration.sql b/prisma/migrations/0132_v1150_cycle_enable_flag/migration.sql new file mode 100644 index 000000000..2d8c53bc2 --- /dev/null +++ b/prisma/migrations/0132_v1150_cycle_enable_flag/migration.sql @@ -0,0 +1,17 @@ +-- v1.15.0 — cycle-tracking feature enable flag. +-- +-- Adds the per-user `cycle_tracking_enabled` toggle to `cycle_profiles`. +-- The column is NULLABLE on purpose: NULL means "derive from gender" +-- (`FEMALE` → enabled, everything else → disabled), so no row backfill +-- is required — existing profiles read NULL and resolve through the +-- `isCycleEnabled()` gate helper. An explicit `true` opts a non-FEMALE +-- account in (Settings toggle); an explicit `false` opts a FEMALE +-- account out. Every `/api/cycle/*` route returns +-- `403 { error, meta:{ errorCode:"cycle.disabled" } }` when the resolved +-- value is false. +-- +-- Reversibility (down): +-- ALTER TABLE "cycle_profiles" DROP COLUMN IF EXISTS "cycle_tracking_enabled"; + +-- AlterTable +ALTER TABLE "cycle_profiles" ADD COLUMN "cycle_tracking_enabled" BOOLEAN; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 49ed7ec50..e49884e87 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3308,6 +3308,14 @@ model CycleProfile { /// Drives all cycle copy + fertile-window gating (see CycleTrackingGoal). goal CycleTrackingGoal @default(GENERAL_HEALTH) + /// Per-user feature enable. NULL = derive from gender (`FEMALE` → on, + /// everything else → off) so the column never needs a backfill; an + /// explicit `true` opts a non-FEMALE account in, an explicit `false` + /// opts a FEMALE account out. Resolved through `isCycleEnabled()` — + /// every `/api/cycle/*` route 403s with `errorCode:"cycle.disabled"` + /// when the resolved value is false. + cycleTrackingEnabled Boolean? @map("cycle_tracking_enabled") + /// Optional user-supplied priors. The engine applies a luteal default /// of 14 days in code (NOT a DB default) when `lutealPhaseLength` is /// NULL, so a future constant change never strands historical rows. diff --git a/src/app/api/auth/me/cycle-prefs/route.ts b/src/app/api/auth/me/cycle-prefs/route.ts new file mode 100644 index 000000000..3de329d1c --- /dev/null +++ b/src/app/api/auth/me/cycle-prefs/route.ts @@ -0,0 +1,131 @@ +/** + * v1.15.0 — per-user cycle-tracking preferences. + * + * GET /api/auth/me/cycle-prefs — current resolved CycleProfileDTO. + * PATCH /api/auth/me/cycle-prefs — body `{ enabled?, goal?, rawChartMode?, + * predictionEnabled?, discreetNotifications?, + * sensitiveCategoryEncryption?, + * typicalCycleLength?, typicalPeriodLength?, + * lutealPhaseLength? }`. Deep-merges the + * supplied fields over the persisted row + * (the notification-prefs precedent) and + * returns the merged CycleProfileDTO. + * + * This route does NOT enforce the cycle gate — it is the surface that + * FLIPS the gate (`enabled` → `cycleTrackingEnabled`). `enabled` is + * idempotent last-writer-wins; the route is otherwise idempotent. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; +import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; +import { isCycleEnabled } from "@/lib/cycle/gate"; +import { toCycleProfileDTO } from "@/lib/cycle/dto"; +import { cyclePrefsSchema } from "@/lib/validations/cycle"; + +export const dynamic = "force-dynamic"; + +const PATCH_RATE_LIMIT = 60; +const PATCH_WINDOW_MS = 60_000; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "auth.me.cycle-prefs.get" } }); + + const profile = await getOrCreateCycleProfile(user.id); + const resolved = isCycleEnabled(user.gender, profile); + return apiSuccess(toCycleProfileDTO(profile, resolved)); +}); + +export const PATCH = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `cycle-prefs:patch:${user.id}`, + PATCH_RATE_LIMIT, + PATCH_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return apiError("Invalid JSON body", 422, { + errorCode: "cycle-prefs.body.invalid_json", + }); + } + + const parsed = cyclePrefsSchema.safeParse(body ?? {}); + if (!parsed.success) { + annotate({ + action: { name: "auth.me.cycle-prefs.patch.invalid_shape" }, + meta: { issues: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "cycle-prefs.invalid", + }); + } + + // Ensure the row exists, then deep-merge: only the supplied fields are + // touched; siblings are left untouched (field-by-field, no mass + // assignment). `enabled` maps onto the `cycleTrackingEnabled` column. + await getOrCreateCycleProfile(user.id); + const p = parsed.data; + + const updated = await prisma.cycleProfile.update({ + where: { userId: user.id }, + data: { + ...(p.enabled !== undefined && { cycleTrackingEnabled: p.enabled }), + ...(p.goal !== undefined && { goal: p.goal }), + ...(p.rawChartMode !== undefined && { rawChartMode: p.rawChartMode }), + ...(p.predictionEnabled !== undefined && { + predictionEnabled: p.predictionEnabled, + }), + ...(p.discreetNotifications !== undefined && { + discreetNotifications: p.discreetNotifications, + }), + ...(p.sensitiveCategoryEncryption !== undefined && { + sensitiveCategoryEncryption: p.sensitiveCategoryEncryption, + }), + ...(p.typicalCycleLength !== undefined && { + typicalCycleLength: p.typicalCycleLength, + }), + ...(p.typicalPeriodLength !== undefined && { + typicalPeriodLength: p.typicalPeriodLength, + }), + ...(p.lutealPhaseLength !== undefined && { + lutealPhaseLength: p.lutealPhaseLength, + }), + }, + }); + + const resolved = isCycleEnabled(user.gender, updated); + + await auditLog("user.cycle-prefs.update", { + userId: user.id, + ipAddress: getClientIp(req), + details: { changed: Object.keys(p) }, + }); + + annotate({ + action: { name: "user.cycle_prefs.update" }, + meta: { changed: Object.keys(p), enabled: resolved }, + }); + + return apiSuccess(toCycleProfileDTO(updated, resolved)); +}); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 49f74a96a..22b43748f 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -4,6 +4,8 @@ import { annotate } from "@/lib/logging/context"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; import { buildAvatarUrl } from "@/lib/avatar"; import { decrypt } from "@/lib/crypto"; +import { prisma } from "@/lib/db"; +import { isCycleEnabled } from "@/lib/cycle/gate"; export const dynamic = "force-dynamic"; @@ -19,6 +21,15 @@ export const GET = apiHandler(async () => { annotate({ action: { name: "auth.me" } }); + // v1.15.0 — resolved cycle-tracking gate. Read the profile without + // forcing a row (a NULL toggle derives from gender); the resolver + // collapses both gates into the single boolean iOS hides the tab on. + const cycleProfile = await prisma.cycleProfile.findUnique({ + where: { userId: user.id }, + select: { cycleTrackingEnabled: true }, + }); + const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile); + // v1.7.0 — patient-identity fields for the health-record export. The // KVNR is stored encrypted; decrypt fail-soft so a key-rotation gap on // one row never 500s the whole profile fetch (the field just reads @@ -68,5 +79,8 @@ export const GET = apiHandler(async () => { insurerName: user.insurerName ?? null, insurerIkNumber: user.insurerIkNumber ?? null, insuranceNumber, + // v1.15.0 — cycle-tracking feature gate, resolved server-side. iOS + // hides the whole cycle tab when this is false. + cycleTrackingEnabled, }); }); diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts new file mode 100644 index 000000000..ee72d97c5 --- /dev/null +++ b/src/app/api/cycle/calendar/route.ts @@ -0,0 +1,171 @@ +/** + * `GET /api/cycle/calendar?from&to` — predicted calendar read + * (ios-contract §2.D). + * + * Calls the pure engine (`predictCycle` + per-day phase) to build + * `{ profile, prediction, days }`. The synchronous engine call is cheap + * (pure stats); the heavy part is the cache persist, which is debounced + * stale-while-revalidate and fire-and-forget so it never blocks the GET. + * Fertile-window fields are goal-gated (null unless TRYING_TO_CONCEIVE), + * suppressed server-side. + * + * Default range: current cycle − 90d … +180d forward. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { cycleCalendarQuerySchema } from "@/lib/validations/cycle"; +import { + buildCalendar, + type CalendarDayLogRow, +} from "@/lib/cycle/engine-adapter"; +import { + toCyclePredictionDTO, + goalAllowsFertileWindow, +} from "@/lib/cycle/dto"; +import { persistPredictionCache } from "@/lib/cycle/prediction-cache"; +import { addDays } from "@/lib/cycle/day-math"; +import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; + +const DEFAULT_PAST_DAYS = 90; +const DEFAULT_FORWARD_DAYS = 180; +/** Hard cap on the rendered span (days) to bound the day-grid build. */ +const MAX_SPAN_DAYS = 400; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + const profile = gate.profile; + + const url = new URL(request.url); + const parsed = cycleCalendarQuerySchema.safeParse({ + from: url.searchParams.get("from") ?? undefined, + to: url.searchParams.get("to") ?? undefined, + }); + if (!parsed.success) { + return apiError("Invalid calendar query", 422, { + errorCode: "cycle.calendar.invalid", + }); + } + + const tz = user.timezone ?? DEFAULT_TIMEZONE; + const today = moodDateKey(new Date(), tz); + const from = parsed.data.from ?? addDays(today, -DEFAULT_PAST_DAYS); + const to = parsed.data.to ?? addDays(today, DEFAULT_FORWARD_DAYS); + + // Reject an inverted or absurdly wide range up front. + const fromMs = Date.parse(`${from}T12:00:00Z`); + const toMs = Date.parse(`${to}T12:00:00Z`); + if (toMs < fromMs || (toMs - fromMs) / 86_400_000 > MAX_SPAN_DAYS) { + return apiError("Calendar range too wide or inverted", 422, { + errorCode: "cycle.calendar.range", + }); + } + + const [cycles, dayLogRows, nightlyTemps] = await Promise.all([ + prisma.menstrualCycle.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { startDate: "asc" }, + }), + prisma.cycleDayLog.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { date: "asc" }, + select: { + date: true, + flow: true, + basalBodyTempC: true, + ovulationTest: true, + cervicalMucus: true, + _count: { select: { symptomLinks: true } }, + }, + }), + // Apple Watch wrist/skin temperature feeds the temperature-trend + // ovulation layer. Read the WRIST_TEMPERATURE measurements as nightly + // values; the engine derives the trailing-mean deviation itself. + prisma.measurement.findMany({ + where: { + userId: user.id, + deletedAt: null, + type: "WRIST_TEMPERATURE", + measuredAt: { + gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)), + }, + }, + orderBy: { measuredAt: "asc" }, + select: { measuredAt: true, value: true }, + }), + ]); + + const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => ({ + date: l.date, + flow: l.flow, + basalBodyTempC: l.basalBodyTempC, + ovulationTest: l.ovulationTest, + cervicalMucus: l.cervicalMucus, + hasSymptoms: l._count.symptomLinks > 0, + })); + + const nights = nightlyTemps.map((m) => ({ + date: moodDateKey(m.measuredAt, tz), + valueC: m.value, + })); + + const goalAllowsFertile = goalAllowsFertileWindow(profile.goal); + + const { prediction, days } = buildCalendar( + profile, + cycles, + dayLogs, + nights, + from, + to, + today, + goalAllowsFertile, + ); + + // Persist the cache fire-and-forget (debounced) — never block the read. + let generatedAt = new Date().toISOString(); + if (prediction) { + const now = new Date(); + generatedAt = now.toISOString(); + void persistPredictionCache(user.id, prediction, now); + } + + const locale = await resolveServerLocale({ + request, + userLocale: user.locale ?? undefined, + }); + const t = getServerTranslator(locale); + const disclaimer = t.t("cycle.prediction.disclaimer"); + + annotate({ + action: { name: "cycle.calendar.read" }, + meta: { + days: days.length, + cycles_observed: prediction?.cyclesObserved ?? 0, + has_prediction: prediction !== null, + }, + }); + + return apiSuccess({ + profile: { + goal: profile.goal, + rawChartMode: profile.rawChartMode, + predictionEnabled: profile.predictionEnabled, + cyclesObserved: prediction?.cyclesObserved ?? cycles.length, + }, + prediction: prediction + ? toCyclePredictionDTO(prediction, goalAllowsFertile, disclaimer) + : null, + days, + meta: { generatedAt }, + }); +}); diff --git a/src/app/api/cycle/cycles/[id]/route.ts b/src/app/api/cycle/cycles/[id]/route.ts new file mode 100644 index 000000000..2810bc90e --- /dev/null +++ b/src/app/api/cycle/cycles/[id]/route.ts @@ -0,0 +1,55 @@ +/** + * `DELETE /api/cycle/cycles/{id}` — soft-delete a cycle + * (ios-contract §2.F): set `deletedAt` + bump `syncVersion`, emit a + * tombstone on the next sync page. 204. Idempotent. Owner-scoped + gated. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiError, getClientIp } from "@/lib/api-response"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const { id } = await params; + + const existing = await prisma.menstrualCycle.findUnique({ + where: { id }, + select: { id: true, userId: true }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Cycle not found", 404); + } + + await prisma.menstrualCycle.update({ + where: { id }, + data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, + }); + + await auditLog("cycle.cycle.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { cycleId: id }, + }); + + annotate({ + action: { + name: "cycle.cycle.delete", + entity_type: "menstrual_cycle", + entity_id: id, + }, + }); + + return new Response(null, { status: 204 }); + }, +); diff --git a/src/app/api/cycle/cycles/route.ts b/src/app/api/cycle/cycles/route.ts new file mode 100644 index 000000000..d714c010b --- /dev/null +++ b/src/app/api/cycle/cycles/route.ts @@ -0,0 +1,99 @@ +/** + * `GET /api/cycle/cycles?limit=24` — cycle history + summary stats + * (ios-contract §2.E). + * + * Returns the most recent observed cycles (newest first) plus + * `{ avgLengthDays, lengthVariabilityDays (MAD), avgPeriodLengthDays, + * regularity }`. The median / MAD reuse the engine helpers so the stats + * match the prediction's own variability math. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { cycleHistoryQuerySchema } from "@/lib/validations/cycle"; +import { toMenstrualCycleDTO } from "@/lib/cycle/dto"; +import { median, mad } from "@/lib/cycle"; +import { dayDiff } from "@/lib/cycle/day-math"; +import { MIN_CYCLES_TO_PREDICT } from "@/lib/cycle/types"; + +const DEFAULT_LIMIT = 24; + +/** + * Regularity gate. < MIN_CYCLES → LEARNING; a length MAD at/under 2 days + * (the clinical "regular cycle" rule of thumb) → REGULAR, else IRREGULAR. + */ +const REGULAR_MAD_THRESHOLD = 2; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const url = new URL(request.url); + const parsed = cycleHistoryQuerySchema.safeParse({ + limit: url.searchParams.get("limit") ?? undefined, + }); + if (!parsed.success) { + return apiError("Invalid history query", 422, { + errorCode: "cycle.cycles.invalid", + }); + } + const limit = parsed.data.limit ?? DEFAULT_LIMIT; + + const rows = await prisma.menstrualCycle.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { startDate: "desc" }, + take: limit, + }); + + // Completed lengths (non-null lengthDays) drive the length stats. + const lengths = rows + .map((r) => r.lengthDays) + .filter((l): l is number => l != null && l > 0); + + // Period lengths from periodEndDate − startDate + 1. + const periodLengths = rows + .filter((r) => r.periodEndDate != null) + .map((r) => dayDiff(r.periodEndDate as string, r.startDate) + 1) + .filter((l) => l > 0); + + let avgLengthDays: number | null = null; + let lengthVariabilityDays: number | null = null; + let avgPeriodLengthDays: number | null = null; + let regularity: "REGULAR" | "IRREGULAR" | "LEARNING" = "LEARNING"; + + if (lengths.length > 0) { + const med = median(lengths); + avgLengthDays = Math.round(med); + lengthVariabilityDays = Math.round(mad(lengths, med) * 10) / 10; + if (lengths.length < MIN_CYCLES_TO_PREDICT) { + regularity = "LEARNING"; + } else { + regularity = + lengthVariabilityDays <= REGULAR_MAD_THRESHOLD ? "REGULAR" : "IRREGULAR"; + } + } + if (periodLengths.length > 0) { + avgPeriodLengthDays = Math.round(median(periodLengths)); + } + + annotate({ + action: { name: "cycle.cycles.read" }, + meta: { returned: rows.length, regularity }, + }); + + return apiSuccess({ + cycles: rows.map(toMenstrualCycleDTO), + stats: { + avgLengthDays, + lengthVariabilityDays, + avgPeriodLengthDays, + regularity, + }, + }); +}); diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts new file mode 100644 index 000000000..362d039b0 --- /dev/null +++ b/src/app/api/cycle/day-logs/[id]/route.ts @@ -0,0 +1,192 @@ +/** + * `PATCH /api/cycle/day-logs/{id}` — edit a single day-log + * (ios-contract §2.A). + * `DELETE /api/cycle/day-logs/{id}` — soft-delete (ios-contract §2.F): + * set `deletedAt` + bump `syncVersion`, emit a tombstone on the next + * sync page. 204. Idempotent. + * + * Both are gated (`cycle.disabled` 403) and owner-scoped (a row owned by + * another user 404s). + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { encrypt } from "@/lib/crypto"; +import { cycleDayLogPatchSchema } from "@/lib/validations/cycle"; +import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const { id } = await params; + + const existing = await prisma.cycleDayLog.findFirst({ + where: { id, deletedAt: null }, + select: { id: true, userId: true }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Day-log not found", 404); + } + + const { data: rawBody, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + const parsed = cycleDayLogPatchSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "cycle.day-log.patch.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "cycle.day-log.invalid", + }); + } + + const body = parsed.data; + + // Field-by-field update (no mass assignment). Every field is + // optional; an omitted field is left untouched. `note` re-encrypts; + // an explicit null clears it. + await prisma.cycleDayLog.update({ + where: { id }, + data: { + ...(body.flow !== undefined && { flow: body.flow }), + ...(body.intermenstrualBleeding !== undefined && { + intermenstrualBleeding: body.intermenstrualBleeding, + }), + ...(body.basalBodyTempC !== undefined && { + basalBodyTempC: body.basalBodyTempC, + }), + ...(body.ovulationTest !== undefined && { + ovulationTest: body.ovulationTest, + }), + ...(body.cervicalMucus !== undefined && { + cervicalMucus: body.cervicalMucus, + }), + ...(body.sexualActivity !== undefined && { + sexualActivity: body.sexualActivity, + }), + ...(body.protectedSex !== undefined && { + protectedSex: body.protectedSex, + }), + ...(body.pregnancyTest !== undefined && { + pregnancyTest: body.pregnancyTest, + }), + ...(body.progesteroneTest !== undefined && { + progesteroneTest: body.progesteroneTest, + }), + ...(body.contraceptive !== undefined && { + contraceptive: body.contraceptive, + }), + ...(body.note !== undefined && { + notesEncrypted: body.note ? encrypt(body.note) : null, + }), + syncVersion: { increment: 1 }, + }, + }); + + // Replace symptom links only when `symptoms` was supplied. + if (body.symptoms !== undefined) { + const keys = Array.from(new Set(body.symptoms.map((s) => s.key))); + const symptoms = + keys.length > 0 + ? await prisma.cycleSymptom.findMany({ + where: { + key: { in: keys }, + isActive: true, + OR: [{ userId: null }, { userId: user.id }], + }, + select: { id: true }, + }) + : []; + await prisma.cycleSymptomLink.deleteMany({ where: { dayLogId: id } }); + if (symptoms.length > 0) { + await prisma.cycleSymptomLink.createMany({ + data: symptoms.map((s) => ({ dayLogId: id, symptomId: s.id })), + skipDuplicates: true, + }); + } + } + + const row = await prisma.cycleDayLog.findUniqueOrThrow({ + where: { id }, + include: dayLogSymptomInclude, + }); + + await auditLog("cycle.day-log.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { dayLogId: id }, + }); + + annotate({ + action: { + name: "cycle.day-log.update", + entity_type: "cycle_day_log", + entity_id: id, + }, + }); + + return apiSuccess(toCycleDayLogDTO(row)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const { id } = await params; + + const existing = await prisma.cycleDayLog.findUnique({ + where: { id }, + select: { id: true, userId: true }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Day-log not found", 404); + } + + // Soft-delete: leave the row in place so the `/api/sync/changes` + // delta feed surfaces it as a tombstone. A re-delete re-bumps + // `syncVersion` harmlessly (idempotent). + await prisma.cycleDayLog.update({ + where: { id }, + data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, + }); + + await auditLog("cycle.day-log.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { dayLogId: id }, + }); + + annotate({ + action: { + name: "cycle.day-log.delete", + entity_type: "cycle_day_log", + entity_id: id, + }, + }); + + return new Response(null, { status: 204 }); + }, +); diff --git a/src/app/api/cycle/day-logs/bulk/route.ts b/src/app/api/cycle/day-logs/bulk/route.ts new file mode 100644 index 000000000..27272e80c --- /dev/null +++ b/src/app/api/cycle/day-logs/bulk/route.ts @@ -0,0 +1,169 @@ +/** + * `POST /api/cycle/day-logs/bulk` — Outbox / HealthKit drain + * (ios-contract §2.B / §3). + * + * The route iOS's Outbox flushes to. Per-entry UPSERT keyed on the + * NULL-distinct `(userId, source, externalId)` (or `(userId, date)` + * when no externalId). Capped at 500, wrapped in `withIdempotency`, + * rate-limited `cycle:day-logs:bulk:{userId}` 60/min. Always 200; each + * entry carries `{ index, status, id?, externalId?, reason? }` where + * status is `inserted` | `duplicate` | `updated` | `skipped`. `updated` + * = an externalId re-post that changed a field; `duplicate` = an + * unchanged re-post. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + apiError, + getClientIp, + returnAllZodIssues, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; +import { withIdempotency } from "@/lib/idempotency"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { cycleBulkSchema, MAX_CYCLE_BULK_ENTRIES } from "@/lib/validations/cycle"; +import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; +import { findOwningCycleId } from "@/lib/cycle/cycle-attribution"; +import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; + +const BATCH_RATE_LIMIT_MAX = 60; +const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000; + +type EntryStatus = "inserted" | "duplicate" | "updated" | "skipped"; +interface EntryResult { + index: number; + status: EntryStatus; + id?: string; + externalId?: string; + reason?: string; +} + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk)); + +async function postBulk(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `cycle:day-logs:bulk:${user.id}`, + BATCH_RATE_LIMIT_MAX, + BATCH_RATE_LIMIT_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many bulk submissions, try again later", 429); + } + + const { data: rawBody, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + if ( + typeof rawBody === "object" && + rawBody !== null && + "entries" in rawBody && + Array.isArray((rawBody as { entries: unknown }).entries) && + (rawBody as { entries: unknown[] }).entries.length > MAX_CYCLE_BULK_ENTRIES + ) { + return apiError( + `Batch exceeds the ${MAX_CYCLE_BULK_ENTRIES}-entry limit`, + 422, + { errorCode: "cycle.bulk.too_large" }, + ); + } + + const parsed = cycleBulkSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "cycle.bulk.validation-failed" }, + meta: { issue_count: sanitiseZodIssues(parsed.error.issues).length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "cycle.bulk.invalid", + }); + } + + const { entries } = parsed.data; + const tz = user.timezone ?? DEFAULT_TIMEZONE; + + const results: EntryResult[] = []; + let inserted = 0; + let updated = 0; + let duplicates = 0; + let skipped = 0; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + try { + const cycleId = await findOwningCycleId(user.id, entry.date); + const r = await upsertCycleDayLog(user.id, entry, tz, cycleId); + + let status: EntryStatus; + if (!r.existed) { + status = "inserted"; + inserted += 1; + } else if (r.changed) { + status = "updated"; + updated += 1; + } else { + status = "duplicate"; + duplicates += 1; + } + + results.push({ + index: i, + status, + id: r.id, + ...(entry.externalId ? { externalId: entry.externalId } : {}), + }); + } catch (err: unknown) { + const reason = + err instanceof Error ? err.message.slice(0, 120) : "upsert_failed"; + skipped += 1; + results.push({ + index: i, + status: "skipped", + reason, + ...(entry.externalId ? { externalId: entry.externalId } : {}), + }); + } + } + + await auditLog("cycle.bulk.ingest", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + processed: entries.length, + inserted, + updated, + duplicates, + skipped, + }, + }); + + annotate({ + action: { name: "cycle.bulk.ingest" }, + meta: { + processed: entries.length, + inserted, + updated, + duplicates, + skipped, + }, + }); + + return apiSuccess({ + processed: entries.length, + inserted, + updated, + duplicates, + skipped, + entries: results, + }); +} diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts new file mode 100644 index 000000000..1490559ac --- /dev/null +++ b/src/app/api/cycle/day-logs/route.ts @@ -0,0 +1,83 @@ +/** + * `POST /api/cycle/day-logs` — single cycle day-log capture + * (ios-contract §2.A). + * + * Upserts on `(userId, source, externalId)` when an `externalId` is + * present (the cross-device dedup key), else the canonical `(userId, + * date)` key. `note` is encrypted at rest (`notesEncrypted`); the intent + * fields stay queryable plaintext (they feed the rollup / correlation + * tier). Returns the full `CycleDayLogDTO`: 201 on insert, 200 on update. + * + * Gated: a disabled / non-FEMALE-without-opt-in account 403s with + * `errorCode:"cycle.disabled"` even with a valid Bearer token. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { withIdempotency } from "@/lib/idempotency"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { cycleDayLogInputSchema } from "@/lib/validations/cycle"; +import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; +import { findOwningCycleId } from "@/lib/cycle/cycle-attribution"; +import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto"; +import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postDayLog)); + +async function postDayLog(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const { data: rawBody, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + const parsed = cycleDayLogInputSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "cycle.day-log.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "cycle.day-log.invalid", + }); + } + + const entry = parsed.data; + const tz = user.timezone ?? DEFAULT_TIMEZONE; + + const cycleId = await findOwningCycleId(user.id, entry.date); + const result = await upsertCycleDayLog(user.id, entry, tz, cycleId); + + const row = await prisma.cycleDayLog.findUniqueOrThrow({ + where: { id: result.id }, + include: dayLogSymptomInclude, + }); + + await auditLog("cycle.day-log.upsert", { + userId: user.id, + ipAddress: getClientIp(request), + details: { dayLogId: result.id, existed: result.existed }, + }); + + annotate({ + action: { + name: "cycle.day-log.upsert", + entity_type: "cycle_day_log", + entity_id: result.id, + }, + meta: { existed: result.existed, source: entry.source }, + }); + + return apiSuccess(toCycleDayLogDTO(row), result.existed ? 200 : 201); +} diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts new file mode 100644 index 000000000..cc866aa62 --- /dev/null +++ b/src/app/api/cycle/period/route.ts @@ -0,0 +1,151 @@ +/** + * `POST /api/cycle/period` — one-tap period-boundary shortcut + * (ios-contract §2.C). + * + * `action:"start"` opens a new `MenstrualCycle` anchored at `date`, + * closes the prior open cycle (endDate = day before, lengthDays set), + * and writes the boundary `CycleDayLog(flow=MEDIUM)`. `action:"end"` + * stamps the current cycle's `periodEndDate` and writes the boundary + * day-log. Convenience over §2.A; returns + * `{ cycle: MenstrualCycleDTO, dayLog: CycleDayLogDTO }`. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { cyclePeriodSchema } from "@/lib/validations/cycle"; +import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; +import { + toCycleDayLogDTO, + toMenstrualCycleDTO, + dayLogSymptomInclude, +} from "@/lib/cycle/dto"; +import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { addDays, dayDiff } from "@/lib/cycle/day-math"; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const { data: rawBody, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + const parsed = cyclePeriodSchema.safeParse(rawBody); + if (!parsed.success) { + annotate({ + action: { name: "cycle.period.validation-failed" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "cycle.period.invalid", + }); + } + + const { action, date, externalId, loggedAt } = parsed.data; + const tz = user.timezone ?? DEFAULT_TIMEZONE; + + let cycleId: string; + + if (action === "start") { + // Close the prior open cycle (its end is the day before this start) + // and record its observed length. + const prior = await prisma.menstrualCycle.findFirst({ + where: { userId: user.id, deletedAt: null, startDate: { lt: date } }, + orderBy: { startDate: "desc" }, + select: { id: true, startDate: true }, + }); + if (prior) { + await prisma.menstrualCycle.update({ + where: { id: prior.id }, + data: { + endDate: addDays(date, -1), + lengthDays: dayDiff(date, prior.startDate), + syncVersion: { increment: 1 }, + }, + }); + } + + // Upsert the new cycle on the `(userId, startDate)` unique so a + // re-tap on the same day is idempotent. + const cycle = await prisma.menstrualCycle.upsert({ + where: { userId_startDate: { userId: user.id, startDate: date } }, + create: { userId: user.id, startDate: date, tz, isPredicted: false }, + update: { deletedAt: null, isPredicted: false, syncVersion: { increment: 1 } }, + }); + cycleId = cycle.id; + } else { + // `end`: stamp the current cycle's periodEndDate. + const current = await prisma.menstrualCycle.findFirst({ + where: { userId: user.id, deletedAt: null, startDate: { lte: date } }, + orderBy: { startDate: "desc" }, + select: { id: true }, + }); + if (!current) { + return apiSuccessNoCycle(); + } + await prisma.menstrualCycle.update({ + where: { id: current.id }, + data: { periodEndDate: date, syncVersion: { increment: 1 } }, + }); + cycleId = current.id; + } + + // Boundary day-log. Start → MEDIUM flow opens the bleed; end → SPOTTING + // tail (the last logged bleeding day). + await upsertCycleDayLog( + user.id, + { + date, + flow: action === "start" ? "MEDIUM" : "SPOTTING", + loggedAt, + source: "MANUAL", + ...(externalId ? { externalId } : {}), + }, + tz, + cycleId, + ); + + const [cycleRow, dayLogRow] = await Promise.all([ + prisma.menstrualCycle.findUniqueOrThrow({ where: { id: cycleId } }), + prisma.cycleDayLog.findFirstOrThrow({ + where: { userId: user.id, date, deletedAt: null }, + include: dayLogSymptomInclude, + }), + ]); + + await auditLog("cycle.period.boundary", { + userId: user.id, + ipAddress: getClientIp(request), + details: { action, date, cycleId }, + }); + + annotate({ + action: { + name: "cycle.period.boundary", + entity_type: "menstrual_cycle", + entity_id: cycleId, + }, + meta: { boundary: action }, + }); + + return apiSuccess({ + cycle: toMenstrualCycleDTO(cycleRow), + dayLog: toCycleDayLogDTO(dayLogRow), + }); +}); + +/** `end` with no preceding cycle is a no-op the client should not hit. */ +function apiSuccessNoCycle(): Response { + return apiSuccess({ cycle: null, dayLog: null }); +} diff --git a/src/app/api/cycle/profile/route.ts b/src/app/api/cycle/profile/route.ts new file mode 100644 index 000000000..051d916d0 --- /dev/null +++ b/src/app/api/cycle/profile/route.ts @@ -0,0 +1,27 @@ +/** + * `GET /api/cycle/profile` — full CycleProfileDTO (ios-contract §2.G). + * + * Gated like every cycle route; returns the lazily-upserted profile with + * the resolved `cycleTrackingEnabled`. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess } from "@/lib/api-response"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { isCycleEnabled } from "@/lib/cycle/gate"; +import { toCycleProfileDTO } from "@/lib/cycle/dto"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const resolved = isCycleEnabled(user.gender, gate.profile); + + annotate({ action: { name: "cycle.profile.read" } }); + + return apiSuccess(toCycleProfileDTO(gate.profile, resolved)); +}); diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts index 952b8ab56..6a6d62e1b 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -8,7 +8,9 @@ * domain now soft-delete (set `deletedAt` + bump `syncVersion`) rather * than hard-delete. * - * Domains served: `measurements`, `mood`, `intakes`. The iOS consumer + * Domains served: `measurements`, `mood`, `intakes`, `cycleDays`, + * `cycles` (the last two added v1.15.0; `cycleDays` tombstones key on + * `externalId` when present else `id`, `cycles` on `id`). The iOS consumer * is measurements-only this cycle (iOS-coord * `v1.7.0-ios-offline-sync-answers.md` §7.1); mood + intakes are * forward-prep so the contract is complete + tested before the @@ -57,6 +59,13 @@ import { type SyncCursor, type SyncDomain, } from "@/lib/sync/cursor"; +import { + toCycleDayLogDTO, + toMenstrualCycleDTO, + dayLogSymptomInclude, + type CycleDayLogDTO, + type MenstrualCycleDTO, +} from "@/lib/cycle/dto"; const DEFAULT_LIMIT = 200; const MAX_LIMIT = 500; @@ -126,6 +135,25 @@ interface IntakeTombstone { updatedAt: string; } +// v1.15.0 — cycle domains. cycleDays key tombstones on `externalId` when +// present (HealthKit-origin rows are externalId-keyed cross-device, like +// measurements) else the server `id`; cycles key on the server `id` +// (computed rows carry no externalId). +interface CycleDayTombstone { + id: string; + externalId: string | null; + syncVersion: number; + deletedAt: string; + updatedAt: string; +} + +interface CycleTombstone { + id: string; + syncVersion: number; + deletedAt: string; + updatedAt: string; +} + /** * Build the `(updatedAt, id)` keyset predicate for a domain's watermark. * Empty object (full scan) when the client has no watermark for it yet. @@ -196,6 +224,8 @@ export const GET = apiHandler(async (request: NextRequest) => { measurements: { upserts: [], tombstones: [] }, mood: { upserts: [], tombstones: [] }, intakes: { upserts: [], tombstones: [] }, + cycleDays: { upserts: [], tombstones: [] }, + cycles: { upserts: [], tombstones: [] }, }, }); } @@ -204,7 +234,8 @@ export const GET = apiHandler(async (request: NextRequest) => { // same scan — a soft-delete bumps `updatedAt`, so a tombstone is just a // row whose `deletedAt` is non-null. Fetch limit+1 to detect `hasMore` // without a count query. - const [measurementRows, moodRows, intakeRows] = await Promise.all([ + const [measurementRows, moodRows, intakeRows, cycleDayRows, cycleRows] = + await Promise.all([ prisma.measurement.findMany({ where: { userId: user.id, ...keysetFilter(cursor.measurements) }, orderBy: [{ updatedAt: "asc" }, { id: "asc" }], @@ -257,6 +288,17 @@ export const GET = apiHandler(async (request: NextRequest) => { updatedAt: true, }, }), + prisma.cycleDayLog.findMany({ + where: { userId: user.id, ...keysetFilter(cursor.cycleDays) }, + orderBy: [{ updatedAt: "asc" }, { id: "asc" }], + take: limit + 1, + include: dayLogSymptomInclude, + }), + prisma.menstrualCycle.findMany({ + where: { userId: user.id, ...keysetFilter(cursor.cycles) }, + orderBy: [{ updatedAt: "asc" }, { id: "asc" }], + take: limit + 1, + }), ]); const nextCursor: SyncCursor = { ...cursor }; @@ -354,6 +396,49 @@ export const GET = apiHandler(async (request: NextRequest) => { advanceCursor(nextCursor, "intakes", intakePage); hasMore = hasMore || intakeHasMore; + // ── cycleDays ───────────────────────────────────────────── + const cycleDayHasMore = cycleDayRows.length > limit; + const cycleDayPage = cycleDayHasMore + ? cycleDayRows.slice(0, limit) + : cycleDayRows; + const cycleDayUpserts: CycleDayLogDTO[] = []; + const cycleDayTombstones: CycleDayTombstone[] = []; + for (const row of cycleDayPage) { + if (row.deletedAt) { + cycleDayTombstones.push({ + id: row.id, + externalId: row.externalId, + syncVersion: row.syncVersion, + deletedAt: row.deletedAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }); + } else { + cycleDayUpserts.push(toCycleDayLogDTO(row)); + } + } + advanceCursor(nextCursor, "cycleDays", cycleDayPage); + hasMore = hasMore || cycleDayHasMore; + + // ── cycles ──────────────────────────────────────────────── + const cycleHasMore = cycleRows.length > limit; + const cyclePage = cycleHasMore ? cycleRows.slice(0, limit) : cycleRows; + const cycleUpserts: MenstrualCycleDTO[] = []; + const cycleTombstones: CycleTombstone[] = []; + for (const row of cyclePage) { + if (row.deletedAt) { + cycleTombstones.push({ + id: row.id, + syncVersion: row.syncVersion, + deletedAt: row.deletedAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }); + } else { + cycleUpserts.push(toMenstrualCycleDTO(row)); + } + } + advanceCursor(nextCursor, "cycles", cyclePage); + hasMore = hasMore || cycleHasMore; + annotate({ action: { name: "sync.changes.pull" }, meta: { @@ -363,6 +448,10 @@ export const GET = apiHandler(async (request: NextRequest) => { mood_tombstones: moodTombstones.length, intake_upserts: intakeUpserts.length, intake_tombstones: intakeTombstones.length, + cycle_day_upserts: cycleDayUpserts.length, + cycle_day_tombstones: cycleDayTombstones.length, + cycle_upserts: cycleUpserts.length, + cycle_tombstones: cycleTombstones.length, has_more: hasMore, cursor_present: Boolean(parsed.data.cursor), }, @@ -380,6 +469,11 @@ export const GET = apiHandler(async (request: NextRequest) => { }, mood: { upserts: moodUpserts, tombstones: moodTombstones }, intakes: { upserts: intakeUpserts, tombstones: intakeTombstones }, + cycleDays: { + upserts: cycleDayUpserts, + tombstones: cycleDayTombstones, + }, + cycles: { upserts: cycleUpserts, tombstones: cycleTombstones }, }, }); }); diff --git a/src/lib/cycle/__tests__/engine-adapter.test.ts b/src/lib/cycle/__tests__/engine-adapter.test.ts new file mode 100644 index 000000000..900a3031d --- /dev/null +++ b/src/lib/cycle/__tests__/engine-adapter.test.ts @@ -0,0 +1,126 @@ +/** + * v1.15.0 — calendar composition over the deterministic engine. + */ +import { describe, it, expect } from "vitest"; + +import { buildCalendar, type CalendarDayLogRow } from "../engine-adapter"; +import type { CycleProfile, MenstrualCycle } from "@/generated/prisma/client"; + +function profile(overrides: Partial = {}): CycleProfile { + return { + id: "p1", + userId: "u1", + goal: "GENERAL_HEALTH", + cycleTrackingEnabled: true, + typicalCycleLength: null, + typicalPeriodLength: null, + lutealPhaseLength: null, + predictionEnabled: true, + rawChartMode: false, + discreetNotifications: false, + sensitiveCategoryEncryption: true, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as CycleProfile; +} + +function cycle(startDate: string, overrides: Partial = {}): MenstrualCycle { + return { + id: `c-${startDate}`, + userId: "u1", + startDate, + endDate: null, + periodEndDate: null, + lengthDays: null, + ovulationDate: null, + ovulationConfirmed: false, + isPredicted: false, + tz: null, + syncVersion: 0, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as MenstrualCycle; +} + +describe("buildCalendar", () => { + // Three ~28-day cycles → a real prediction. + const cycles = [ + cycle("2026-01-01", { endDate: "2026-01-28", lengthDays: 28, periodEndDate: "2026-01-05" }), + cycle("2026-01-29", { endDate: "2026-02-25", lengthDays: 28, periodEndDate: "2026-02-02" }), + cycle("2026-02-26", { periodEndDate: "2026-03-02" }), + ]; + + it("labels logged-flow days and emits a forecast for ≥2 cycles", () => { + const dayLogs: CalendarDayLogRow[] = [ + { date: "2026-02-26", flow: "MEDIUM", basalBodyTempC: null, ovulationTest: null, cervicalMucus: null, hasSymptoms: true }, + ]; + const { prediction, days } = buildCalendar( + profile(), + cycles, + dayLogs, + [], + "2026-02-20", + "2026-04-15", + "2026-03-10", + false, + ); + expect(prediction).not.toBeNull(); + expect(prediction!.cyclesObserved).toBeGreaterThanOrEqual(2); + + const logged = days.find((d) => d.date === "2026-02-26")!; + expect(logged.isPeriodLogged).toBe(true); + expect(logged.flow).toBe("MEDIUM"); + expect(logged.hasSymptoms).toBe(true); + + // The forecast next-period start falls inside a predicted-period day. + const predStart = prediction!.nextPeriodStart; + const predDay = days.find((d) => d.date === predStart); + expect(predDay?.isPredictedPeriod).toBe(true); + }); + + it("suppresses the fertile window at the grid when the goal disallows it", () => { + const { days } = buildCalendar( + profile({ goal: "GENERAL_HEALTH" }), + cycles, + [], + [], + "2026-02-20", + "2026-04-15", + "2026-03-10", + false, + ); + expect(days.every((d) => !d.isFertileWindow)).toBe(true); + expect(days.every((d) => !d.isPredictedOvulation)).toBe(true); + }); + + it("surfaces the fertile window for the conception goal", () => { + const { days } = buildCalendar( + profile({ goal: "TRYING_TO_CONCEIVE" }), + cycles, + [], + [], + "2026-02-20", + "2026-04-15", + "2026-03-10", + true, + ); + expect(days.some((d) => d.isFertileWindow)).toBe(true); + }); + + it("emits no prediction in raw-chart mode", () => { + const { prediction } = buildCalendar( + profile({ rawChartMode: true }), + cycles, + [], + [], + "2026-02-20", + "2026-04-15", + "2026-03-10", + false, + ); + expect(prediction).toBeNull(); + }); +}); diff --git a/src/lib/cycle/__tests__/gate.test.ts b/src/lib/cycle/__tests__/gate.test.ts new file mode 100644 index 000000000..df6dd2266 --- /dev/null +++ b/src/lib/cycle/__tests__/gate.test.ts @@ -0,0 +1,41 @@ +/** + * v1.15.0 — cycle-tracking feature gate resolver. + */ +import { describe, it, expect } from "vitest"; + +import { isCycleEnabled, CYCLE_DISABLED_ERROR_CODE } from "../gate"; + +describe("isCycleEnabled", () => { + it("derives from gender when the toggle is null", () => { + expect(isCycleEnabled("FEMALE", { cycleTrackingEnabled: null })).toBe(true); + expect(isCycleEnabled("MALE", { cycleTrackingEnabled: null })).toBe(false); + expect(isCycleEnabled(null, { cycleTrackingEnabled: null })).toBe(false); + }); + + it("derives from gender when no profile row exists", () => { + expect(isCycleEnabled("FEMALE", null)).toBe(true); + expect(isCycleEnabled("MALE", undefined)).toBe(false); + expect(isCycleEnabled(null, null)).toBe(false); + }); + + it("an explicit true opts a non-FEMALE account in", () => { + expect(isCycleEnabled("MALE", { cycleTrackingEnabled: true })).toBe(true); + expect(isCycleEnabled(null, { cycleTrackingEnabled: true })).toBe(true); + }); + + it("an explicit false opts a FEMALE account out", () => { + expect(isCycleEnabled("FEMALE", { cycleTrackingEnabled: false })).toBe( + false, + ); + }); + + it("gates on UPPERCASE FEMALE only (server enum spelling)", () => { + // Lowercase / other spellings never enable by derivation. + expect(isCycleEnabled("female", { cycleTrackingEnabled: null })).toBe(false); + expect(isCycleEnabled("OTHER", { cycleTrackingEnabled: null })).toBe(false); + }); + + it("exports the wire errorCode the iOS retry classifier branches on", () => { + expect(CYCLE_DISABLED_ERROR_CODE).toBe("cycle.disabled"); + }); +}); diff --git a/src/lib/cycle/cycle-attribution.ts b/src/lib/cycle/cycle-attribution.ts new file mode 100644 index 000000000..17f91763b --- /dev/null +++ b/src/lib/cycle/cycle-attribution.ts @@ -0,0 +1,24 @@ +/** + * Attribute a day-log date to its owning `MenstrualCycle` span. + * + * A day-log belongs to the cycle whose `startDate <= date` and which is + * the latest such start (the cycle is open-ended until the next cycle's + * start). Returns the cycle id or null when no cycle precedes the date. + */ +import { prisma } from "@/lib/db"; + +export async function findOwningCycleId( + userId: string, + date: string, +): Promise { + const cycle = await prisma.menstrualCycle.findFirst({ + where: { + userId, + deletedAt: null, + startDate: { lte: date }, + }, + orderBy: { startDate: "desc" }, + select: { id: true }, + }); + return cycle?.id ?? null; +} diff --git a/src/lib/cycle/day-log-write.ts b/src/lib/cycle/day-log-write.ts new file mode 100644 index 000000000..c02c1c01c --- /dev/null +++ b/src/lib/cycle/day-log-write.ts @@ -0,0 +1,171 @@ +/** + * Cycle day-log write helper — the shared upsert behind the single POST, + * the PATCH, and the bulk drain (ios-contract §2.A / §2.B). + * + * UPSERT key: `(userId, source, externalId)` when `externalId` is present + * (the NULL-distinct cross-device dedup key, like MoodEntry), else the + * canonical `(userId, date)` key. `note` → `notesEncrypted` (AES-256-GCM); + * every other field is queryable plaintext. Symptom keys resolve against + * the seeded catalog (unknown keys dropped silently) and write the + * `CycleSymptomLink` join. + * + * Returns the upserted row id + whether a row already existed (so the + * caller can map `inserted`/`updated`/`duplicate`) plus whether any field + * actually changed on a re-post (drives the bulk `updated` vs `duplicate` + * distinction). + */ +import { prisma } from "@/lib/db"; +import { encrypt } from "@/lib/crypto"; +import type { CycleDayLogInput } from "@/lib/validations/cycle"; + +export interface DayLogWriteResult { + id: string; + existed: boolean; + /** A pre-existing row whose persisted fields differ from the re-post. */ + changed: boolean; +} + +/** Resolve catalog symptom keys → ids, dropping unknown keys silently. */ +async function resolveSymptomIds( + userId: string, + keys: readonly string[], +): Promise { + if (keys.length === 0) return []; + const unique = Array.from(new Set(keys)); + // Catalog rows (userId null) plus this user's own custom symptoms. + const rows = await prisma.cycleSymptom.findMany({ + where: { + key: { in: unique }, + isActive: true, + OR: [{ userId: null }, { userId }], + }, + select: { id: true }, + }); + return rows.map((r) => r.id); +} + +/** + * Upsert one cycle day-log. `tz` anchors the date string. When + * `cycleId` is supplied the row is attributed to that cycle span. + */ +export async function upsertCycleDayLog( + userId: string, + entry: CycleDayLogInput, + tz: string | null, + cycleId: string | null = null, +): Promise { + const source = entry.source; + const notesEncrypted = entry.note ? encrypt(entry.note) : null; + + const where = entry.externalId + ? { + userId_source_externalId: { + userId, + source, + externalId: entry.externalId, + }, + } + : { userId_date: { userId, date: entry.date } }; + + // Probe so the caller can distinguish inserted / updated / duplicate + // and (for an externalId re-post) detect whether a field changed. + const existing = await prisma.cycleDayLog.findUnique({ + where, + select: { + id: true, + date: true, + flow: true, + intermenstrualBleeding: true, + basalBodyTempC: true, + ovulationTest: true, + cervicalMucus: true, + sexualActivity: true, + protectedSex: true, + pregnancyTest: true, + progesteroneTest: true, + contraceptive: true, + notesEncrypted: true, + deletedAt: true, + }, + }); + + // Field-by-field create data (no mass assignment). + const baseData = { + flow: entry.flow ?? null, + intermenstrualBleeding: entry.intermenstrualBleeding ?? false, + basalBodyTempC: entry.basalBodyTempC ?? null, + ovulationTest: entry.ovulationTest ?? null, + cervicalMucus: entry.cervicalMucus ?? null, + sexualActivity: entry.sexualActivity ?? false, + protectedSex: entry.protectedSex ?? null, + pregnancyTest: entry.pregnancyTest ?? null, + progesteroneTest: entry.progesteroneTest ?? null, + contraceptive: entry.contraceptive ?? null, + notesEncrypted, + }; + + // Last-writer-wins on the field set. An externalId re-post also refreshes + // `date` (a re-zoned re-import lands the corrected wall-clock). A + // soft-deleted row is resurrected on a fresh write (deletedAt cleared). + const updateData = { + ...baseData, + ...(entry.externalId ? { date: entry.date } : {}), + ...(cycleId !== null ? { cycleId } : {}), + deletedAt: null, + syncVersion: { increment: 1 }, + }; + + const row = await prisma.cycleDayLog.upsert({ + where, + create: { + userId, + date: entry.date, + tz, + source, + externalId: entry.externalId ?? null, + cycleId, + ...baseData, + }, + update: updateData, + select: { id: true }, + }); + + // Replace the symptom-link set when symptoms were supplied. Absent + // `symptoms` leaves existing links untouched (a partial day-log edit + // that doesn't mention symptoms must not wipe them). + if (entry.symptoms !== undefined) { + const ids = await resolveSymptomIds( + userId, + entry.symptoms.map((s) => s.key), + ); + await prisma.cycleSymptomLink.deleteMany({ where: { dayLogId: row.id } }); + if (ids.length > 0) { + await prisma.cycleSymptomLink.createMany({ + data: ids.map((symptomId) => ({ dayLogId: row.id, symptomId })), + skipDuplicates: true, + }); + } + } + + // `changed` only matters for the bulk `updated` vs `duplicate` split on + // an existing row: did any persisted field differ from the re-post? + let changed = false; + if (existing) { + changed = + existing.flow !== baseData.flow || + existing.intermenstrualBleeding !== baseData.intermenstrualBleeding || + existing.basalBodyTempC !== baseData.basalBodyTempC || + existing.ovulationTest !== baseData.ovulationTest || + existing.cervicalMucus !== baseData.cervicalMucus || + existing.sexualActivity !== baseData.sexualActivity || + existing.protectedSex !== baseData.protectedSex || + existing.pregnancyTest !== baseData.pregnancyTest || + existing.progesteroneTest !== baseData.progesteroneTest || + existing.contraceptive !== baseData.contraceptive || + existing.notesEncrypted !== baseData.notesEncrypted || + existing.deletedAt !== null || + (Boolean(entry.externalId) && existing.date !== entry.date); + } + + return { id: row.id, existed: existing !== null, changed }; +} diff --git a/src/lib/cycle/dto.ts b/src/lib/cycle/dto.ts new file mode 100644 index 000000000..a5c651695 --- /dev/null +++ b/src/lib/cycle/dto.ts @@ -0,0 +1,217 @@ +/** + * Cycle row → wire-DTO mappers (ios-contract §2 + §3). + * + * The canonical `CycleDayLogDTO` and `MenstrualCycleDTO` shapes the iOS + * client mirrors. `notesEncrypted` is decrypted fail-soft on read (a + * key-rotation gap on one row reads `null`, never 500s the whole page). + * Symptom links are flattened to `[{ key, severity }]` using the catalog + * key (severity is not yet persisted per-link in v1.15.0 — the join is a + * presence link; severity rides the input but the catalog link carries + * presence only, so it reads back `null`). + */ +import { decrypt } from "@/lib/crypto"; +import type { CyclePredictionResult } from "@/lib/cycle/types"; +import type { + CycleDayLog, + CycleSymptomLink, + CycleSymptom, + MenstrualCycle, +} from "@/generated/prisma/client"; + +export interface CycleSymptomDTO { + key: string; + severity: number | null; +} + +export interface CycleDayLogDTO { + id: string; + date: string; + cycleId: string | null; + flow: string | null; + intermenstrualBleeding: boolean; + basalBodyTempC: number | null; + ovulationTest: string | null; + cervicalMucus: string | null; + sexualActivity: boolean; + protectedSex: boolean | null; + pregnancyTest: string | null; + progesteroneTest: string | null; + contraceptive: string | null; + symptoms: CycleSymptomDTO[]; + note: string | null; + source: string; + externalId: string | null; + syncVersion: number; + updatedAt: string; + deletedAt: string | null; +} + +export interface MenstrualCycleDTO { + id: string; + startDate: string; + endDate: string | null; + periodEndDate: string | null; + lengthDays: number | null; + ovulationDate: string | null; + ovulationConfirmed: boolean; + isPredicted: boolean; + syncVersion: number; + updatedAt: string; +} + +type DayLogWithLinks = CycleDayLog & { + symptomLinks?: (CycleSymptomLink & { symptom: Pick })[]; +}; + +/** Decrypt a day-log note fail-soft (null on missing / undecryptable). */ +function decryptNote(notesEncrypted: string | null): string | null { + if (!notesEncrypted) return null; + try { + return decrypt(notesEncrypted); + } catch { + return null; + } +} + +export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO { + return { + id: row.id, + date: row.date, + cycleId: row.cycleId, + flow: row.flow, + intermenstrualBleeding: row.intermenstrualBleeding, + basalBodyTempC: row.basalBodyTempC, + ovulationTest: row.ovulationTest, + cervicalMucus: row.cervicalMucus, + sexualActivity: row.sexualActivity, + protectedSex: row.protectedSex, + pregnancyTest: row.pregnancyTest, + progesteroneTest: row.progesteroneTest, + contraceptive: row.contraceptive, + symptoms: (row.symptomLinks ?? []).map((l) => ({ + key: l.symptom.key, + severity: null, + })), + note: decryptNote(row.notesEncrypted), + source: row.source, + externalId: row.externalId, + syncVersion: row.syncVersion, + updatedAt: row.updatedAt.toISOString(), + deletedAt: row.deletedAt ? row.deletedAt.toISOString() : null, + }; +} + +export function toMenstrualCycleDTO(row: MenstrualCycle): MenstrualCycleDTO { + return { + id: row.id, + startDate: row.startDate, + endDate: row.endDate, + periodEndDate: row.periodEndDate, + lengthDays: row.lengthDays, + ovulationDate: row.ovulationDate, + ovulationConfirmed: row.ovulationConfirmed, + isPredicted: row.isPredicted, + syncVersion: row.syncVersion, + updatedAt: row.updatedAt.toISOString(), + }; +} + +/** The Prisma `include` that hydrates symptom links → DTO keys. */ +export const dayLogSymptomInclude = { + symptomLinks: { include: { symptom: { select: { key: true } } } }, +} as const; + +export interface CyclePredictionDTO { + method: string; + nextPeriodStart: string; + nextPeriodStartLow: string; + nextPeriodStartHigh: string; + fertileWindowStart: string | null; + fertileWindowEnd: string | null; + predictedOvulation: string | null; + confidence: number; + cyclesObserved: number; + stillLearning: boolean; + disclaimer: string; +} + +/** + * Map the engine result to the wire `CyclePredictionDTO`. Fertile-window + * fields (and the predicted ovulation) are suppressed unless the goal + * permits them (ios-contract §2.D — GENERAL_HEALTH / PERIMENOPAUSE / OFF + * never surface a fertile window; the suppression is server-side, never + * iOS). + */ +export function toCyclePredictionDTO( + result: CyclePredictionResult, + goalAllowsFertile: boolean, + disclaimer: string, +): CyclePredictionDTO { + return { + method: result.method, + nextPeriodStart: result.nextPeriodStart, + nextPeriodStartLow: result.nextPeriodStartLow, + nextPeriodStartHigh: result.nextPeriodStartHigh, + fertileWindowStart: goalAllowsFertile ? result.fertileWindowStart : null, + fertileWindowEnd: goalAllowsFertile ? result.fertileWindowEnd : null, + predictedOvulation: goalAllowsFertile ? result.predictedOvulation : null, + confidence: result.confidence, + cyclesObserved: result.cyclesObserved, + stillLearning: result.stillLearning, + disclaimer, + }; +} + +/** The full profile read DTO (ios-contract §2.G). */ +export interface CycleProfileDTO { + goal: string; + cycleTrackingEnabled: boolean; + rawChartMode: boolean; + predictionEnabled: boolean; + discreetNotifications: boolean; + sensitiveCategoryEncryption: boolean; + typicalCycleLength: number | null; + typicalPeriodLength: number | null; + lutealPhaseLength: number | null; + updatedAt: string; +} + +export function toCycleProfileDTO( + row: MenstrualCycleProfileRow, + cycleTrackingEnabled: boolean, +): CycleProfileDTO { + return { + goal: row.goal, + cycleTrackingEnabled, + rawChartMode: row.rawChartMode, + predictionEnabled: row.predictionEnabled, + discreetNotifications: row.discreetNotifications, + sensitiveCategoryEncryption: row.sensitiveCategoryEncryption, + typicalCycleLength: row.typicalCycleLength, + typicalPeriodLength: row.typicalPeriodLength, + lutealPhaseLength: row.lutealPhaseLength, + updatedAt: row.updatedAt.toISOString(), + }; +} + +type MenstrualCycleProfileRow = { + goal: string; + rawChartMode: boolean; + predictionEnabled: boolean; + discreetNotifications: boolean; + sensitiveCategoryEncryption: boolean; + typicalCycleLength: number | null; + typicalPeriodLength: number | null; + lutealPhaseLength: number | null; + updatedAt: Date; +}; + +/** + * Whether a goal surfaces the fertile window. Per the v1.15 contract the + * window is gated to the conception goal only — GENERAL_HEALTH / + * AVOID_PREGNANCY / PERIMENOPAUSE / OFF suppress it (the suppression is + * always server-side, never iOS). + */ +export function goalAllowsFertileWindow(goal: string): boolean { + return goal === "TRYING_TO_CONCEIVE"; +} diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts new file mode 100644 index 000000000..94b471388 --- /dev/null +++ b/src/lib/cycle/engine-adapter.ts @@ -0,0 +1,233 @@ +/** + * Prisma-row → engine-input adapters + calendar composition + * (ios-contract §2.D). The engine itself (`predictCycle`, `phaseSeries`) + * is pure + DB-free; this module bridges the persisted rows to the plain + * input shapes and assembles the `CalendarDayDTO` grid the read returns. + */ +import { + predictCycle, + phaseForDay, + addDays, + dayDiff, + isWithin, + type CycleInput, + type DayLogInput, + type NightlyTempInput, + type CycleProfileInput, + type CyclePredictionResult, + type CyclePhase, + type PhaseCycle, + LUTEAL_DEFAULT, +} from "@/lib/cycle"; +import type { + CycleProfile, + CycleDayLog, + MenstrualCycle, +} from "@/generated/prisma/client"; + +export interface CalendarDayDTO { + date: string; + phase: CyclePhase | null; + isPredictedPeriod: boolean; + isFertileWindow: boolean; + isPredictedOvulation: boolean; + isPeriodLogged: boolean; + flow: string | null; + hasSymptoms: boolean; + confidence: number; +} + +/** Rows the calendar needs from each day-log. */ +export type CalendarDayLogRow = Pick< + CycleDayLog, + "date" | "flow" | "basalBodyTempC" | "ovulationTest" | "cervicalMucus" +> & { hasSymptoms: boolean }; + +/** Map MenstrualCycle rows (oldest→newest) to engine `CycleInput`. */ +export function toCycleInputs( + cycles: readonly Pick< + MenstrualCycle, + "startDate" | "endDate" | "periodEndDate" | "ovulationDate" | "ovulationConfirmed" + >[], +): CycleInput[] { + return cycles.map((c) => ({ + startDate: c.startDate, + endDate: c.endDate, + periodEndDate: c.periodEndDate, + ovulationDate: c.ovulationDate, + ovulationConfirmed: c.ovulationConfirmed, + })); +} + +/** Map CycleDayLog rows to engine `DayLogInput`. */ +export function toDayLogInputs( + logs: readonly Pick< + CycleDayLog, + "date" | "flow" | "basalBodyTempC" | "ovulationTest" | "cervicalMucus" + >[], +): DayLogInput[] { + return logs.map((l) => ({ + date: l.date, + flow: l.flow, + basalBodyTempC: l.basalBodyTempC, + ovulationTest: l.ovulationTest, + cervicalMucus: l.cervicalMucus, + })); +} + +/** Map a CycleProfile row to the engine's `CycleProfileInput`. */ +export function toProfileInput(profile: CycleProfile): CycleProfileInput { + return { + goal: profile.goal, + typicalCycleLength: profile.typicalCycleLength, + typicalPeriodLength: profile.typicalPeriodLength, + lutealPhaseLength: profile.lutealPhaseLength, + predictionEnabled: profile.predictionEnabled, + rawChartMode: profile.rawChartMode, + }; +} + +/** Period-length of an observed cycle: periodEnd − start + 1, or default. */ +function periodLengthOf( + cycle: Pick, +): number | null { + if (!cycle.periodEndDate) return null; + return dayDiff(cycle.periodEndDate, cycle.startDate) + 1; +} + +/** + * Build a contiguous `PhaseCycle[]` from the observed cycles (each cycle's + * span ends where the next begins; the latest open cycle runs to the + * predicted next-period start). Used to label every calendar day's phase. + */ +function buildPhaseCycles( + cycles: readonly Pick< + MenstrualCycle, + "startDate" | "endDate" | "periodEndDate" | "ovulationDate" + >[], + predictedNextStart: string | null, + lutealLength: number, +): PhaseCycle[] { + const sorted = [...cycles].sort((a, b) => dayDiff(a.startDate, b.startDate)); + const out: PhaseCycle[] = []; + for (let i = 0; i < sorted.length; i++) { + const c = sorted[i]; + const next = sorted[i + 1]; + const nextStart = + next?.startDate ?? predictedNextStart ?? addDays(c.startDate, 28); + out.push({ + startDate: c.startDate, + nextStart, + ovulationDate: c.ovulationDate, + periodLength: periodLengthOf(c), + lutealLength, + }); + } + return out; +} + +/** Phase of a single date across the contiguous cycle list (or null). */ +function phaseAcross(date: string, phaseCycles: readonly PhaseCycle[]): CyclePhase | null { + for (const pc of phaseCycles) { + const r = phaseForDay(date, pc); + if (r.phase !== null) return r.phase; + } + return null; +} + +export interface CalendarBuildResult { + prediction: CyclePredictionResult | null; + days: CalendarDayDTO[]; +} + +/** + * Compose the calendar grid for `[from, to]` inclusive. Runs the engine + * once for the forward forecast, then labels each day's phase, predicted + * period bar, fertile window, and logged-flow overlay. + * + * Fertile-window fields are caller-gated: pass `goalAllowsFertile=false` + * (GENERAL_HEALTH / PERIMENOPAUSE) to suppress `isFertileWindow` + + * `isPredictedOvulation` at the grid level (the prediction's own window + * fields are nulled upstream by the engine for those goals). + */ +export function buildCalendar( + profile: CycleProfile, + cycles: readonly MenstrualCycle[], + dayLogs: readonly CalendarDayLogRow[], + nights: readonly NightlyTempInput[], + from: string, + to: string, + today: string, + goalAllowsFertile: boolean, +): CalendarBuildResult { + const lutealLength = profile.lutealPhaseLength ?? LUTEAL_DEFAULT; + + // Forecast (only when prediction is on + not raw-chart mode). + let prediction: CyclePredictionResult | null = null; + if (profile.predictionEnabled && !profile.rawChartMode) { + prediction = predictCycle( + toCycleInputs(cycles), + toDayLogInputs(dayLogs), + toProfileInput(profile), + today, + nights, + ); + } + + const phaseCycles = buildPhaseCycles( + cycles, + prediction?.nextPeriodStart ?? null, + lutealLength, + ); + + // Forward predicted period bar: [nextPeriodStart, +predictedPeriodLength). + const predictedPeriodStart = prediction?.nextPeriodStart ?? null; + const predictedPeriodEnd = + prediction && predictedPeriodStart + ? addDays( + predictedPeriodStart, + Math.max(0, prediction.predictedPeriodLength - 1), + ) + : null; + + const logByDate = new Map(); + for (const l of dayLogs) logByDate.set(l.date, l); + + const days: CalendarDayDTO[] = []; + const span = dayDiff(to, from); + for (let i = 0; i <= span; i++) { + const date = addDays(from, i); + const log = logByDate.get(date); + + const isPredictedPeriod = + predictedPeriodStart !== null && + predictedPeriodEnd !== null && + isWithin(date, predictedPeriodStart, predictedPeriodEnd); + + const isFertileWindow = + goalAllowsFertile && + prediction?.fertileWindowStart != null && + prediction.fertileWindowEnd != null && + isWithin(date, prediction.fertileWindowStart, prediction.fertileWindowEnd); + + const isPredictedOvulation = + goalAllowsFertile && + prediction?.predictedOvulation != null && + prediction.predictedOvulation === date; + + days.push({ + date, + phase: phaseAcross(date, phaseCycles), + isPredictedPeriod, + isFertileWindow, + isPredictedOvulation, + isPeriodLogged: + log?.flow != null && log.flow !== "NONE", + flow: log?.flow ?? null, + hasSymptoms: log?.hasSymptoms ?? false, + confidence: prediction?.confidence ?? 0, + }); + } + + return { prediction, days }; +} diff --git a/src/lib/cycle/gate.ts b/src/lib/cycle/gate.ts new file mode 100644 index 000000000..8f4ce1062 --- /dev/null +++ b/src/lib/cycle/gate.ts @@ -0,0 +1,72 @@ +/** + * Cycle-tracking feature gate. + * + * Two gates collapse into one resolved boolean (ios-contract §1): + * (a) the per-user `CycleProfile.cycleTrackingEnabled` toggle, and + * (b) a gender-derived default when that toggle is NULL. + * + * `isCycleEnabled` is the single source of truth. The server gender enum + * ships UPPERCASE (`"MALE" | "FEMALE" | null`); the gate keys on + * `"FEMALE"`. A NULL toggle derives `gender === "FEMALE"`; an explicit + * `true`/`false` overrides the derivation (so a non-FEMALE account can + * opt in and a FEMALE account can opt out). + * + * NOTE: the gender enum normalisation (adding `OTHER` / lowercasing) is a + * separate, iOS-blocked decision (migration 0131, reserved). Until that + * lands, `OTHER` / non-binary accounts gate purely on the explicit + * `cycleTrackingEnabled = true` opt-in via the Settings toggle. + */ +import { apiError } from "@/lib/api-response"; +import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; +import type { CycleProfile } from "@/generated/prisma/client"; + +/** Wire `errorCode` the iOS retry classifier branches on. */ +export const CYCLE_DISABLED_ERROR_CODE = "cycle.disabled"; + +/** + * Resolve whether cycle tracking is enabled for an account. + * + * @param gender the user's `gender` column (`"MALE" | "FEMALE" | null`). + * @param profile the user's `CycleProfile`, or just its + * `cycleTrackingEnabled` field (NULL ⇒ derive from gender). + */ +export function isCycleEnabled( + gender: string | null | undefined, + profile: { cycleTrackingEnabled: boolean | null } | null | undefined, +): boolean { + const toggle = profile?.cycleTrackingEnabled; + if (toggle === true) return true; + if (toggle === false) return false; + // NULL toggle → derive from gender. + return gender === "FEMALE"; +} + +/** + * Route-guard outcome: either the resolved profile (enabled) or a ready + * 403 envelope (disabled) to return verbatim. + */ +export type CycleGateResult = + | { enabled: true; profile: CycleProfile } + | { enabled: false; response: Response }; + +/** + * Enforce the gate for a `/api/cycle/*` route. Lazily upserts the + * `CycleProfile` (so the toggle row exists), resolves `isCycleEnabled`, + * and returns a 403 `cycle.disabled` envelope when the account may not + * write/read cycle data — even with a valid Bearer token. + */ +export async function requireCycleEnabled( + userId: string, + gender: string | null | undefined, +): Promise { + const profile = await getOrCreateCycleProfile(userId); + if (!isCycleEnabled(gender, profile)) { + return { + enabled: false, + response: apiError("Cycle tracking is not enabled", 403, { + errorCode: CYCLE_DISABLED_ERROR_CODE, + }), + }; + } + return { enabled: true, profile }; +} diff --git a/src/lib/cycle/prediction-cache.ts b/src/lib/cycle/prediction-cache.ts new file mode 100644 index 000000000..4ad546003 --- /dev/null +++ b/src/lib/cycle/prediction-cache.ts @@ -0,0 +1,80 @@ +/** + * Cycle prediction cache — debounced stale-while-revalidate (the v1.8.7 + * insight-assessment cache pattern, ios-contract §2.D). + * + * `GET /api/cycle/calendar` runs the pure engine synchronously (it is + * cheap) for the live forecast it returns, AND persists that forecast to + * the `CyclePrediction` cache row so the Coach snapshot + notifications + * read a stable materialised value without re-running the engine. The + * persist is debounced: a re-write only lands when the prior row is older + * than the debounce window OR the forecast actually changed, so a tight + * read loop never thrashes the row. + */ +import { prisma } from "@/lib/db"; +import type { CyclePredictionResult } from "@/lib/cycle/types"; + +/** Don't re-persist an unchanged forecast more often than this. */ +const PREDICTION_DEBOUNCE_MS = 6 * 60 * 60 * 1000; // 6h + +/** + * Persist (or refresh) the cached forecast for a user, debounced. A + * no-op when a fresh-enough identical row already exists. Best-effort: + * never throws into the read path. + */ +export async function persistPredictionCache( + userId: string, + result: CyclePredictionResult, + now: Date = new Date(), +): Promise { + try { + const existing = await prisma.cyclePrediction.findUnique({ + where: { userId }, + select: { + method: true, + nextPeriodStart: true, + nextPeriodStartLow: true, + nextPeriodStartHigh: true, + confidence: true, + cyclesObserved: true, + generatedAt: true, + }, + }); + + const unchanged = + existing !== null && + existing.method === result.method && + existing.nextPeriodStart === result.nextPeriodStart && + existing.nextPeriodStartLow === result.nextPeriodStartLow && + existing.nextPeriodStartHigh === result.nextPeriodStartHigh && + existing.confidence === result.confidence && + existing.cyclesObserved === result.cyclesObserved; + + const fresh = + existing !== null && + now.getTime() - existing.generatedAt.getTime() < PREDICTION_DEBOUNCE_MS; + + // Skip the write only when the row is BOTH unchanged AND fresh. + if (unchanged && fresh) return; + + const data = { + method: result.method, + nextPeriodStart: result.nextPeriodStart, + nextPeriodStartLow: result.nextPeriodStartLow, + nextPeriodStartHigh: result.nextPeriodStartHigh, + fertileWindowStart: result.fertileWindowStart, + fertileWindowEnd: result.fertileWindowEnd, + predictedOvulation: result.predictedOvulation, + confidence: result.confidence, + cyclesObserved: result.cyclesObserved, + generatedAt: now, + }; + + await prisma.cyclePrediction.upsert({ + where: { userId }, + create: { userId, ...data }, + update: data, + }); + } catch { + /* best-effort cache write — the live engine result is authoritative */ + } +} diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index 3ce0fab6a..77cebceb3 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -67,6 +67,7 @@ const openApiBase: Pick< { name: "Devices" }, { name: "Export" }, { name: "Sync" }, + { name: "Cycle" }, { name: "Admin" }, { name: "Meta" }, ], diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index ac8bd49a2..4914a9c18 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -54,6 +54,18 @@ import { VITALS_BASELINE_TYPES, } from "@/lib/insights/derived/registry"; import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta"; +import { + flowLevelEnum, + ovulationTestEnum, + cervicalMucusEnum, + homeTestResultEnum, + cycleTrackingGoalEnum, + cycleDayLogInputSchema, + cycleDayLogPatchSchema, + cycleBulkSchema, + cyclePeriodSchema, + cyclePrefsSchema, +} from "@/lib/validations/cycle"; /** * Common envelopes — every HealthLog API response wraps payload in @@ -2148,9 +2160,477 @@ const coachFactDeletedResponse = z.object({ ), }); +// ── 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(), + ovulationTest: ovulationTestEnum.nullable(), + cervicalMucus: cervicalMucusEnum.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(), + confidence: z.number(), + cyclesObserved: z.number().int(), + stillLearning: z.boolean(), + disclaimer: z.string(), + }) + .meta({ + id: "CyclePredictionDTO", + description: + "The materialised forecast. Fertile-window fields are server-suppressed (null) unless the goal is TRYING_TO_CONCEIVE.", + }); + +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(), +}); + +const cycleProfileDto = z + .object({ + goal: cycleTrackingGoalEnum, + cycleTrackingEnabled: z.boolean(), + 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(), + 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 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; + // ── Path table ─────────────────────────────────────────────────────── export const openApiPaths: NonNullable = { + "/api/cycle/day-logs": { + 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:` 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/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/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, + }, + }, + }, "/api/meta/capabilities": { get: { tags: ["Meta"], diff --git a/src/lib/sync/__tests__/cursor.test.ts b/src/lib/sync/__tests__/cursor.test.ts index e4020537e..d8faff9bc 100644 --- a/src/lib/sync/__tests__/cursor.test.ts +++ b/src/lib/sync/__tests__/cursor.test.ts @@ -42,21 +42,36 @@ describe("sync cursor codec", () => { expect(decodeCursor("")).toBeNull(); // valid base64url but not the expected JSON shape expect(decodeCursor(Buffer.from("[]").toString("base64url"))).toBeNull(); - // wrong version + // wrong version — v1.15.0 bumped CURSOR_VERSION to 2, so a v1 token + // (the pre-cycle format) now decodes to null → clean re-init. expect( - decodeCursor(Buffer.from('{"v":2,"d":{}}').toString("base64url")), + decodeCursor(Buffer.from('{"v":1,"d":{}}').toString("base64url")), ).toBeNull(); // missing the `d` envelope expect( - decodeCursor(Buffer.from('{"v":1}').toString("base64url")), + decodeCursor(Buffer.from('{"v":2}').toString("base64url")), ).toBeNull(); }); + it("decodes a current-version empty envelope to a fresh-scan map", () => { + expect( + decodeCursor(Buffer.from('{"v":2,"d":{}}').toString("base64url")), + ).toEqual({}); + }); + + it("round-trips the v1.15.0 cycle domains", () => { + const cursor: SyncCursor = { + cycleDays: { updatedAtMs: 1_717_001_000_000, id: "clx0cday" }, + cycles: { updatedAtMs: 1_717_001_500_000, id: "clx0cyc" }, + }; + expect(decodeCursor(encodeCursor(cursor))).toEqual(cursor); + }); + it("drops a domain whose watermark is malformed but keeps valid ones", () => { // measurements has a string `u` (invalid); mood is well-formed. const token = Buffer.from( JSON.stringify({ - v: 1, + v: 2, d: { measurements: { u: "x", i: "z" }, mood: { u: 5, i: "clx0mood" }, diff --git a/src/lib/sync/cursor.ts b/src/lib/sync/cursor.ts index bcf1925a6..b74d40152 100644 --- a/src/lib/sync/cursor.ts +++ b/src/lib/sync/cursor.ts @@ -20,12 +20,19 @@ */ /** The sync domains the feed serves. */ -export type SyncDomain = "measurements" | "mood" | "intakes"; +export type SyncDomain = + | "measurements" + | "mood" + | "intakes" + | "cycleDays" + | "cycles"; export const SYNC_DOMAINS: readonly SyncDomain[] = [ "measurements", "mood", "intakes", + "cycleDays", + "cycles", ]; /** A single domain's keyset position. */ @@ -42,7 +49,11 @@ export interface DomainWatermark { */ export type SyncCursor = Partial>; -const CURSOR_VERSION = 1; +// v1.15.0 — bumped to 2 when the `cycleDays` + `cycles` domains were +// added. An in-flight v1 token decodes to null (wrong version) → clean +// re-init, which is acceptable because the cursor is opaque and the +// client re-syncs from scratch. +const CURSOR_VERSION = 2; /** Encode a per-domain keyset map into an opaque token. */ export function encodeCursor(cursor: SyncCursor): string { diff --git a/src/lib/validations/cycle.ts b/src/lib/validations/cycle.ts new file mode 100644 index 000000000..e4f490f1b --- /dev/null +++ b/src/lib/validations/cycle.ts @@ -0,0 +1,185 @@ +/** + * 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"; + +/* ── 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", +]); + +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"); + +/* ── 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(), + ovulationTest: ovulationTestEnum.optional(), + cervicalMucus: cervicalMucusEnum.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: z.iso.datetime({ offset: true }), + source: cycleSourceEnum.optional().default("MANUAL"), + externalId: z.string().min(1).max(120).optional(), +}); + +export type CycleDayLogInput = z.infer; + +/** 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(), + ovulationTest: ovulationTestEnum.nullable().optional(), + cervicalMucus: cervicalMucusEnum.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: z.iso.datetime({ offset: true }).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: z.iso.datetime({ offset: true }), +}); + +/* ── 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(), + 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(), + lutealPhaseLength: z.number().int().min(8).max(20).nullable().optional(), + }) + .refine((v) => Object.keys(v).length > 0, { + message: "At least one preference field is required", + }); + +export type CyclePrefsInput = z.infer; diff --git a/tests/integration/cycle-routes.test.ts b/tests/integration/cycle-routes.test.ts new file mode 100644 index 000000000..d7855fe4d --- /dev/null +++ b/tests/integration/cycle-routes.test.ts @@ -0,0 +1,362 @@ +/** + * Integration suite for the v1.15.0 cycle-tracking routes against a real + * Postgres in a testcontainer. Asserts the iOS-facing contract end-to-end: + * + * - the feature gate (`cycle.disabled` 403) keys on gender + the + * `cycleTrackingEnabled` toggle, even with a valid session + * - single day-log capture upserts (201 insert → 200 update), encrypts + * the note, and reads it back decrypted + * - the bulk drain returns per-entry inserted / updated / duplicate + * - the period shortcut opens a cycle + boundary day-log + * - the calendar read runs the engine + goal-gates the fertile window + * - the cycle-prefs PATCH flips the gate for a non-FEMALE account + * - `/api/sync/changes` carries the cycleDays + cycles domains incl. + * tombstones after a soft-delete + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-cycle-routes-integration-32-bytes-min-1234567890"; +// Crypto-at-rest needs a key for the note encrypt/decrypt round-trip. +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const FEMALE_USER_ID = "user-cycle-female"; +const MALE_USER_ID = "user-cycle-male"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +async function loginAs(userId: string): Promise { + cookieJar.clear(); + headerJar.clear(); + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: FEMALE_USER_ID, + username: "cycle-female", + email: "cycle-female@example.test", + gender: "FEMALE", + }, + }); + await prisma.user.create({ + data: { + id: MALE_USER_ID, + username: "cycle-male", + email: "cycle-male@example.test", + gender: "MALE", + }, + }); +}); + +function jsonRequest( + path: string, + method: string, + body?: unknown, + opts: { idempotencyKey?: string } = {}, +): NextRequest { + const headers: Record = { + "content-type": "application/json", + }; + if (opts.idempotencyKey) headers["idempotency-key"] = opts.idempotencyKey; + return new NextRequest(`http://localhost${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +describe("cycle routes — feature gate", () => { + it("403s a MALE account on every cycle route", async () => { + await loginAs(MALE_USER_ID); + const { POST } = await import("@/app/api/cycle/day-logs/route"); + const res = await POST( + jsonRequest("/api/cycle/day-logs", "POST", { + date: "2026-03-01", + flow: "MEDIUM", + loggedAt: "2026-03-01T08:00:00.000Z", + }), + ); + expect(res.status).toBe(403); + const json = await res.json(); + expect(json.meta?.errorCode).toBe("cycle.disabled"); + }); + + it("admits a FEMALE account by gender derivation", async () => { + await loginAs(FEMALE_USER_ID); + const { POST } = await import("@/app/api/cycle/day-logs/route"); + const res = await POST( + jsonRequest("/api/cycle/day-logs", "POST", { + date: "2026-03-01", + flow: "MEDIUM", + loggedAt: "2026-03-01T08:00:00.000Z", + }), + ); + expect(res.status).toBe(201); + }); +}); + +describe("cycle day-logs — single capture", () => { + it("upserts (201 insert → 200 update) and round-trips the encrypted note", async () => { + await loginAs(FEMALE_USER_ID); + const { POST } = await import("@/app/api/cycle/day-logs/route"); + + const insert = await POST( + jsonRequest("/api/cycle/day-logs", "POST", { + date: "2026-03-02", + flow: "LIGHT", + note: "tender, mild cramps", + loggedAt: "2026-03-02T08:00:00.000Z", + }), + ); + expect(insert.status).toBe(201); + const insertJson = await insert.json(); + expect(insertJson.data.flow).toBe("LIGHT"); + expect(insertJson.data.note).toBe("tender, mild cramps"); + + // The note column is ciphertext, not plaintext. + const row = await getPrismaClient().cycleDayLog.findFirstOrThrow({ + where: { userId: FEMALE_USER_ID, date: "2026-03-02" }, + }); + expect(row.notesEncrypted).not.toBeNull(); + expect(row.notesEncrypted).not.toContain("cramps"); + + const update = await POST( + jsonRequest("/api/cycle/day-logs", "POST", { + date: "2026-03-02", + flow: "HEAVY", + loggedAt: "2026-03-02T09:00:00.000Z", + }), + ); + expect(update.status).toBe(200); + const updateJson = await update.json(); + expect(updateJson.data.flow).toBe("HEAVY"); + expect(updateJson.data.id).toBe(insertJson.data.id); + }); +}); + +describe("cycle day-logs — bulk drain", () => { + it("returns per-entry inserted / updated / duplicate", async () => { + await loginAs(FEMALE_USER_ID); + const { POST } = await import("@/app/api/cycle/day-logs/bulk/route"); + + const first = await POST( + jsonRequest("/api/cycle/day-logs/bulk", "POST", { + entries: [ + { + date: "2026-03-05", + flow: "MEDIUM", + source: "APPLE_HEALTH", + externalId: "hk-aaa", + loggedAt: "2026-03-05T08:00:00.000Z", + }, + ], + }), + ); + expect(first.status).toBe(200); + const firstJson = await first.json(); + expect(firstJson.data.inserted).toBe(1); + expect(firstJson.data.entries[0].status).toBe("inserted"); + + // Re-post unchanged → duplicate; re-post changed → updated. + const second = await POST( + jsonRequest("/api/cycle/day-logs/bulk", "POST", { + entries: [ + { + date: "2026-03-05", + flow: "MEDIUM", + source: "APPLE_HEALTH", + externalId: "hk-aaa", + loggedAt: "2026-03-05T08:00:00.000Z", + }, + { + date: "2026-03-05", + flow: "HEAVY", + source: "APPLE_HEALTH", + externalId: "hk-aaa", + loggedAt: "2026-03-05T08:00:00.000Z", + }, + ], + }), + ); + const secondJson = await second.json(); + expect(secondJson.data.entries[0].status).toBe("duplicate"); + expect(secondJson.data.entries[1].status).toBe("updated"); + }); +}); + +describe("cycle period shortcut", () => { + it("opens a cycle + writes the boundary day-log", async () => { + await loginAs(FEMALE_USER_ID); + const { POST } = await import("@/app/api/cycle/period/route"); + const res = await POST( + jsonRequest("/api/cycle/period", "POST", { + action: "start", + date: "2026-03-10", + loggedAt: "2026-03-10T08:00:00.000Z", + }), + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.cycle.startDate).toBe("2026-03-10"); + expect(json.data.dayLog.flow).toBe("MEDIUM"); + }); +}); + +describe("cycle calendar", () => { + it("runs the engine and goal-gates the fertile window", async () => { + await loginAs(FEMALE_USER_ID); + const prisma = getPrismaClient(); + // Three ~28d cycles → a real prediction. + for (const [start, end, len] of [ + ["2026-01-01", "2026-01-28", 28], + ["2026-01-29", "2026-02-25", 28], + ] as const) { + await prisma.menstrualCycle.create({ + data: { + userId: FEMALE_USER_ID, + startDate: start, + endDate: end, + lengthDays: len, + periodEndDate: null, + }, + }); + } + await prisma.menstrualCycle.create({ + data: { userId: FEMALE_USER_ID, startDate: "2026-02-26" }, + }); + + const { GET } = await import("@/app/api/cycle/calendar/route"); + const res = await GET( + new NextRequest( + "http://localhost/api/cycle/calendar?from=2026-02-20&to=2026-04-15", + ), + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.prediction).not.toBeNull(); + expect(json.data.days.length).toBeGreaterThan(0); + // GENERAL_HEALTH default → fertile window suppressed. + expect(json.data.prediction.fertileWindowStart).toBeNull(); + + // The cache row is persisted fire-and-forget (the route never blocks + // the read on it), so poll briefly for it to land. + let cache = null; + for (let i = 0; i < 20 && cache === null; i++) { + cache = await prisma.cyclePrediction.findUnique({ + where: { userId: FEMALE_USER_ID }, + }); + if (cache === null) await new Promise((r) => setTimeout(r, 25)); + } + expect(cache).not.toBeNull(); + }); +}); + +describe("cycle-prefs gate flip", () => { + it("enables a MALE account via the prefs PATCH, then admits cycle routes", async () => { + await loginAs(MALE_USER_ID); + const { PATCH } = await import("@/app/api/auth/me/cycle-prefs/route"); + const patch = await PATCH( + jsonRequest("/api/auth/me/cycle-prefs", "PATCH", { enabled: true }), + ); + expect(patch.status).toBe(200); + const patchJson = await patch.json(); + expect(patchJson.data.cycleTrackingEnabled).toBe(true); + + // Re-login (fresh session) and confirm the gate now opens. + await loginAs(MALE_USER_ID); + const { POST } = await import("@/app/api/cycle/day-logs/route"); + const res = await POST( + jsonRequest("/api/cycle/day-logs", "POST", { + date: "2026-03-15", + flow: "LIGHT", + loggedAt: "2026-03-15T08:00:00.000Z", + }), + ); + expect(res.status).toBe(201); + }); +}); + +describe("sync/changes — cycle domains", () => { + it("carries cycleDays + cycles upserts and tombstones after a soft-delete", async () => { + await loginAs(FEMALE_USER_ID); + const prisma = getPrismaClient(); + + const cycle = await prisma.menstrualCycle.create({ + data: { userId: FEMALE_USER_ID, startDate: "2026-03-20" }, + }); + const dayLog = await prisma.cycleDayLog.create({ + data: { + userId: FEMALE_USER_ID, + date: "2026-03-20", + flow: "MEDIUM", + source: "APPLE_HEALTH", + externalId: "hk-sync-1", + }, + }); + + const { GET } = await import("@/app/api/sync/changes/route"); + const first = await GET( + new NextRequest("http://localhost/api/sync/changes"), + ); + const firstJson = await first.json(); + expect(firstJson.data.changes.cycleDays.upserts.length).toBe(1); + expect(firstJson.data.changes.cycles.upserts.length).toBe(1); + expect(firstJson.data.changes.cycleDays.upserts[0].externalId).toBe( + "hk-sync-1", + ); + + // Soft-delete both → next pull surfaces tombstones. + await prisma.cycleDayLog.update({ + where: { id: dayLog.id }, + data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, + }); + await prisma.menstrualCycle.update({ + where: { id: cycle.id }, + data: { deletedAt: new Date(), syncVersion: { increment: 1 } }, + }); + + const second = await GET( + new NextRequest("http://localhost/api/sync/changes"), + ); + const secondJson = await second.json(); + const dayTombs = secondJson.data.changes.cycleDays.tombstones; + const cycleTombs = secondJson.data.changes.cycles.tombstones; + expect(dayTombs.length).toBe(1); + expect(dayTombs[0].externalId).toBe("hk-sync-1"); + expect(cycleTombs.length).toBe(1); + expect(cycleTombs[0].id).toBe(cycle.id); + }); +});