mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
feat(cycle): name the start a period start folds in and the starts a removal gives back
This commit is contained in:
1 parent
74fa4e10b6
commit
b562587c45
18 files changed
+481
-49
No files matched your search
+3
-1
@@ -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",
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
+3
-1
@@ -9235,7 +9235,9 @@
|
||||
"periodStartSaved": "생리 시작을 저장했어요",
|
||||
"periodEndSaved": "생리 종료를 저장했어요",
|
||||
"startedPeriodOnDate": "이 날 생리를 시작했어요",
|
||||
"endedPeriodOnDate": "이 날 생리가 끝났어요"
|
||||
"endedPeriodOnDate": "이 날 생리가 끝났어요",
|
||||
"startAbsorbed": "생리 시작을 저장했어요. {absorbed}의 시작은 {date}로 합쳐졌어요.",
|
||||
"startRestored": "저장했어요. {dates}의 시작이 다시 표시돼요."
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "생리가 곧 시작될 것 같아요",
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -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 ?? [],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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 ?? [],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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<Response> {
|
||||
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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<CycleDayLogDTO>(`/api/cycle/day-logs/${id}`, patch);
|
||||
return apiPatch<CycleDayLogDTO & { restored?: CycleStartRef[] }>(
|
||||
`/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<unknown>("/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 }),
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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<ZodOpenApiObject["paths"]> = {
|
||||
.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<ZodOpenApiObject["paths"]> = {
|
||||
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<ZodOpenApiObject["paths"]> = {
|
||||
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.",
|
||||
|
||||
@@ -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<string, string> = {},
|
||||
): 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<string> {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user