feat(illness): soft-delete restore route + undo toast

This commit is contained in:
Marc-André Bombeck committed 2026-06-17 14:15:44 +02:00
commit 4cce49374f
12 files changed
+350 -1

No files matched your search

+43
View File
@@ -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:
@@ -18545,6 +18571,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:
+2
View File
@@ -6717,6 +6717,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": {
+2
View File
@@ -6717,6 +6717,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": {
+2
View File
@@ -6717,6 +6717,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": {
+2
View File
@@ -6717,6 +6717,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": {
+2
View File
@@ -6717,6 +6717,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": {
+2
View File
@@ -6717,6 +6717,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": {
@@ -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:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...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<string, unknown> = {}) {
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();
});
});
@@ -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));
}),
);
+16 -1
View File
@@ -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.
}
+14
View File
@@ -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<IllnessEpisodeDTO>(`/api/illness/episodes/${id}/restore`, {}),
onSuccess: invalidate,
});
}
/** Upsert one day-log on an episode. */
export function useUpsertDayLog(episodeId: string) {
const invalidate = useInvalidateIllness();
+24
View File
@@ -389,6 +389,30 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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"],