mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
This commit is contained in:
1 parent
c5fd21b8a6
commit
55e7835ef4
12 files changed
+350
-1
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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));
|
||||
}),
|
||||
);
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user