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