mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Give custom-metric values the same second chance as every other entry
Refs A3-11. Deleting a logged custom-metric value was the one hard delete left on an entry surface — everywhere else a deletion tombstones and the toast offers Undo. The DELETE verb now stamps deletedAt, every read (list, latest-value, counts, revive check) filters the tombstone, and the new entries/restore endpoint clears it scoped to owner and parent metric — a foreign, live or mismatched id is a silent no-op, never an existence leak. The history table's delete toast carries the Undo action.
This commit is contained in:
1 parent
689cacc039
commit
7bf8dbd8af
14 files changed
+467
-23
No files matched your search
@@ -22,8 +22,11 @@ import { updateCustomMetricEntrySchema } from "@/lib/validations/custom-metrics"
|
||||
*
|
||||
* PATCH applies a partial edit (`data` built field-by-field; an explicit `null`
|
||||
* on `note` clears it, an omitted key leaves the column untouched). DELETE
|
||||
* hard-deletes the value. Every read/write is narrowed by both `userId` and the
|
||||
* parent `customMetricId`; a cross-user or mismatched-parent id surfaces as 404.
|
||||
* tombstones the value (v1.37.20, A3-11 — undo parity with every peer entry
|
||||
* surface; the sibling `restore` route clears the tombstone for the toast's
|
||||
* Undo). Every read/write is narrowed by both `userId` and the parent
|
||||
* `customMetricId`; a cross-user, tombstoned or mismatched-parent id surfaces
|
||||
* as 404.
|
||||
*/
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string; entryId: string }> };
|
||||
@@ -34,7 +37,12 @@ export const PATCH = apiHandler(
|
||||
const { id, entryId } = await params;
|
||||
|
||||
const existing = await prisma.customMetricEntry.findFirst({
|
||||
where: { id: entryId, userId: user.id, customMetricId: id },
|
||||
where: {
|
||||
id: entryId,
|
||||
userId: user.id,
|
||||
customMetricId: id,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
return apiError("Custom metric entry not found", 404);
|
||||
@@ -104,13 +112,22 @@ export const DELETE = apiHandler(
|
||||
const { id, entryId } = await params;
|
||||
|
||||
const existing = await prisma.customMetricEntry.findFirst({
|
||||
where: { id: entryId, userId: user.id, customMetricId: id },
|
||||
where: {
|
||||
id: entryId,
|
||||
userId: user.id,
|
||||
customMetricId: id,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
if (!existing) {
|
||||
return apiError("Custom metric entry not found", 404);
|
||||
}
|
||||
|
||||
await prisma.customMetricEntry.delete({ where: { id: entryId } });
|
||||
// Tombstone, not a hard delete: the Undo toast's restore clears it.
|
||||
await prisma.customMetricEntry.update({
|
||||
where: { id: entryId },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
await auditLog("customMetricEntry.delete", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* v1.37.20 (A3-11) — custom-metric entries move from hard delete to the
|
||||
* tombstone + Undo contract every peer entry surface carries.
|
||||
*
|
||||
* Watched red: before the conversion the DELETE verb called
|
||||
* `customMetricEntry.delete` and no restore route existed — the first two
|
||||
* suites below fail against that state (no `update` with a `deletedAt`
|
||||
* stamp; module not found), which is the proof the assertions bite.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
customMetricEntry: {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", () => ({
|
||||
invalidateUserCorrelationPatterns: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { DELETE } from "../[entryId]/route";
|
||||
import { POST as RESTORE } from "../restore/route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "u", role: "USER" as const, locale: "en" },
|
||||
};
|
||||
|
||||
const ENTRY = {
|
||||
id: "e-1",
|
||||
userId: "user-1",
|
||||
customMetricId: "cm-1",
|
||||
value: 42,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-06-10T00:00:00.000Z"),
|
||||
note: null,
|
||||
createdAt: new Date("2026-06-10T00:00:00.000Z"),
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
});
|
||||
|
||||
describe("DELETE /api/custom-metrics/[id]/entries/[entryId]", () => {
|
||||
it("tombstones instead of hard-deleting", async () => {
|
||||
vi.mocked(prisma.customMetricEntry.findFirst).mockResolvedValue(
|
||||
ENTRY as never,
|
||||
);
|
||||
vi.mocked(prisma.customMetricEntry.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/custom-metrics/cm-1/entries/e-1", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "cm-1", entryId: "e-1" }) },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.customMetricEntry.delete).not.toHaveBeenCalled();
|
||||
const call = vi.mocked(prisma.customMetricEntry.update).mock.calls[0][0];
|
||||
expect(call.where).toEqual({ id: "e-1" });
|
||||
expect(call.data.deletedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("404s an already-tombstoned entry (the lookup filters the tombstone)", async () => {
|
||||
vi.mocked(prisma.customMetricEntry.findFirst).mockResolvedValue(null);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/custom-metrics/cm-1/entries/e-1", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "cm-1", entryId: "e-1" }) },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
const where = vi.mocked(prisma.customMetricEntry.findFirst).mock.calls[0][0]
|
||||
.where;
|
||||
expect(where.deletedAt).toBeNull();
|
||||
expect(prisma.customMetricEntry.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/custom-metrics/[id]/entries/restore", () => {
|
||||
function restoreRequest(body: unknown): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/custom-metrics/cm-1/entries/restore",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
it("clears the tombstone scoped to owner + parent metric", async () => {
|
||||
vi.mocked(prisma.customMetricEntry.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
|
||||
const res = await RESTORE(restoreRequest({ ids: ["e-1"] }), {
|
||||
params: Promise.resolve({ id: "cm-1" }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual({ restored: 1 });
|
||||
const call = vi.mocked(prisma.customMetricEntry.updateMany).mock
|
||||
.calls[0][0];
|
||||
expect(call.where).toEqual({
|
||||
id: { in: ["e-1"] },
|
||||
userId: "user-1",
|
||||
customMetricId: "cm-1",
|
||||
deletedAt: { not: null },
|
||||
});
|
||||
expect(call.data).toEqual({ deletedAt: null });
|
||||
});
|
||||
|
||||
it("answers restored: 0 for foreign / live ids — a silent no-op, not a leak", async () => {
|
||||
vi.mocked(prisma.customMetricEntry.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const res = await RESTORE(restoreRequest({ ids: ["not-mine"] }), {
|
||||
params: Promise.resolve({ id: "cm-1" }),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual({ restored: 0 });
|
||||
});
|
||||
|
||||
it("422s an empty id list", async () => {
|
||||
const res = await RESTORE(restoreRequest({ ids: [] }), {
|
||||
params: Promise.resolve({ id: "cm-1" }),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.customMetricEntry.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* `POST /api/custom-metrics/{id}/entries/restore` — un-tombstone for the
|
||||
* entry delete-Undo affordance (v1.37.20, A3-11).
|
||||
*
|
||||
* Body: `{ ids: string[] }` — 1..200 entry ids, scoped to the caller and the
|
||||
* parent metric.
|
||||
*
|
||||
* Clears `deletedAt` on every owned, currently-tombstoned row of THIS metric
|
||||
* in one `updateMany`, so the value re-surfaces in normal reads. A forged /
|
||||
* foreign / live / mismatched-parent id is a silent no-op (never a 404
|
||||
* existence leak): the mutation `where` pins `userId` and `customMetricId`,
|
||||
* so it only ever touches the caller's rows. Mirrors `/api/labs/restore`.
|
||||
* Same session auth as the delete beside it — custom-metric entry writes are
|
||||
* not delegable.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
const MAX_IDS_PER_BATCH = 200;
|
||||
|
||||
const restoreSchema = z.object({
|
||||
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
|
||||
});
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = restoreSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { count } = await prisma.customMetricEntry.updateMany({
|
||||
where: {
|
||||
id: { in: parsed.data.ids },
|
||||
userId: user.id,
|
||||
customMetricId: id,
|
||||
deletedAt: { not: null },
|
||||
},
|
||||
data: { deletedAt: null },
|
||||
});
|
||||
|
||||
if (count > 0) invalidateUserCorrelationPatterns(user.id);
|
||||
|
||||
await auditLog("customMetricEntry.restore", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { customMetricId: id, restored: count },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "custom-metric.entry.restore" },
|
||||
meta: { customMetricId: id, restored: count },
|
||||
});
|
||||
|
||||
return apiSuccess({ restored: count });
|
||||
},
|
||||
);
|
||||
@@ -57,7 +57,7 @@ export const GET = apiHandler(
|
||||
}
|
||||
|
||||
const { limit, offset, sortDir } = parsed.data;
|
||||
const where = { userId: user.id, customMetricId: id };
|
||||
const where = { userId: user.id, customMetricId: id, deletedAt: null };
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.customMetricEntry.findMany({
|
||||
|
||||
@@ -36,11 +36,12 @@ export const GET = apiHandler(async () => {
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
entries: {
|
||||
where: { deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
take: 1,
|
||||
select: { value: true, unit: true, measuredAt: true },
|
||||
},
|
||||
_count: { select: { entries: true } },
|
||||
_count: { select: { entries: { where: { deletedAt: null } } } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -164,12 +165,12 @@ async function postCustomMetric(request: NextRequest) {
|
||||
if (existing) {
|
||||
const [latestEntry, count] = await Promise.all([
|
||||
prisma.customMetricEntry.findFirst({
|
||||
where: { userId: user.id, customMetricId: created.id },
|
||||
where: { userId: user.id, customMetricId: created.id, deletedAt: null },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { value: true, unit: true, measuredAt: true },
|
||||
}),
|
||||
prisma.customMetricEntry.count({
|
||||
where: { userId: user.id, customMetricId: created.id },
|
||||
where: { userId: user.id, customMetricId: created.id, deletedAt: null },
|
||||
}),
|
||||
]);
|
||||
latest = latestEntry ?? null;
|
||||
|
||||
Reference in new issue
Block a user