fix(cycle): give back a start that a mis-tapped earlier start folded in

A start logged within one period before an existing start folds that start
into itself and soft-deletes it, since the two are one period entered out of
order. Nothing recorded which start was folded, so taking the earlier start
back could not bring the later one back: a mis-tap on the 5th with a real
start on the 10th, then deleted, left the 10th gone and the cycle before it
reopened.

Migration 0345 adds menstrual_cycles.absorbed_into_id. The folded row names
the start that absorbed it; removing that start restores it, re-derives its
own end and hands its days back to it. The column is needed because a
soft-deleted row alone cannot tell a folded start from one the person
deleted, and the audit trail is not state a removal can read. Deleting a
cycle directly now runs the same shared removal as deleting its day-log.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 12:30:39 +02:00
1 parent f28153c832
commit e822154936
5 files changed
+155 -17

No files matched your search

+11 -8
View File
@@ -19,7 +19,7 @@ import { auditLog } from "@/lib/auth/audit";
import { overwriteDetails } from "@/lib/sharing/audit-details";
import { apiError, getClientIp } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { reanchorAfterRemovedStart } from "@/lib/cycle/cycle-boundaries";
import { removeCycleStartedOn } from "@/lib/cycle/cycle-boundaries";
type RouteParams = { params: Promise<{ id: string }> };
@@ -45,13 +45,13 @@ export const DELETE = apiHandler(
// Tombstone + re-anchor as one unit: a neighbour re-derived against a
// half-applied delete would read the row being removed as still live.
const reanchored = await prisma.$transaction(async (db) => {
await db.menstrualCycle.update({
where: { id },
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
});
return reanchorAfterRemovedStart(db, user.id, existing.startDate);
});
// The shared removal also gives back a start this one folded in and
// hands the removed cycle's days back. `null` is a cycle already
// tombstoned: nothing left to move.
const removed = await prisma.$transaction((db) =>
removeCycleStartedOn(db, user.id, existing.startDate),
);
const reanchored = removed?.reanchored ?? null;
await auditLog("cycle.cycle.delete", {
userId: user.id,
@@ -61,6 +61,9 @@ export const DELETE = apiHandler(
details: {
cycleId: id,
startDate: existing.startDate,
...(removed && removed.restoredCycleIds.length > 0
? { restoredCycleIds: removed.restoredCycleIds }
: {}),
...(reanchored
? {
reanchoredCycleId: reanchored.cycleId,