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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 07:02:14 +02:00
1 parent d81518b5bf
commit adec5bd045
7 files changed
+431 -31

No files matched your search

+28
View File
@@ -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
@@ -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<string, unknown> = {}) {
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<Record<string, unknown>[]> {
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<string, unknown>[];
}
function day(days: Record<string, unknown>[], 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<string, unknown>;
};
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);
});
});
+35 -12
View File
@@ -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),
@@ -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(
+46 -17
View File
@@ -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,
+39 -1
View File
@@ -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,
});
}
+18 -1
View File
@@ -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