From b562587c45d06a164dfe8f5fb676819c81ae9a06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 23:16:11 +0200 Subject: [PATCH] feat(cycle): name the start a period start folds in and the starts a removal gives back --- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/ko.json | 4 +- messages/pl.json | 4 +- src/app/api/cycle/cycles/[id]/route.ts | 12 +- src/app/api/cycle/day-logs/[id]/route.ts | 16 +- src/app/api/cycle/period/route.ts | 15 +- src/components/cycle/use-cycle.ts | 68 ++++- src/lib/cycle/cycle-boundaries.ts | 6 + src/lib/cycle/delete-outcome.ts | 30 +++ src/lib/documents/ai-runs/http.ts | 10 +- src/lib/http/__tests__/prefer.test.ts | 39 +++ src/lib/http/prefer.ts | 22 ++ src/lib/openapi/routes/cycle.ts | 50 ++-- .../cycle-absorbed-restored.test.ts | 234 ++++++++++++++++++ 18 files changed, 481 insertions(+), 49 deletions(-) create mode 100644 src/lib/cycle/delete-outcome.ts create mode 100644 src/lib/http/__tests__/prefer.test.ts create mode 100644 src/lib/http/prefer.ts create mode 100644 tests/integration/cycle-absorbed-restored.test.ts diff --git a/messages/de.json b/messages/de.json index f2f5c30ba..6e756d71e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9235,7 +9235,9 @@ "periodStartSaved": "Periodenbeginn gespeichert", "periodEndSaved": "Periodenende gespeichert", "startedPeriodOnDate": "Periode hat an diesem Tag begonnen", - "endedPeriodOnDate": "Periode an diesem Tag beendet" + "endedPeriodOnDate": "Periode an diesem Tag beendet", + "startAbsorbed": "Periodenbeginn gespeichert. Dein Beginn am {absorbed} wurde mit dem {date} zusammengeführt.", + "startRestored": "Gespeichert. Der Beginn am {dates} ist wieder da." }, "cycleReminders": { "periodSoonTitle": "Periode steht wahrscheinlich bevor", diff --git a/messages/en.json b/messages/en.json index 0d53008ba..f8452cf4d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9235,7 +9235,9 @@ "periodStartSaved": "Period start saved", "periodEndSaved": "End of period saved", "startedPeriodOnDate": "My period started this day", - "endedPeriodOnDate": "My period ended this day" + "endedPeriodOnDate": "My period ended this day", + "startAbsorbed": "Period start saved. Your start on {absorbed} was merged into {date}.", + "startRestored": "Saved. The start on {dates} is back." }, "cycleReminders": { "periodSoonTitle": "Period likely soon", diff --git a/messages/es.json b/messages/es.json index 0b50e9a0f..708fa2729 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9235,7 +9235,9 @@ "periodStartSaved": "Inicio del periodo guardado", "periodEndSaved": "Fin del periodo guardado", "startedPeriodOnDate": "Mi periodo empezó este día", - "endedPeriodOnDate": "Mi periodo terminó este día" + "endedPeriodOnDate": "Mi periodo terminó este día", + "startAbsorbed": "Inicio del periodo guardado. Tu inicio del {absorbed} se ha unido al del {date}.", + "startRestored": "Guardado. El inicio del {dates} vuelve a estar." }, "cycleReminders": { "periodSoonTitle": "El periodo llegará pronto", diff --git a/messages/fr.json b/messages/fr.json index 72666ef78..28c551f9e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9235,7 +9235,9 @@ "periodStartSaved": "Début des règles enregistré", "periodEndSaved": "Fin des règles enregistrée", "startedPeriodOnDate": "Mes règles ont commencé ce jour-là", - "endedPeriodOnDate": "Mes règles se sont terminées ce jour-là" + "endedPeriodOnDate": "Mes règles se sont terminées ce jour-là", + "startAbsorbed": "Début des règles enregistré. Votre début du {absorbed} a été fusionné avec celui du {date}.", + "startRestored": "Enregistré. Le début du {dates} est de retour." }, "cycleReminders": { "periodSoonTitle": "Règles probablement bientôt", diff --git a/messages/it.json b/messages/it.json index 2bb621f5c..6ef3ef883 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9235,7 +9235,9 @@ "periodStartSaved": "Inizio delle mestruazioni salvato", "periodEndSaved": "Fine delle mestruazioni salvata", "startedPeriodOnDate": "Le mestruazioni sono iniziate questo giorno", - "endedPeriodOnDate": "Le mestruazioni sono finite questo giorno" + "endedPeriodOnDate": "Le mestruazioni sono finite questo giorno", + "startAbsorbed": "Inizio delle mestruazioni salvato. Il tuo inizio del {absorbed} è stato unito a quello del {date}.", + "startRestored": "Salvato. L’inizio del {dates} è di nuovo presente." }, "cycleReminders": { "periodSoonTitle": "Ciclo probabilmente in arrivo", diff --git a/messages/ko.json b/messages/ko.json index 6129f6db7..a90b85329 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9235,7 +9235,9 @@ "periodStartSaved": "생리 시작을 저장했어요", "periodEndSaved": "생리 종료를 저장했어요", "startedPeriodOnDate": "이 날 생리를 시작했어요", - "endedPeriodOnDate": "이 날 생리가 끝났어요" + "endedPeriodOnDate": "이 날 생리가 끝났어요", + "startAbsorbed": "생리 시작을 저장했어요. {absorbed}의 시작은 {date}로 합쳐졌어요.", + "startRestored": "저장했어요. {dates}의 시작이 다시 표시돼요." }, "cycleReminders": { "periodSoonTitle": "생리가 곧 시작될 것 같아요", diff --git a/messages/pl.json b/messages/pl.json index 6d71a7276..6b417cc1f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9235,7 +9235,9 @@ "periodStartSaved": "Początek miesiączki zapisany", "periodEndSaved": "Koniec miesiączki zapisany", "startedPeriodOnDate": "Miesiączka zaczęła się tego dnia", - "endedPeriodOnDate": "Miesiączka skończyła się tego dnia" + "endedPeriodOnDate": "Miesiączka skończyła się tego dnia", + "startAbsorbed": "Początek miesiączki zapisany. Twój początek z {absorbed} został połączony z {date}.", + "startRestored": "Zapisano. Początek z {dates} jest z powrotem." }, "cycleReminders": { "periodSoonTitle": "Miesiączka prawdopodobnie wkrótce", diff --git a/src/app/api/cycle/cycles/[id]/route.ts b/src/app/api/cycle/cycles/[id]/route.ts index 088c6c7a0..705c511a0 100644 --- a/src/app/api/cycle/cycles/[id]/route.ts +++ b/src/app/api/cycle/cycles/[id]/route.ts @@ -2,6 +2,9 @@ * `DELETE /api/cycle/cycles/{id}` — soft-delete a cycle * (ios-contract §2.F): set `deletedAt` + bump `syncVersion`, emit a * tombstone on the next sync page. 204. Idempotent. Owner-scoped + gated. + * With `Prefer: return=representation` (v1.43, RFC 7240) the answer is 200 + * with what the delete changed — the removed cycle and the starts it gave + * back — so a client can say "the start on 17 Sep is back". * * The delete also hands the span back to the preceding cycle. Opening a cycle * closes the one before it, so removing it without re-deriving that boundary @@ -18,6 +21,7 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { overwriteDetails } from "@/lib/sharing/audit-details"; import { apiError, getClientIp } from "@/lib/api-response"; +import { cycleDeleteOutcome } from "@/lib/cycle/delete-outcome"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { removeCycleStartedOn } from "@/lib/cycle/cycle-boundaries"; @@ -44,7 +48,7 @@ export const DELETE = apiHandler( } // Already a tombstone: the delete is done, and nothing is left to move. if (existing.deletedAt !== null) { - return new Response(null, { status: 204 }); + return cycleDeleteOutcome(request, null, []); } // Tombstone + re-anchor as one unit: a neighbour re-derived against a @@ -99,6 +103,10 @@ export const DELETE = apiHandler( meta: { reanchored_prior: reanchored !== null }, }); - return new Response(null, { status: 204 }); + return cycleDeleteOutcome( + request, + removed?.cycleId ?? null, + removed?.restored ?? [], + ); }, ); diff --git a/src/app/api/cycle/day-logs/[id]/route.ts b/src/app/api/cycle/day-logs/[id]/route.ts index 4c92fe9a2..4db316019 100644 --- a/src/app/api/cycle/day-logs/[id]/route.ts +++ b/src/app/api/cycle/day-logs/[id]/route.ts @@ -3,7 +3,8 @@ * (ios-contract §2.A). * `DELETE /api/cycle/day-logs/{id}` — soft-delete (ios-contract §2.F): * set `deletedAt` + bump `syncVersion`, emit a tombstone on the next - * sync page. 204. Idempotent. + * sync page. 204. Idempotent. With `Prefer: return=representation` 200 + * with the removed cycle and the starts given back (v1.43). * * Both are gated (`cycle.disabled` 403) and owner-scoped (a row owned by * another user 404s). @@ -34,6 +35,7 @@ import { getOrCreateCycleProfile } from "@/lib/cycle/profile"; import { replaceSymptomLinks } from "@/lib/cycle/day-log-write"; import { cycleDayLogPatchSchema } from "@/lib/validations/cycle"; import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto"; +import { cycleDeleteOutcome } from "@/lib/cycle/delete-outcome"; type RouteParams = { params: Promise<{ id: string }> }; @@ -176,11 +178,13 @@ export const PATCH = apiHandler( row, ); let removedCycleId: string | null = null; + let restored: Array<{ cycleId: string; startDate: string }> = []; if (!openedCycleId && !stillBleeds(row)) { const removed = await prisma.$transaction((db) => removeCycleStartedOn(db, user.id, existing.date), ); removedCycleId = removed?.cycleId ?? null; + restored = removed?.restored ?? []; } await auditLog("cycle.day-log.update", { @@ -221,7 +225,9 @@ export const PATCH = apiHandler( }, }); - return apiSuccess(toCycleDayLogDTO(row)); + // v1.43 (ios #116 item 9) — the starts this edit gave back by clearing + // the flow on a start day; empty when it removed none. + return apiSuccess({ ...toCycleDayLogDTO(row), restored }); }, ); @@ -303,7 +309,11 @@ export const DELETE = apiHandler( }, }); - return new Response(null, { status: 204 }); + return cycleDeleteOutcome( + request, + removed?.cycleId ?? null, + removed?.restored ?? [], + ); }, ); diff --git a/src/app/api/cycle/period/route.ts b/src/app/api/cycle/period/route.ts index 80b276b3f..d95ee0b3a 100644 --- a/src/app/api/cycle/period/route.ts +++ b/src/app/api/cycle/period/route.ts @@ -7,7 +7,8 @@ * 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 }`. + * `{ cycle: MenstrualCycleDTO, dayLog: CycleDayLogDTO, absorbed }`, where + * `absorbed` names a later start this one folded in (v1.43, null otherwise). */ import { NextRequest } from "next/server"; @@ -221,12 +222,22 @@ async function postPeriod(request: NextRequest): Promise { return apiSuccess({ cycle: toMenstrualCycleDTO(cycleRow), dayLog: toCycleDayLogDTO(dayLogRow), + // v1.43 (ios #116 item 9) — a later start inside the same period that + // this start folded in, so the client can say which date stopped being a + // start. Null when nothing was folded; present on every `start`. + absorbed: + txResult.moved.absorbedCycleId && txResult.moved.absorbedStartDate + ? { + cycleId: txResult.moved.absorbedCycleId, + startDate: txResult.moved.absorbedStartDate, + } + : null, }); } /** `end` with no preceding cycle is a no-op the client should not hit. */ function apiSuccessNoCycle(): Response { - return apiSuccess({ cycle: null, dayLog: null }); + return apiSuccess({ cycle: null, dayLog: null, absorbed: null }); } /** diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index 7b561dc7e..dd2f902f7 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -23,6 +23,7 @@ import { import { useTranslations } from "@/lib/i18n/context"; import { randomId } from "@/lib/random-id"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; +import { useCalendarDate } from "@/hooks/use-calendar-date"; import { cycleDependentKeys, @@ -318,10 +319,33 @@ export interface CycleDayLogPatch { note?: string | null; } +/** + * A period start the server folded in or gave back (v1.43). The server names + * it; the toast only says it, so the person learns which date moved without + * comparing the calendar before and after. + */ +export interface CycleStartRef { + cycleId: string; + startDate: string; +} + +/** The toast after a write that may have given starts back. */ +function useRestoredStartsMessage() { + const { t } = useTranslations(); + const formatDay = useCalendarDate(); + return (fallback: string, restored: readonly CycleStartRef[] | undefined) => + restored && restored.length > 0 + ? t("cycle.startRestored", { + dates: restored.map((r) => formatDay(r.startDate)).join(", "), + }) + : fallback; +} + /** Edit an existing day-log by id (PATCH — accepts explicit null to clear). */ export function usePatchDayLog() { const qc = useQueryClient(); const { t } = useTranslations(); + const restoredMessage = useRestoredStartsMessage(); return useMutation({ mutationFn: async ({ id, @@ -330,11 +354,17 @@ export function usePatchDayLog() { id: string; patch: CycleDayLogPatch; }) => { - return apiPatch(`/api/cycle/day-logs/${id}`, patch); + return apiPatch( + `/api/cycle/day-logs/${id}`, + patch, + ); }, - onSuccess: () => { + onSuccess: (data) => { void invalidateKeys(qc, cycleDependentKeys); - toastWrittenOutcome("success", t("cycle.saveSuccess")); + toastWrittenOutcome( + "success", + restoredMessage(t("cycle.saveSuccess"), data?.restored), + ); }, }); } @@ -342,17 +372,26 @@ export function usePatchDayLog() { export function useStartPeriod() { const qc = useQueryClient(); const { t } = useTranslations(); + const formatDay = useCalendarDate(); return useMutation({ mutationFn: async (date: string) => { - return apiPost("/api/cycle/period", { + return apiPost<{ absorbed?: CycleStartRef | null }>("/api/cycle/period", { action: "start", date, loggedAt: new Date().toISOString(), }); }, - onSuccess: () => { + onSuccess: (data, date) => { void invalidateKeys(qc, cycleDependentKeys); - toastWrittenOutcome("success", t("cycle.periodStartSaved")); + toastWrittenOutcome( + "success", + data?.absorbed + ? t("cycle.startAbsorbed", { + absorbed: formatDay(data.absorbed.startDate), + date: formatDay(date), + }) + : t("cycle.periodStartSaved"), + ); }, // v1.16.4 — period boundaries had no failure signal at all (the sheet // simply stayed open); a toast names the rejection. @@ -386,13 +425,24 @@ export function useEndPeriod() { export function useDeleteDayLog() { const qc = useQueryClient(); const { t } = useTranslations(); + const restoredMessage = useRestoredStartsMessage(); return useMutation({ mutationFn: async (id: string) => { - await apiDelete(`/api/cycle/day-logs/${id}`); + // Ask for what the delete changed: removing a start day can give back a + // start it had folded in, and the toast names it. A server that does + // not know the preference answers 204, read as nothing given back. + return apiDelete<{ restored?: CycleStartRef[] } | null>( + `/api/cycle/day-logs/${id}`, + undefined, + { headers: { Prefer: "return=representation" } }, + ); }, - onSuccess: () => { + onSuccess: (data) => { void invalidateKeys(qc, cycleDependentKeys); - toastWrittenOutcome("success", t("cycle.deleteSuccess")); + toastWrittenOutcome( + "success", + restoredMessage(t("cycle.deleteSuccess"), data?.restored), + ); }, onError: (failure) => showErrorToast(t("cycle.deleteError"), { cause: failure }), diff --git a/src/lib/cycle/cycle-boundaries.ts b/src/lib/cycle/cycle-boundaries.ts index 660c5ed73..337213aee 100644 --- a/src/lib/cycle/cycle-boundaries.ts +++ b/src/lib/cycle/cycle-boundaries.ts @@ -366,6 +366,8 @@ export async function removeCycleStartedOn( cycleId: string; reanchored: ReanchoredCycle | null; restoredCycleIds: string[]; + /** The same starts with their dates, for the answer to the client. */ + restored: Array<{ cycleId: string; startDate: string }>; } | null> { const opened = await db.menstrualCycle.findFirst({ where: { userId, deletedAt: null, startDate: date }, @@ -432,6 +434,10 @@ export async function removeCycleStartedOn( cycleId: opened.id, reanchored, restoredCycleIds: restored.map((row) => row.id), + restored: restored.map((row) => ({ + cycleId: row.id, + startDate: row.startDate, + })), }; } diff --git a/src/lib/cycle/delete-outcome.ts b/src/lib/cycle/delete-outcome.ts new file mode 100644 index 000000000..d164e4123 --- /dev/null +++ b/src/lib/cycle/delete-outcome.ts @@ -0,0 +1,30 @@ +/** + * The answer to a delete that can take a period start with it (v1.43, ios + * #116 item 9). + * + * Without a preference the delete answers 204, exactly as before, which is + * what every client predating this relies on. With `Prefer: + * return=representation` (RFC 7240) it answers 200 with the cycle it removed + * and the starts that removal gave back — a start folded into the removed one + * stands again — and says so in `Preference-Applied`. + */ +import { apiSuccess } from "@/lib/api-response"; +import { hasPreference, RETURN_REPRESENTATION } from "@/lib/http/prefer"; + +export interface CycleRestoredStart { + cycleId: string; + startDate: string; +} + +export function cycleDeleteOutcome( + request: Request, + removedCycleId: string | null, + restored: readonly CycleRestoredStart[], +): Response { + if (!hasPreference(request, RETURN_REPRESENTATION)) { + return new Response(null, { status: 204 }); + } + const response = apiSuccess({ removedCycleId, restored: [...restored] }); + response.headers.set("Preference-Applied", RETURN_REPRESENTATION); + return response; +} diff --git a/src/lib/documents/ai-runs/http.ts b/src/lib/documents/ai-runs/http.ts index 642c0bab9..6a75966f0 100644 --- a/src/lib/documents/ai-runs/http.ts +++ b/src/lib/documents/ai-runs/http.ts @@ -4,6 +4,7 @@ * response the synchronous route has always given. */ import { apiError, apiSuccess } from "@/lib/api-response"; +import { hasPreference } from "@/lib/http/prefer"; import { AiUnavailableError } from "@/lib/ai/capabilities/refusal"; import type { AiRunFailure } from "./store"; @@ -20,14 +21,7 @@ import { * is what a client that has not adopted runs relies on. */ export function prefersRespondAsync(request: Request): boolean { - const header = request.headers.get("prefer"); - if (!header) return false; - return header - .split(",") - .some( - (preference) => - preference.split(";")[0]?.trim().toLowerCase() === "respond-async", - ); + return hasPreference(request, "respond-async"); } /** The 202 for a queued run, with the poll address and the applied preference. */ diff --git a/src/lib/http/__tests__/prefer.test.ts b/src/lib/http/__tests__/prefer.test.ts new file mode 100644 index 000000000..3a2210264 --- /dev/null +++ b/src/lib/http/__tests__/prefer.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { hasPreference, RETURN_REPRESENTATION } from "../prefer"; + +function req(prefer?: string): Request { + return new Request("http://localhost/x", { + headers: prefer === undefined ? {} : { prefer }, + }); +} + +describe("hasPreference", () => { + it("is false without the header", () => { + expect(hasPreference(req(), RETURN_REPRESENTATION)).toBe(false); + }); + + it("finds a valued preference among several, with parameters and case", () => { + expect( + hasPreference( + req("respond-async, Return=Representation; foo=bar"), + RETURN_REPRESENTATION, + ), + ).toBe(true); + expect( + hasPreference(req("return = representation"), "return=representation"), + ).toBe(true); + }); + + it("does not take a different value of the same preference", () => { + expect(hasPreference(req("return=minimal"), RETURN_REPRESENTATION)).toBe( + false, + ); + }); + + it("reads a bare token", () => { + expect(hasPreference(req("respond-async; wait=10"), "respond-async")).toBe( + true, + ); + }); +}); diff --git a/src/lib/http/prefer.ts b/src/lib/http/prefer.ts new file mode 100644 index 000000000..697ff1fcc --- /dev/null +++ b/src/lib/http/prefer.ts @@ -0,0 +1,22 @@ +/** + * RFC 7240 `Prefer` header reading. A preference is the token before its + * first `;` (parameters follow it), several preferences are comma-separated, + * and a token without `=` stands alone (`respond-async`) while one with `=` + * carries a value (`return=representation`). Compared case-insensitively. + */ +export function hasPreference(request: Request, preference: string): boolean { + const header = request.headers.get("prefer"); + if (!header) return false; + const wanted = preference.toLowerCase(); + return header.split(",").some( + (entry) => + entry + .split(";")[0] + ?.trim() + .toLowerCase() + .replace(/\s*=\s*/, "=") === wanted, + ); +} + +/** `Prefer: return=representation`: answer a write with what it changed. */ +export const RETURN_REPRESENTATION = "return=representation"; diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index 354bf29a0..d60b379f1 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -333,6 +333,30 @@ const cycleDeleteOutcomeEnvelope = dataEnvelope( "CycleDeleteOutcomeEnvelope", ); +/** `Prefer: return=representation` on the two deletes that can remove a start. */ +const preferReturnRepresentationParameter = { + name: "Prefer", + in: "header" as const, + required: false, + schema: { type: "string" as const, example: "return=representation" }, + description: + "v1.43 — send `return=representation` (RFC 7240) to get 200 with what the delete changed (`CycleDeleteOutcome`) and a `Preference-Applied: return=representation` header. Without it the route answers 204 with no body, as before.", +}; + +/** The 200 a delete answers with when the request preferred a representation. */ +const cycleDeleteOutcomeResponse = { + description: + "v1.43 — with `Prefer: return=representation`: the removed cycle (null when the delete removed none, e.g. a day that opened no cycle or a repeated delete) and the starts the removal gave back.", + content: { + "application/json": { schema: cycleDeleteOutcomeEnvelope }, + }, + headers: z.object({ + "Preference-Applied": z.literal("return=representation").meta({ + description: "The route honoured `Prefer: return=representation`.", + }), + }), +}; + const cyclePeriodResponse = z.object({ cycle: menstrualCycleDto.nullable(), dayLog: cycleDayLogDto.nullable(), @@ -340,7 +364,7 @@ const cyclePeriodResponse = z.object({ .nullable() .optional() .describe( - 'v1.43 — on `action: "start"`: a later start within the period length folded into this one, or null.', + 'v1.43 — a later start within the period length that this start folded in (it stops being a start of its own), or null. Present on every `action: "start"` answer; null on `end`.', ), }); @@ -787,7 +811,7 @@ export const cyclePaths: NonNullable = { .array(cycleRestoredStart) .optional() .describe( - "v1.43 — starts given back because this edit cleared the flow on a start day.", + "v1.43 — starts given back because this edit cleared the flow on a start day; empty when it gave back none. Absent on older servers.", ), }), ), @@ -808,16 +832,11 @@ export const cyclePaths: NonNullable = { tags: ["Cycle"], summary: "Soft-delete a cycle day-log (v1.15.0)", description: - "Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. 204. Idempotent.", + "Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. A day that opened a cycle takes that cycle with it, which gives back any start that cycle had folded in. 204, or 200 with `CycleDeleteOutcome` under `Prefer: return=representation`. Idempotent.", requestParams: { path: z.object({ id: z.string() }) }, + parameters: [preferReturnRepresentationParameter], responses: { - "200": { - description: - "v1.43 — with `Prefer: return=representation`: what the delete changed.", - content: { - "application/json": { schema: cycleDeleteOutcomeEnvelope }, - }, - }, + "200": cycleDeleteOutcomeResponse, "204": { description: "Soft-deleted (no body)." }, "404": { description: "Day-log not found / not owned.", @@ -970,16 +989,11 @@ export const cyclePaths: NonNullable = { tags: ["Cycle"], summary: "Soft-delete a menstrual cycle (v1.15.0)", description: - "Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. 204. Idempotent.", + "Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. Any start this cycle had folded in stands again. 204, or 200 with `CycleDeleteOutcome` under `Prefer: return=representation`. Idempotent.", requestParams: { path: z.object({ id: z.string() }) }, + parameters: [preferReturnRepresentationParameter], responses: { - "200": { - description: - "v1.43 — with `Prefer: return=representation`: what the delete changed.", - content: { - "application/json": { schema: cycleDeleteOutcomeEnvelope }, - }, - }, + "200": cycleDeleteOutcomeResponse, "204": { description: "Soft-deleted (no body)." }, "404": { description: "Cycle not found / not owned.", diff --git a/tests/integration/cycle-absorbed-restored.test.ts b/tests/integration/cycle-absorbed-restored.test.ts new file mode 100644 index 000000000..0d807a61c --- /dev/null +++ b/tests/integration/cycle-absorbed-restored.test.ts @@ -0,0 +1,234 @@ +/** + * The cycle writes name the starts they fold in and give back (ios #116 + * item 9). + * + * A start logged a few days before an existing one is the same period entered + * in the other order, so the later start is folded in. Taking the earlier one + * back gives the later one back. Both used to reach only the audit row; the + * client could refetch the grid but could not say which date moved. These + * pin the answers: `absorbed` on the start, `restored` on the PATCH that + * clears a start day's flow, and the opt-in 200 on the two deletes, with the + * 204 unchanged for a client that does not ask. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-cycle-absorbed-integration-32-bytes-min-1234567890"; +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const USER_ID = "user-cycle-absorbed"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "cycle-absorbed", + email: "cycle-absorbed@example.test", + gender: "FEMALE", + }, + }); + const session = await getPrismaClient().session.create({ + data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); +}); + +function request( + path: string, + method: string, + body?: unknown, + headers: Record = {}, +): NextRequest { + return new NextRequest(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json", ...headers }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +async function startPeriod(date: string): Promise<{ + cycle: { id: string; startDate: string } | null; + absorbed: { cycleId: string; startDate: string } | null; +}> { + const { POST } = await import("@/app/api/cycle/period/route"); + const res = await POST( + request("/api/cycle/period", "POST", { + action: "start", + date, + loggedAt: `${date}T08:00:00.000Z`, + }), + ); + expect(res.status).toBe(200); + return (await res.json()).data; +} + +async function cycleIdOn(date: string): Promise { + const row = await getPrismaClient().menstrualCycle.findFirstOrThrow({ + where: { userId: USER_ID, startDate: date }, + select: { id: true }, + }); + return row.id; +} + +const PREFER = { prefer: "return=representation" }; + +describe("absorbed and restored cycle starts", () => { + it("names the later start a start folds in, and null when it folds none", async () => { + const first = await startPeriod("2026-09-17"); + expect(first.absorbed).toBeNull(); + const later = await cycleIdOn("2026-09-17"); + + const earlier = await startPeriod("2026-09-14"); + expect(earlier.absorbed).toEqual({ + cycleId: later, + startDate: "2026-09-17", + }); + }); + + it("gives the folded start back on a cycle delete with Prefer, as 200", async () => { + await startPeriod("2026-09-17"); + const later = await cycleIdOn("2026-09-17"); + await startPeriod("2026-09-14"); + const earlier = await cycleIdOn("2026-09-14"); + + const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route"); + const res = await DELETE( + request(`/api/cycle/cycles/${earlier}`, "DELETE", undefined, PREFER), + { params: Promise.resolve({ id: earlier }) }, + ); + expect(res.status).toBe(200); + expect(res.headers.get("preference-applied")).toBe("return=representation"); + expect((await res.json()).data).toEqual({ + removedCycleId: earlier, + restored: [{ cycleId: later, startDate: "2026-09-17" }], + }); + // The calendar shows 17 Sep again. + const live = await getPrismaClient().menstrualCycle.findMany({ + where: { userId: USER_ID, deletedAt: null }, + select: { startDate: true }, + }); + expect(live.map((c) => c.startDate)).toEqual(["2026-09-17"]); + }); + + it("keeps answering 204 without a body when the request does not ask", async () => { + await startPeriod("2026-09-17"); + await startPeriod("2026-09-14"); + const earlier = await cycleIdOn("2026-09-14"); + + const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route"); + const res = await DELETE( + request(`/api/cycle/cycles/${earlier}`, "DELETE"), + { params: Promise.resolve({ id: earlier }) }, + ); + expect(res.status).toBe(204); + expect(res.headers.get("preference-applied")).toBeNull(); + expect(await res.text()).toBe(""); + }); + + it("answers a repeated delete with nothing removed and nothing restored", async () => { + await startPeriod("2026-09-17"); + const id = await cycleIdOn("2026-09-17"); + const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route"); + await DELETE(request(`/api/cycle/cycles/${id}`, "DELETE"), { + params: Promise.resolve({ id }), + }); + const again = await DELETE( + request(`/api/cycle/cycles/${id}`, "DELETE", undefined, PREFER), + { params: Promise.resolve({ id }) }, + ); + expect(again.status).toBe(200); + expect((await again.json()).data).toEqual({ + removedCycleId: null, + restored: [], + }); + }); + + it("gives the folded start back on a day-log delete with Prefer", async () => { + await startPeriod("2026-09-17"); + const later = await cycleIdOn("2026-09-17"); + await startPeriod("2026-09-14"); + const earlier = await cycleIdOn("2026-09-14"); + const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({ + where: { userId: USER_ID, date: "2026-09-14" }, + select: { id: true }, + }); + + const { DELETE } = await import("@/app/api/cycle/day-logs/[id]/route"); + const res = await DELETE( + request(`/api/cycle/day-logs/${dayLog.id}`, "DELETE", undefined, { + prefer: "handling=lenient, return=representation", + }), + { params: Promise.resolve({ id: dayLog.id }) }, + ); + expect(res.status).toBe(200); + expect((await res.json()).data).toEqual({ + removedCycleId: earlier, + restored: [{ cycleId: later, startDate: "2026-09-17" }], + }); + }); + + it("names the start given back when an edit clears the flow on a start day", async () => { + await startPeriod("2026-09-17"); + const later = await cycleIdOn("2026-09-17"); + await startPeriod("2026-09-14"); + const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({ + where: { userId: USER_ID, date: "2026-09-14" }, + select: { id: true }, + }); + + const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route"); + const res = await PATCH( + request(`/api/cycle/day-logs/${dayLog.id}`, "PATCH", { flow: "NONE" }), + { params: Promise.resolve({ id: dayLog.id }) }, + ); + expect(res.status).toBe(200); + const data = (await res.json()).data; + expect(data.restored).toEqual([ + { cycleId: later, startDate: "2026-09-17" }, + ]); + expect(data.id).toBe(dayLog.id); + }); + + it("answers an edit that removes no start with an empty list", async () => { + await startPeriod("2026-09-17"); + const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({ + where: { userId: USER_ID, date: "2026-09-17" }, + select: { id: true }, + }); + const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route"); + const res = await PATCH( + request(`/api/cycle/day-logs/${dayLog.id}`, "PATCH", { flow: "HEAVY" }), + { params: Promise.resolve({ id: dayLog.id }) }, + ); + expect((await res.json()).data.restored).toEqual([]); + }); +});