Files
HealthLog/src/app/api/cycle/all/route.ts
T
Marc-André Bombeck a635c1de05 fix(cycle): v1.15.1 QA reconcile — purge, rate limits, marker contrast, token discipline
- Add CYCLE_FERTILE_SOON to the cycle-purge push_attempts eventType list so a
  data reset leaves no dated reproductive-intent delivery trace.
- Rate-limit the custom-symptom mutations (POST create, PATCH, DELETE) under a
  shared cycle:symptom:custom:<userId> bucket (30/min), mirroring the insights
  route, so a session can't flood the encrypted-label catalogue.
- Re-run the active-count cap inside PATCH when a symptom reactivates
  (hidden → active); a hidden row doesn't count toward the cap, so without the
  recheck a user could exceed it by hiding then re-enabling rows.
- Gate the log-day "limit reached" copy on the cap errorCode rather than any
  create failure, so a transient/validation error no longer masquerades as the
  cap being hit; thread the returned key so a new custom symptom auto-selects.
- Move the history-chart bar fill alphas into named phase-tokens
  (HISTORY_REST_OPACITY / HISTORY_PERIOD_OPACITY) so bar intensity lives in one
  token source alongside FLOW_OPACITY.
- Strengthen the confirmed-ovulation calendar marker (ring 1.5px → 2px, fill
  alpha 0.16 → 0.22) so it stays distinct from the predicted dot on small light
  screens; keeps its data-attr + aria naming.
- Raise the phase-education chip border from /10 to /15 for legible contrast on
  the tinted tile.
2026-06-06 17:19:59 +02:00

115 lines
4.3 KiB
TypeScript

/**
* `DELETE /api/cycle/all` — one-click cycle hard-delete purge.
*
* Distinct from the per-row soft-delete (`DELETE /api/cycle/day-logs/{id}`,
* which leaves a tombstone for the sync feed): this HARD-deletes every
* cycle row the user owns — day-logs (and their symptom links, by cascade),
* menstrual cycles, predictions, the cycle audit trail, and the cycle
* reminder-delivery rows in the push-attempts ledger — so no dated
* reproductive trace survives in Postgres (the post-Dobbs threat model). The
* `CycleProfile` row is left in place (gate + settings keep working) but its
* intent-revealing fields — goal + cycle/period/luteal priors — are reset to
* neutral defaults, so no reproductive intent survives either.
*
* Gated (`cycle.disabled` 403) and owner-scoped. The purge action itself IS
* audited (written after the transaction, so it survives) — its `details`
* carry only counts, no reproductive content.
*/
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 } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
export const DELETE = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
// Hard delete in a transaction. Symptom links cascade off the day-log
// delete (the schema's onDelete: Cascade). Order child-before-parent for
// the rows that don't cascade.
const cleared = await prisma.$transaction(async (tx) => {
const dayLogs = await tx.cycleDayLog.deleteMany({
where: { userId: user.id },
});
// Per-user custom symptoms carry an intent-revealing free-text label
// (encrypted at rest). A purge that promises "nothing reproductive
// persists" must drop them too; their links cascade off the row delete.
const customSymptoms = await tx.cycleSymptom.deleteMany({
where: { userId: user.id },
});
const predictions = await tx.cyclePrediction.deleteMany({
where: { userId: user.id },
});
const cycles = await tx.menstrualCycle.deleteMany({
where: { userId: user.id },
});
// The cycle audit trail carries dated reproductive-adjacent details
// (period boundaries, the AVOID_PREGNANCY goal-nudge, goal changes). A
// purge that promises "nothing reproductive persists" must clear it too.
// The purge's own audit row is written AFTER this transaction, so it
// survives (counts only, no reproductive content).
const auditRows = await tx.auditLog.deleteMany({
where: {
userId: user.id,
OR: [
{ action: { startsWith: "cycle." } },
{ action: "user.cycle-prefs.update" },
],
},
});
// The shared push-attempts ledger holds dated cycle reminder-delivery
// rows (eventType + createdAt) for up to 90 days — drop the cycle ones.
const pushRows = await tx.pushAttempt.deleteMany({
where: {
userId: user.id,
eventType: {
in: [
"CYCLE_PERIOD_SOON",
"CYCLE_PERIOD_CONFIRM",
"CYCLE_FERTILE_SOON",
],
},
},
});
// Reset the reproductive INTENT carried on the profile (goal + cycle/period/
// luteal priors) to neutral defaults so a purge leaves no intent-revealing
// state, while keeping the row so the gate + settings keep working.
await tx.cycleProfile.updateMany({
where: { userId: user.id },
data: {
goal: "GENERAL_HEALTH",
typicalCycleLength: null,
typicalPeriodLength: null,
lutealPhaseLength: null,
},
});
return {
dayLogs: dayLogs.count,
customSymptoms: customSymptoms.count,
predictions: predictions.count,
cycles: cycles.count,
auditRows: auditRows.count,
pushRows: pushRows.count,
};
});
await auditLog("cycle.purge", {
userId: user.id,
ipAddress: getClientIp(request),
details: cleared,
});
annotate({
action: { name: "cycle.purge" },
meta: cleared,
});
return apiSuccess({ purged: true, ...cleared });
});