From adec5bd045792f535e4929b9d6be357bf3b8fbc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 07:02:14 +0200 Subject: [PATCH] fix(cycle): carry every logged day entry on the calendar read The calendar grid carried flow, symptoms, BBT, the ovulation test and the mucus reading, and dropped the rest of the day log: intercourse, spotting, the pregnancy and progesterone tests, contraception and whether a note exists. The published contract also promised the three cervix signs while the route never sent them. The intent fields resolve through the same reader the day-log read uses, so an encrypted envelope reads the same on both, and the grid is served under the same cycle grant as the day log. Additive fields; the contract test pins the wire keys to the published schema. --- docs/api/openapi.yaml | 28 ++ .../calendar/__tests__/day-entries.test.ts | 259 ++++++++++++++++++ src/app/api/cycle/calendar/route.ts | 47 +++- .../cycle/__tests__/engine-adapter.test.ts | 6 + src/lib/cycle/dto.ts | 63 +++-- src/lib/cycle/engine-adapter.ts | 40 ++- src/lib/openapi/routes/cycle.ts | 19 +- 7 files changed, 431 insertions(+), 31 deletions(-) create mode 100644 src/app/api/cycle/calendar/__tests__/day-entries.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b407f67b8..6c8714466 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -30974,6 +30974,28 @@ components: - CLOSED - OPEN - type: "null" + intermenstrualBleeding: + type: boolean + description: Spotting or bleeding outside the period logged on this day. + sexualActivity: + type: boolean + description: Intercourse logged on this day. Resolved from the plaintext column or the encrypted envelope, exactly as + the day-log read resolves it, and served under the same cycle grant. + pregnancyTest: + anyOf: + - $ref: "#/components/schemas/HomeTestResult" + - type: "null" + progesteroneTest: + anyOf: + - $ref: "#/components/schemas/HomeTestResult" + - type: "null" + contraceptive: + anyOf: + - type: string + - type: "null" + hasNote: + type: boolean + description: Whether the day carries a note. The note text is only on the day-log read. required: - date - phase @@ -30994,6 +31016,12 @@ components: - cervixPosition - cervixFirmness - cervixOpening + - intermenstrualBleeding + - sexualActivity + - pregnancyTest + - progesteroneTest + - contraceptive + - hasNote additionalProperties: false meta: type: object diff --git a/src/app/api/cycle/calendar/__tests__/day-entries.test.ts b/src/app/api/cycle/calendar/__tests__/day-entries.test.ts new file mode 100644 index 000000000..48e25b400 --- /dev/null +++ b/src/app/api/cycle/calendar/__tests__/day-entries.test.ts @@ -0,0 +1,259 @@ +/** + * `GET /api/cycle/calendar` — what a person logged on a day reaches the grid. + * + * The calendar used to carry flow, symptoms, BBT, the ovulation test and the + * mucus reading, and nothing else from the day log. Intercourse, spotting, the + * pregnancy and progesterone tests, contraception, the cervix signs and the + * presence of a note were all dropped between the row and the wire, so a day + * with only one of those logged looked exactly like a day with nothing logged. + * + * Same harness as the verdict round trip: the real exported `GET`, the real + * builder, the real DTO mapping, the response read back as text. Two ends are + * pinned — what the route asks Prisma for (a field that is not selected never + * reaches the builder) and what comes back on the wire. + * + * The intent fields can sit in the encrypted envelope instead of the plaintext + * columns (`sensitiveCategoryEncryption`), so one case writes them there and + * expects the grid to read them just as the day-log read does. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +vi.mock("@/lib/db", () => ({ + prisma: { + menstrualCycle: { findMany: vi.fn() }, + cycleDayLog: { findMany: vi.fn() }, + measurement: { findMany: vi.fn() }, + cyclePrediction: { findUnique: vi.fn(), upsert: vi.fn() }, + auditLog: { create: vi.fn() }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/cycle/gate", () => ({ requireCycleEnabled: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { encrypt } from "@/lib/crypto"; +import { cycleCalendarDayDto } from "@/lib/openapi/routes/cycle"; + +function session() { + return { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER" as const, + gender: "FEMALE", + timezone: "UTC", + locale: "en", + }, + }; +} + +function profile() { + return { + userId: "user-1", + goal: "GENERAL_HEALTH", + typicalCycleLength: 28, + typicalPeriodLength: 5, + lutealPhaseLength: 14, + predictionEnabled: true, + rawChartMode: false, + secondarySymptom: "MUCUS", + cycleTrackingEnabled: true, + sensitiveCategoryEncryption: false, + }; +} + +/** A day-log row as Prisma returns it for the route's select. */ +function logRow(date: string, over: Record = {}) { + return { + date, + flow: null, + intermenstrualBleeding: false, + basalBodyTempC: null, + temperatureExcluded: false, + ovulationTest: null, + cervicalMucus: null, + cervixPosition: null, + cervixFirmness: null, + cervixOpening: null, + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + sensitiveEncrypted: null, + notesEncrypted: null, + _count: { symptomLinks: 0 }, + ...over, + }; +} + +async function callCalendar(): Promise[]> { + const res = await GET( + new NextRequest( + "http://localhost/api/cycle/calendar?from=2026-09-01&to=2026-09-30", + ), + ); + expect(res.status).toBe(200); + const body = JSON.parse(await res.text()); + return body.data.days as Record[]; +} + +function day(days: Record[], date: string) { + const found = days.find((d) => d.date === date); + if (!found) throw new Error(`no grid day ${date}`); + return found; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-25T12:00:00Z")); + vi.mocked(getSession).mockResolvedValue(session() as never); + vi.mocked(requireCycleEnabled).mockResolvedValue({ + enabled: true, + profile: profile(), + } as never); + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([ + { + id: "cyc-1", + userId: "user-1", + startDate: "2025-12-29", + endDate: null, + periodEndDate: null, + ovulationDate: null, + ovulationConfirmed: false, + isPredicted: false, + }, + ] as never); + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("GET /api/cycle/calendar carries every per-day entry", () => { + it("asks Prisma for the fields the grid now shows", async () => { + await callCalendar(); + const args = vi.mocked(prisma.cycleDayLog.findMany).mock.calls[0][0] as { + select: Record; + }; + expect(args.select).toMatchObject({ + intermenstrualBleeding: true, + sexualActivity: true, + pregnancyTest: true, + progesteroneTest: true, + contraceptive: true, + sensitiveEncrypted: true, + notesEncrypted: true, + cervixPosition: true, + cervixFirmness: true, + cervixOpening: true, + }); + }); + + it("marks a day with intercourse logged, and only that day", async () => { + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([ + logRow("2026-09-05", { sexualActivity: true }), + ] as never); + + const days = await callCalendar(); + expect(day(days, "2026-09-05").sexualActivity).toBe(true); + expect(day(days, "2026-09-04").sexualActivity).toBe(false); + expect(day(days, "2026-09-06").sexualActivity).toBe(false); + }); + + it("reads intercourse and the tests from the encrypted envelope", async () => { + // Encryption ON at write: the plaintext columns are NULL/false and the + // envelope carries the values. The grid must read what the day log reads. + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([ + logRow("2026-09-05", { + sexualActivity: false, + sensitiveEncrypted: encrypt( + JSON.stringify({ + sexualActivity: true, + protectedSex: true, + pregnancyTest: "NEGATIVE", + progesteroneTest: "POSITIVE", + contraceptive: "ORAL", + }), + ), + }), + ] as never); + + const d = day(await callCalendar(), "2026-09-05"); + expect(d.sexualActivity).toBe(true); + expect(d.pregnancyTest).toBe("NEGATIVE"); + expect(d.progesteroneTest).toBe("POSITIVE"); + expect(d.contraceptive).toBe("ORAL"); + }); + + it("an undecryptable envelope reads as nothing logged, never a 500", async () => { + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([ + logRow("2026-09-05", { sensitiveEncrypted: "v1:not-a-real-envelope" }), + ] as never); + + const d = day(await callCalendar(), "2026-09-05"); + expect(d.sexualActivity).toBe(false); + expect(d.pregnancyTest).toBeNull(); + }); + + it("carries spotting, the cervix signs and whether a note exists", async () => { + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([ + logRow("2026-09-10", { + intermenstrualBleeding: true, + cervixPosition: "HIGH", + cervixFirmness: "SOFT", + cervixOpening: "OPEN", + notesEncrypted: encrypt("a note"), + }), + ] as never); + + const days = await callCalendar(); + const d = day(days, "2026-09-10"); + expect(d.intermenstrualBleeding).toBe(true); + expect(d.cervixPosition).toBe("HIGH"); + expect(d.cervixFirmness).toBe("SOFT"); + expect(d.cervixOpening).toBe("OPEN"); + expect(d.hasNote).toBe(true); + // The note's text never rides the grid, only its presence. + expect(JSON.stringify(d)).not.toContain("a note"); + expect(day(days, "2026-09-11").hasNote).toBe(false); + }); + + it("every grid day carries exactly the fields the published contract names", async () => { + // The contract promised the cervix signs while the route never sent them. + // Pin the two against each other so neither can drift alone again. + const days = await callCalendar(); + const contractKeys = Object.keys(cycleCalendarDayDto.shape).sort(); + expect(Object.keys(days[0]).sort()).toEqual(contractKeys); + }); +}); diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index 007a75247..6e68e71f1 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -34,6 +34,7 @@ import { toCyclePredictionDTO, goalAllowsFertileWindow, cycleDisclaimerKey, + resolveSensitiveFields, } from "@/lib/cycle/dto"; import { resolveCycleVerdict } from "@/lib/cycle/verdict"; import { addDays, dayDiff } from "@/lib/cycle/day-math"; @@ -115,6 +116,19 @@ export const GET = apiHandler(async (request: NextRequest) => { cervixPosition: true, cervixFirmness: true, cervixOpening: true, + intermenstrualBleeding: true, + // The intent fields and their encrypted envelope, resolved below + // through the same reader the day-log route uses. Same `cycle` grant + // as that route (see sharing-surface-guard), so the grid shows a + // delegate nothing the day log would not. + sexualActivity: true, + protectedSex: true, + pregnancyTest: true, + progesteroneTest: true, + contraceptive: true, + sensitiveEncrypted: true, + // Selected for presence only; the text is never decrypted here. + notesEncrypted: true, _count: { select: { symptomLinks: true } }, }, }), @@ -141,18 +155,27 @@ export const GET = apiHandler(async (request: NextRequest) => { }), ]); - const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => ({ - date: l.date, - flow: l.flow, - basalBodyTempC: l.basalBodyTempC, - temperatureExcluded: l.temperatureExcluded, - ovulationTest: l.ovulationTest, - cervicalMucus: l.cervicalMucus, - cervixPosition: l.cervixPosition, - cervixFirmness: l.cervixFirmness, - cervixOpening: l.cervixOpening, - hasSymptoms: l._count.symptomLinks > 0, - })); + const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => { + const sensitive = resolveSensitiveFields(l); + return { + date: l.date, + flow: l.flow, + basalBodyTempC: l.basalBodyTempC, + temperatureExcluded: l.temperatureExcluded, + ovulationTest: l.ovulationTest, + cervicalMucus: l.cervicalMucus, + cervixPosition: l.cervixPosition, + cervixFirmness: l.cervixFirmness, + cervixOpening: l.cervixOpening, + intermenstrualBleeding: l.intermenstrualBleeding, + sexualActivity: sensitive.sexualActivity, + pregnancyTest: sensitive.pregnancyTest, + progesteroneTest: sensitive.progesteroneTest, + contraceptive: sensitive.contraceptive, + hasSymptoms: l._count.symptomLinks > 0, + hasNote: l.notesEncrypted != null && l.notesEncrypted !== "", + }; + }); const nights = nightlyTemps.map((m) => ({ date: moodDateKey(m.measuredAt, tz), diff --git a/src/lib/cycle/__tests__/engine-adapter.test.ts b/src/lib/cycle/__tests__/engine-adapter.test.ts index 1c14e74c9..d91d77d69 100644 --- a/src/lib/cycle/__tests__/engine-adapter.test.ts +++ b/src/lib/cycle/__tests__/engine-adapter.test.ts @@ -80,7 +80,13 @@ describe("buildCalendar", () => { cervixPosition: null, cervixFirmness: null, cervixOpening: null, + intermenstrualBleeding: false, + sexualActivity: false, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, hasSymptoms: true, + hasNote: false, }, ]; const { prediction, days } = buildCalendar( diff --git a/src/lib/cycle/dto.ts b/src/lib/cycle/dto.ts index 59fdd1851..761ea0ea0 100644 --- a/src/lib/cycle/dto.ts +++ b/src/lib/cycle/dto.ts @@ -111,11 +111,52 @@ function decryptSensitive(envelope: string | null): SensitiveEnvelope { } } +/** The five intent-revealing fields, as a reader should see them. */ +interface ResolvedSensitiveFields { + sexualActivity: boolean; + protectedSex: boolean | null; + pregnancyTest: string | null; + progesteroneTest: string | null; + contraceptive: string | null; +} + +/** + * Resolve the intent-revealing fields of one day-log row: from the encryption + * envelope when the row carries one, else from the plaintext columns (the + * flag-OFF path). The day-log read and the calendar grid both go through here, + * so a day cannot show intercourse in one and not the other. + */ +export function resolveSensitiveFields( + row: Pick< + CycleDayLog, + | "sensitiveEncrypted" + | "sexualActivity" + | "protectedSex" + | "pregnancyTest" + | "progesteroneTest" + | "contraceptive" + >, +): ResolvedSensitiveFields { + if (row.sensitiveEncrypted != null) { + const enc = decryptSensitive(row.sensitiveEncrypted); + return { + sexualActivity: enc.sexualActivity ?? false, + protectedSex: enc.protectedSex ?? null, + pregnancyTest: enc.pregnancyTest ?? null, + progesteroneTest: enc.progesteroneTest ?? null, + contraceptive: enc.contraceptive ?? null, + }; + } + return { + sexualActivity: row.sexualActivity, + protectedSex: row.protectedSex, + pregnancyTest: row.pregnancyTest, + progesteroneTest: row.progesteroneTest, + contraceptive: row.contraceptive, + }; +} + export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO { - // Prefer the encryption envelope when present; else read the plaintext - // columns (the flag-OFF path). - const enc = decryptSensitive(row.sensitiveEncrypted); - const hasEnvelope = row.sensitiveEncrypted != null; return { id: row.id, date: row.date, @@ -129,19 +170,7 @@ export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO { cervixPosition: row.cervixPosition, cervixFirmness: row.cervixFirmness, cervixOpening: row.cervixOpening, - sexualActivity: hasEnvelope - ? (enc.sexualActivity ?? false) - : row.sexualActivity, - protectedSex: hasEnvelope ? (enc.protectedSex ?? null) : row.protectedSex, - pregnancyTest: hasEnvelope - ? (enc.pregnancyTest ?? null) - : row.pregnancyTest, - progesteroneTest: hasEnvelope - ? (enc.progesteroneTest ?? null) - : row.progesteroneTest, - contraceptive: hasEnvelope - ? (enc.contraceptive ?? null) - : row.contraceptive, + ...resolveSensitiveFields(row), symptoms: (row.symptomLinks ?? []).map((l) => ({ key: l.symptom.key, severity: l.severity ?? null, diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts index d7e3fba57..7eaa6ee53 100644 --- a/src/lib/cycle/engine-adapter.ts +++ b/src/lib/cycle/engine-adapter.ts @@ -69,6 +69,27 @@ export interface CalendarDayDTO { ovulationTest: string | null; /** Logged cervical-mucus quality, or null. */ cervicalMucus: string | null; + /** Logged cervix signs (symptothermal secondary indicator), each or null. */ + cervixPosition: string | null; + cervixFirmness: string | null; + cervixOpening: string | null; + /** Spotting / bleeding outside the period logged on this day. */ + intermenstrualBleeding: boolean; + /** + * Intercourse logged on this day. Read through the same resolver as the + * day-log itself (plaintext column or the encrypted envelope), and carried + * under the same `cycle` grant: a caller who may read this grid may read + * the day log it summarises, and one who may not reaches neither. + */ + sexualActivity: boolean; + /** Logged at-home pregnancy test result, or null. */ + pregnancyTest: string | null; + /** Logged at-home progesterone test result, or null. */ + progesteroneTest: string | null; + /** Logged contraceptive method, or null. */ + contraceptive: string | null; + /** Whether the day carries a note. The note's text never rides the grid. */ + hasNote: boolean; } /** Rows the calendar needs from each day-log. */ @@ -83,7 +104,15 @@ export type CalendarDayLogRow = Pick< | "cervixPosition" | "cervixFirmness" | "cervixOpening" -> & { hasSymptoms: boolean }; + | "intermenstrualBleeding" +> & { + hasSymptoms: boolean; + hasNote: boolean; + sexualActivity: boolean; + pregnancyTest: string | null; + progesteroneTest: string | null; + contraceptive: string | null; +}; /** Map MenstrualCycle rows (oldest→newest) to engine `CycleInput`. */ export function toCycleInputs( @@ -449,6 +478,15 @@ export function buildCalendar( temperatureExcluded: log?.temperatureExcluded ?? false, ovulationTest: log?.ovulationTest ?? null, cervicalMucus: log?.cervicalMucus ?? null, + cervixPosition: log?.cervixPosition ?? null, + cervixFirmness: log?.cervixFirmness ?? null, + cervixOpening: log?.cervixOpening ?? null, + intermenstrualBleeding: log?.intermenstrualBleeding ?? false, + sexualActivity: log?.sexualActivity ?? false, + pregnancyTest: log?.pregnancyTest ?? null, + progesteroneTest: log?.progesteroneTest ?? null, + contraceptive: log?.contraceptive ?? null, + hasNote: log?.hasNote ?? false, }); } diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index 86306fa4d..fd37bebd5 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -186,7 +186,7 @@ const cyclePredictionDto = z "The materialised forecast. Fertile-window fields (and predictedOvulation/ovulationConfirmed) are server-suppressed (null/false) unless the goal is TRYING_TO_CONCEIVE or AVOID_PREGNANCY.", }); -const cycleCalendarDayDto = z.object({ +export const cycleCalendarDayDto = z.object({ date: z.string(), phase: cyclePhaseEnumOpenapi.nullable(), isPredictedPeriod: z.boolean(), @@ -218,6 +218,23 @@ const cycleCalendarDayDto = z.object({ cervixPosition: cervixPositionEnum.nullable(), cervixFirmness: cervixFirmnessEnum.nullable(), cervixOpening: cervixOpeningEnum.nullable(), + // v1.39.1 — the rest of what a day log can hold, so a day with only one of + // these logged no longer reads as an empty day. Additive: an older client + // that does not know a field ignores it. + intermenstrualBleeding: z.boolean().meta({ + description: "Spotting or bleeding outside the period logged on this day.", + }), + sexualActivity: z.boolean().meta({ + description: + "Intercourse logged on this day. Resolved from the plaintext column or the encrypted envelope, exactly as the day-log read resolves it, and served under the same cycle grant.", + }), + pregnancyTest: homeTestResultEnumOpenapi.nullable(), + progesteroneTest: homeTestResultEnumOpenapi.nullable(), + contraceptive: z.string().nullable(), + hasNote: z.boolean().meta({ + description: + "Whether the day carries a note. The note text is only on the day-log read.", + }), }); const cycleVerdictDto = z