Files
HealthLog/src/app/api/cycle/period/route.ts
T
Marc-André BombeckandClaude Opus 4.8 3375bbabac fix(cycle): second-audit safety, privacy, and write-path hardening
Address the second-round audit (server cluster):

- AVOID_PREGNANCY surfaces the fertile window, so it must carry the
  stronger "not a contraceptive method" disclaimer — wire the existing
  strong copy via a shared cycleDisclaimerKey(goal), server and client.
- Re-anchor the FOLLOWING neighbour cycle on a back-filled period start so
  an inserted-between-two-cycles start no longer leaves a stale open cycle.
- Resolve the sensitive-category encryption flag once per bulk/import
  batch instead of re-reading the profile per day; hoist the bulk cycle
  attribution in-memory (no per-entry query).
- Drop the raw driver message from the bulk wide-event meta (annotate meta
  is not run through the central redactor); keep the stable reason + code.
- Hard-purge now clears the cycle audit trail and the cycle reminder rows
  in the push-attempts ledger, so no dated reproductive trace survives.
- Discreet mode collapses the Web-Push coalescing tag and drops cycle
  metadata (phase) from APNs, matching the existing APNs event collapse.
- Add the reproductive intent field names to the central redactor denylist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:15:22 +02:00

225 lines
7.4 KiB
TypeScript

/**
* `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 { withIdempotency } from "@/lib/idempotency";
import { cyclePeriodSchema } from "@/lib/validations/cycle";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import type { FlowLevel } from "@/lib/cycle/types";
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(withIdempotency<[NextRequest]>(postPeriod));
async function postPeriod(request: NextRequest): Promise<Response> {
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;
// The close-prior + open-new (start) or stamp-current (end) mutation set
// is run inside a single transaction so two concurrent taps can never
// double-close a prior cycle or compute the new cycle's length against a
// stale read. `withIdempotency` on the route additionally collapses an
// exact-key replay; the transaction guards the interleaving case.
const txResult = await prisma.$transaction(async (db) => {
if (action === "start") {
// Close the prior open cycle (its end is the day before this start)
// and record its observed length.
const prior = await db.menstrualCycle.findFirst({
where: { userId: user.id, deletedAt: null, startDate: { lt: date } },
orderBy: { startDate: "desc" },
select: { id: true, startDate: true },
});
if (prior) {
await db.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 db.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 },
},
});
// Re-anchor the FOLLOWING neighbour: when this start is back-filled
// between two existing cycles, the new cycle's end + length are already
// known from its successor's start. Without this the inserted cycle keeps
// endDate/lengthDays = null even though the next start exists, and the
// iOS mirror + history surfaces read a stale open cycle (QA M-1).
const next = await db.menstrualCycle.findFirst({
where: { userId: user.id, deletedAt: null, startDate: { gt: date } },
orderBy: { startDate: "asc" },
select: { startDate: true },
});
if (next) {
await db.menstrualCycle.update({
where: { id: cycle.id },
data: {
endDate: addDays(next.startDate, -1),
lengthDays: dayDiff(next.startDate, date),
syncVersion: { increment: 1 },
},
});
}
return { cycleId: cycle.id as string | null };
}
// `end`: stamp the current cycle's periodEndDate.
const current = await db.menstrualCycle.findFirst({
where: { userId: user.id, deletedAt: null, startDate: { lte: date } },
orderBy: { startDate: "desc" },
select: { id: true },
});
if (!current) {
return { cycleId: null };
}
await db.menstrualCycle.update({
where: { id: current.id },
data: { periodEndDate: date, syncVersion: { increment: 1 } },
});
return { cycleId: current.id as string | null };
});
if (txResult.cycleId === null) {
return apiSuccessNoCycle();
}
const cycleId: string = txResult.cycleId;
// Boundary flow. Start → MEDIUM opens the bleed; end → SPOTTING tail.
// Never downgrade a richer flow already logged for the same day (e.g. a
// manual HEAVY entry must survive a later one-tap "start") — only set the
// boundary flow when it ranks at or above what's stored.
const boundaryFlow: FlowLevel = action === "start" ? "MEDIUM" : "SPOTTING";
const existingDay = await prisma.cycleDayLog.findFirst({
where: { userId: user.id, date, deletedAt: null },
select: { flow: true },
});
const flow =
flowRank(boundaryFlow) >= flowRank(existingDay?.flow ?? null)
? boundaryFlow
: undefined;
await upsertCycleDayLog(
user.id,
{
date,
...(flow !== undefined ? { flow } : {}),
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 });
}
/**
* Ordinal rank of a flow level (NONE = 0 … HEAVY = 5; a missing flow ranks
* below NONE). Used to keep the one-tap boundary flow from downgrading a
* richer same-day entry.
*/
function flowRank(flow: FlowLevel | string | null): number {
switch (flow) {
case "NONE":
return 1;
case "SPOTTING":
return 2;
case "LIGHT":
return 3;
case "MEDIUM":
return 4;
case "HEAVY":
return 5;
default:
return 0;
}
}