From 55e7835ef4223b611bdf45a79c164e96240d21c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 17 Jun 2026 14:15:12 +0200 Subject: [PATCH] feat(illness): restore a soft-deleted episode via delete-Undo Add POST /api/illness/episodes/{id}/restore: clears deletedAt so the episode and its preserved day-logs and encrypted note re-surface in every read. Soft-delete keeps the row and its children intact, so restore is a pure flag flip rather than a re-create. apiHandler + idempotency + module gate + owner-scoped 404; restoring a live episode is a no-op. Wire the episode-menu delete to a restore Undo toast, mirroring the labs delete-Undo affordance. Register the route in the OpenAPI source and regenerate the contract. --- docs/api/openapi.yaml | 43 +++++ messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + .../[id]/restore/__tests__/route.test.ts | 169 ++++++++++++++++++ .../illness/episodes/[id]/restore/route.ts | 72 ++++++++ src/components/illness/episode-menu.tsx | 17 +- src/components/illness/use-illness.ts | 14 ++ src/lib/openapi/routes/illness.ts | 24 +++ 12 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 src/app/api/illness/episodes/[id]/restore/__tests__/route.test.ts create mode 100644 src/app/api/illness/episodes/[id]/restore/route.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 84cabb506..ba2dd9c52 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -6448,6 +6448,32 @@ paths: "404": *a11 "422": *a3 "429": *a4 + /api/illness/episodes/{id}/restore: + post: + tags: + - Illness + summary: Restore a soft-deleted illness episode (v1.18.3) + description: Clears `deletedAt`, bringing the episode and its preserved day-logs + encrypted note back into every read. + A soft-delete keeps the row and children intact, so restore is a pure flag flip — nothing is re-created (the + delete-Undo affordance). Idempotent — restoring a live episode is a no-op. Audits as `illness.episode.restore`. + Owner-scoped + born-gated. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Episode restored. + content: + application/json: + schema: + $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" + "401": *a1 + "404": *a11 + "422": *a3 + "429": *a4 /api/illness/episodes/{id}/resolve: patch: tags: @@ -18538,6 +18564,23 @@ components: - data - error additionalProperties: false + RestoreIllnessEpisodeEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/IllnessEpisode" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false ResolveIllnessEpisodeEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index 0021ef660..13dcdc15b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6716,6 +6716,8 @@ "newEpisode": "Neue Episode", "logDay": "Tag erfassen", "markRecovered": "Als genesen markieren", + "deletedToast": "Eintrag gelöscht", + "restoreError": "Eintrag konnte nicht wiederhergestellt werden", "onsetOn": "Begonnen am {date}", "recoveredOn": "genesen {date}", "status": { diff --git a/messages/en.json b/messages/en.json index cbf3de315..2f5ca2488 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6716,6 +6716,8 @@ "newEpisode": "New episode", "logDay": "Log a day", "markRecovered": "Mark recovered", + "deletedToast": "Episode deleted", + "restoreError": "Could not restore the episode", "onsetOn": "Started {date}", "recoveredOn": "recovered {date}", "status": { diff --git a/messages/es.json b/messages/es.json index 4fcfb42c7..f51e2581c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6716,6 +6716,8 @@ "newEpisode": "Nuevo episodio", "logDay": "Registrar un día", "markRecovered": "Marcar como recuperado", + "deletedToast": "Episodio eliminado", + "restoreError": "No se pudo restaurar el episodio", "onsetOn": "Comenzó el {date}", "recoveredOn": "recuperado {date}", "status": { diff --git a/messages/fr.json b/messages/fr.json index c2bdaa342..f6b5f2417 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6716,6 +6716,8 @@ "newEpisode": "Nouvel épisode", "logDay": "Enregistrer un jour", "markRecovered": "Marquer comme rétabli", + "deletedToast": "Épisode supprimé", + "restoreError": "Impossible de restaurer l'épisode", "onsetOn": "Débuté le {date}", "recoveredOn": "rétabli {date}", "status": { diff --git a/messages/it.json b/messages/it.json index 140054cc0..9ac438c4c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6716,6 +6716,8 @@ "newEpisode": "Nuovo episodio", "logDay": "Registra un giorno", "markRecovered": "Segna come guarito", + "deletedToast": "Episodio eliminato", + "restoreError": "Impossibile ripristinare l'episodio", "onsetOn": "Iniziato il {date}", "recoveredOn": "guarito {date}", "status": { diff --git a/messages/pl.json b/messages/pl.json index d799fb13b..ec3760d2e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6716,6 +6716,8 @@ "newEpisode": "Nowy epizod", "logDay": "Zapisz dzień", "markRecovered": "Oznacz jako wyleczone", + "deletedToast": "Wpis usunięty", + "restoreError": "Nie udało się przywrócić wpisu", "onsetOn": "Początek {date}", "recoveredOn": "wyleczono {date}", "status": { diff --git a/src/app/api/illness/episodes/[id]/restore/__tests__/route.test.ts b/src/app/api/illness/episodes/[id]/restore/__tests__/route.test.ts new file mode 100644 index 000000000..a58531548 --- /dev/null +++ b/src/app/api/illness/episodes/[id]/restore/__tests__/route.test.ts @@ -0,0 +1,169 @@ +/** + * v1.18.3 — POST /api/illness/episodes/{id}/restore. + * + * The delete-Undo affordance: clears `deletedAt` so the episode and its + * preserved day-logs re-surface. Owner-scoped (a foreign id 404s, never a + * write), born-gated, and idempotent (restoring a live episode is a no-op). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + illnessEpisode: { + findUnique: vi.fn(), + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + auditLog: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/illness/gate", () => ({ requireIllnessEnabled: vi.fn() })); +vi.mock("@/lib/idempotency", () => ({ + withIdempotency: + (fn: (...args: Args) => Promise) => + (...args: Args) => + fn(...args), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +const PARAMS = { params: Promise.resolve({ id: "ep-1" }) }; + +function liveRow(overrides: Record = {}) { + return { + id: "ep-1", + userId: "user-1", + label: "Erkältung", + type: "INFECTION", + lifecycle: "ACUTE", + onsetAt: new Date("2026-06-01T00:00:00.000Z"), + resolvedAt: null, + parentConditionId: null, + noteEncrypted: null, + createdAt: new Date("2026-06-01T00:00:00.000Z"), + updatedAt: new Date("2026-06-01T00:00:00.000Z"), + deletedAt: null, + ...overrides, + }; +} + +function req(): NextRequest { + return new NextRequest( + "http://localhost/api/illness/episodes/ep-1/restore", + { method: "POST" }, + ); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireIllnessEnabled).mockResolvedValue({ enabled: true }); +}); + +describe("POST /api/illness/episodes/{id}/restore", () => { + it("clears deletedAt on a tombstoned owned episode and returns it", async () => { + vi.mocked(prisma.illnessEpisode.findUnique).mockResolvedValue({ + id: "ep-1", + userId: "user-1", + deletedAt: new Date("2026-06-16T00:00:00.000Z"), + } as never); + vi.mocked(prisma.illnessEpisode.update).mockResolvedValue( + liveRow() as never, + ); + vi.mocked(prisma.illnessEpisode.findUniqueOrThrow).mockResolvedValue( + liveRow() as never, + ); + + const res = await POST(req(), PARAMS); + expect(res.status).toBe(200); + + const body = (await res.json()) as { data: { id: string } }; + expect(body.data.id).toBe("ep-1"); + + // The flag flip clears deletedAt; the row (and its day-logs) come back. + expect(vi.mocked(prisma.illnessEpisode.update)).toHaveBeenCalledWith({ + where: { id: "ep-1" }, + data: { deletedAt: null }, + }); + expect(vi.mocked(auditLog)).toHaveBeenCalledWith( + "illness.episode.restore", + expect.objectContaining({ userId: "user-1" }), + ); + }); + + it("is idempotent — restoring a live episode writes nothing", async () => { + vi.mocked(prisma.illnessEpisode.findUnique).mockResolvedValue({ + id: "ep-1", + userId: "user-1", + deletedAt: null, + } as never); + vi.mocked(prisma.illnessEpisode.findUniqueOrThrow).mockResolvedValue( + liveRow() as never, + ); + + const res = await POST(req(), PARAMS); + expect(res.status).toBe(200); + expect(vi.mocked(prisma.illnessEpisode.update)).not.toHaveBeenCalled(); + expect(vi.mocked(auditLog)).not.toHaveBeenCalled(); + }); + + it("404s for a foreign episode without writing", async () => { + vi.mocked(prisma.illnessEpisode.findUnique).mockResolvedValue({ + id: "ep-1", + userId: "someone-else", + deletedAt: new Date(), + } as never); + + const res = await POST(req(), PARAMS); + expect(res.status).toBe(404); + expect(vi.mocked(prisma.illnessEpisode.update)).not.toHaveBeenCalled(); + }); + + it("404s for an unknown episode", async () => { + vi.mocked(prisma.illnessEpisode.findUnique).mockResolvedValue( + null as never, + ); + + const res = await POST(req(), PARAMS); + expect(res.status).toBe(404); + expect(vi.mocked(prisma.illnessEpisode.update)).not.toHaveBeenCalled(); + }); + + it("refuses when the illness module is disabled", async () => { + vi.mocked(requireIllnessEnabled).mockResolvedValue({ + enabled: false, + response: new Response("nope", { status: 403 }), + } as never); + + const res = await POST(req(), PARAMS); + expect(res.status).toBe(403); + expect(vi.mocked(prisma.illnessEpisode.findUnique)).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/illness/episodes/[id]/restore/route.ts b/src/app/api/illness/episodes/[id]/restore/route.ts new file mode 100644 index 000000000..ce17f9773 --- /dev/null +++ b/src/app/api/illness/episodes/[id]/restore/route.ts @@ -0,0 +1,72 @@ +/** + * `POST /api/illness/episodes/{id}/restore` — un-tombstone a soft-deleted + * episode (the delete-Undo affordance, v1.18.3). + * + * Clears `deletedAt`, so the episode — and its preserved day-logs + encrypted + * note — re-surface in every read. A soft-delete keeps the row and its + * children intact, so restore is a pure flag flip; nothing is re-created. + * + * Born-gated + owner-scoped + idempotent: restoring a live episode is a + * no-op, and a foreign / unknown id seals existence as 404. This answers the + * iOS restore-vs-re-create question (healthlog-iOS#30) — restore wins. + */ +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, apiError, getClientIp } from "@/lib/api-response"; +import { withIdempotency } from "@/lib/idempotency"; +import { requireIllnessEnabled } from "@/lib/illness/gate"; +import { toIllnessEpisodeDTO } from "@/lib/illness/dto"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const POST = apiHandler( + withIdempotency<[NextRequest, RouteParams]>(async (request, { params }) => { + const { user } = await requireAuth(); + + const gate = await requireIllnessEnabled(user.id); + if (!gate.enabled) return gate.response; + + const { id } = await params; + const existing = await prisma.illnessEpisode.findUnique({ + where: { id }, + select: { id: true, userId: true, deletedAt: true }, + }); + // Note: no `deletedAt` filter on the existence check — the row we are + // restoring is precisely a tombstoned one. Owner-scoped 404 either way. + if (!existing || existing.userId !== user.id) { + return apiError("Episode not found", 404); + } + + // Idempotent: only flip a currently-tombstoned row. Restoring a live + // episode is a no-op (no audit, no write). + if (existing.deletedAt !== null) { + await prisma.illnessEpisode.update({ + where: { id }, + data: { deletedAt: null }, + }); + await auditLog("illness.episode.restore", { + userId: user.id, + ipAddress: getClientIp(request), + details: { episodeId: id }, + }); + } + + const restored = await prisma.illnessEpisode.findUniqueOrThrow({ + where: { id }, + }); + + annotate({ + action: { + name: "illness.episode.restore", + entity_type: "illness_episode", + entity_id: id, + }, + }); + + return apiSuccess(toIllnessEpisodeDTO(restored)); + }), +); diff --git a/src/components/illness/episode-menu.tsx b/src/components/illness/episode-menu.tsx index 6fe24c37e..e938f9336 100644 --- a/src/components/illness/episode-menu.tsx +++ b/src/components/illness/episode-menu.tsx @@ -9,6 +9,7 @@ */ import { useState } from "react"; import { MoreVertical, Pencil, CheckCircle2, Trash2 } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -30,7 +31,7 @@ import { } from "@/components/ui/alert-dialog"; import { useTranslations } from "@/lib/i18n/context"; -import { useDeleteEpisode } from "./use-illness"; +import { useDeleteEpisode, useRestoreEpisode } from "./use-illness"; import type { IllnessEpisodeDTO } from "./types"; export interface EpisodeMenuProps { @@ -52,6 +53,7 @@ export function EpisodeMenu({ }: EpisodeMenuProps) { const { t } = useTranslations(); const del = useDeleteEpisode(); + const restore = useRestoreEpisode(); const [confirmOpen, setConfirmOpen] = useState(false); async function handleConfirmDelete() { @@ -59,6 +61,19 @@ export function EpisodeMenu({ await del.mutateAsync(episode.id); setConfirmOpen(false); onDeleted?.(); + // Soft-delete keeps the episode + its day-logs intact, so the delete is + // reversible. Surface an Undo toast wired to the restore route (the labs + // delete+undo UX). + toast.success(t("illness.deletedToast"), { + action: { + label: t("common.undo"), + onClick: () => { + restore.mutate(episode.id, { + onError: () => toast.error(t("illness.restoreError")), + }); + }, + }, + }); } catch { // Keep the dialog open; the list will surface any error on next read. } diff --git a/src/components/illness/use-illness.ts b/src/components/illness/use-illness.ts index 850136b3f..bf135726e 100644 --- a/src/components/illness/use-illness.ts +++ b/src/components/illness/use-illness.ts @@ -148,6 +148,20 @@ export function useDeleteEpisode() { }); } +/** + * Restore a soft-deleted episode (the delete-Undo affordance). Clears + * `deletedAt` server-side, bringing the episode and its preserved day-logs + * back. Idempotent + owner-scoped. + */ +export function useRestoreEpisode() { + const invalidate = useInvalidateIllness(); + return useMutation({ + mutationFn: (id: string) => + apiPost(`/api/illness/episodes/${id}/restore`, {}), + onSuccess: invalidate, + }); +} + /** Upsert one day-log on an episode. */ export function useUpsertDayLog(episodeId: string) { const invalidate = useInvalidateIllness(); diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index 1c9c9d62c..7e56e2c04 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -389,6 +389,30 @@ export const illnessPaths: NonNullable = { }, }, }, + "/api/illness/episodes/{id}/restore": { + post: { + tags: ["Illness"], + summary: "Restore a soft-deleted illness episode (v1.18.3)", + description: + "Clears `deletedAt`, bringing the episode and its preserved day-logs + encrypted note back into every read. A soft-delete keeps the row and children intact, so restore is a pure flag flip — nothing is re-created (the delete-Undo affordance). Idempotent — restoring a live episode is a no-op. Audits as `illness.episode.restore`. Owner-scoped + born-gated.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "Episode restored.", + content: { + "application/json": { + schema: dataEnvelope( + illnessEpisode, + "RestoreIllnessEpisodeEnvelope", + ), + }, + }, + }, + ...episodeNotFound, + ...stdResponses, + }, + }, + }, "/api/illness/episodes/{id}/resolve": { patch: { tags: ["Illness"],