mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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>
225 lines
7.4 KiB
TypeScript
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;
|
|
}
|
|
}
|