mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +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
+100
-2
@@ -13972,7 +13972,8 @@ paths:
|
||||
tags:
|
||||
- Custom metrics
|
||||
summary: Delete a logged value
|
||||
description: Hard-deletes the value. Audits as `customMetricEntry.delete`.
|
||||
description: Tombstones the value (soft delete, v1.37.20); the sibling entries/restore endpoint clears the tombstone.
|
||||
Audits as `customMetricEntry.delete`.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -13995,6 +13996,36 @@ paths:
|
||||
"404": *a25
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/custom-metrics/{id}/entries/restore:
|
||||
post:
|
||||
tags:
|
||||
- Custom metrics
|
||||
summary: Restore tombstoned values (v1.37.20)
|
||||
description: Clears deletedAt on every owned, currently-tombstoned entry of this metric named in ids (1..200). A
|
||||
foreign, live or mismatched-parent id is a silent no-op, never an existence leak. Backs the delete toast's Undo.
|
||||
Audits as `customMetricEntry.restore`.
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RestoreCustomMetricEntriesRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Count of entries restored.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/RestoreCustomMetricEntriesResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/onboarding/disclaimer:
|
||||
post:
|
||||
tags:
|
||||
@@ -18483,6 +18514,18 @@ components:
|
||||
- de
|
||||
required:
|
||||
- message
|
||||
RestoreCustomMetricEntriesRequest:
|
||||
type: object
|
||||
properties:
|
||||
ids:
|
||||
minItems: 1
|
||||
maxItems: 200
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
required:
|
||||
- ids
|
||||
DisclaimerAckRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -39690,7 +39733,8 @@ components:
|
||||
additionalProperties: false
|
||||
description: One completed screener administration. `severityBand` is a descriptive label for the screen (never a
|
||||
diagnosis). `item9Flagged` is true when the PHQ-9 self-harm item was answered > 0 — the client MUST show crisis
|
||||
resources when it is. Raw per-item answers are intentionally absent.
|
||||
resources when it is. Raw per-item answers are intentionally absent from list rows; the by-id detail read
|
||||
returns them decrypted.
|
||||
CreateMentalHealthAssessmentEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -41414,6 +41458,31 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
RestoreCustomMetricEntriesResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
restored:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- restored
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
DisclaimerAckEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -41803,6 +41872,12 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/MetricFreshnessEntry"
|
||||
syncProgress:
|
||||
description: Backfill/sync progress summary. Present on the GET read (null when the progress read failed); omitted from
|
||||
the PATCH echo.
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/AppleHealthSyncProgress"
|
||||
- type: "null"
|
||||
required:
|
||||
- entries
|
||||
- lastSyncedAt
|
||||
@@ -41886,6 +41961,29 @@ components:
|
||||
additionalProperties: false
|
||||
description: Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually
|
||||
delivered appear — absence is absence, never an invented row.
|
||||
AppleHealthSyncProgress:
|
||||
type: object
|
||||
properties:
|
||||
recordsAccepted:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Measurement + workout rows carrying the `APPLE_HEALTH` source (live batch ingest and the one-shot export
|
||||
import both write it). Soft-deleted measurement rows are excluded.
|
||||
oldestMeasuredAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
description: Earliest instant reached across those rows (`measuredAt` for measurements, `startedAt` for workouts); null
|
||||
when nothing has arrived. During a first-run backfill this walks backwards as history lands.
|
||||
required:
|
||||
- recordsAccepted
|
||||
- oldestMeasuredAt
|
||||
additionalProperties: false
|
||||
description: "Server-side Apple Health backfill progress: how many rows have been accepted and how far back in time they
|
||||
reach. Only what the server actually knows — no ETA, no device-side queue state."
|
||||
HealthKitConfigPatchEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+5
-1
@@ -9053,7 +9053,9 @@
|
||||
"flaggedBadge": "Hilfe angezeigt",
|
||||
"chartTitle": "Punkteverlauf",
|
||||
"totalLabel": "Punkte",
|
||||
"bandLabel": "Einstufung"
|
||||
"bandLabel": "Einstufung",
|
||||
"answersToggle": "Antworten",
|
||||
"answersUnavailable": "Die gespeicherten Antworten dieses Check-ins können nicht entschlüsselt werden."
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Du musst das nicht allein durchstehen",
|
||||
@@ -9708,6 +9710,8 @@
|
||||
"deleteConfirmTitle": "Diese eigene Metrik löschen?",
|
||||
"deleteConfirmDescription": "Die Metrik wird aus deiner Liste entfernt. Erfasste Werte bleiben erhalten.",
|
||||
"deletedToast": "Eigene Metrik gelöscht",
|
||||
"restoredToast": "Wert wiederhergestellt",
|
||||
"restoreError": "Wiederherstellen fehlgeschlagen",
|
||||
"deleteError": "Eigene Metrik konnte nicht gelöscht werden",
|
||||
"showAllValues": "Alle Werte anzeigen",
|
||||
"emptyDescription": "Definiere eigene Messwerte, um sie zu verfolgen und darzustellen.",
|
||||
|
||||
+5
-1
@@ -9053,7 +9053,9 @@
|
||||
"flaggedBadge": "Support shown",
|
||||
"chartTitle": "Score trend",
|
||||
"totalLabel": "Score",
|
||||
"bandLabel": "Category"
|
||||
"bandLabel": "Category",
|
||||
"answersToggle": "Answers",
|
||||
"answersUnavailable": "The stored answers for this check-in cannot be decrypted."
|
||||
},
|
||||
"crisis": {
|
||||
"title": "You don't have to face this alone",
|
||||
@@ -9708,6 +9710,8 @@
|
||||
"deleteConfirmTitle": "Delete this custom metric?",
|
||||
"deleteConfirmDescription": "The metric is removed from your list. Its logged values are kept.",
|
||||
"deletedToast": "Custom metric deleted",
|
||||
"restoredToast": "Value restored",
|
||||
"restoreError": "Restore failed",
|
||||
"deleteError": "Could not delete the custom metric",
|
||||
"showAllValues": "Show all values",
|
||||
"emptyDescription": "Define your own measurements to track and chart them.",
|
||||
|
||||
+5
-1
@@ -9053,7 +9053,9 @@
|
||||
"flaggedBadge": "Apoyo mostrado",
|
||||
"chartTitle": "Evolución de la puntuación",
|
||||
"totalLabel": "Puntuación",
|
||||
"bandLabel": "Categoría"
|
||||
"bandLabel": "Categoría",
|
||||
"answersToggle": "Respuestas",
|
||||
"answersUnavailable": "Las respuestas guardadas de esta revisión no se pueden descifrar."
|
||||
},
|
||||
"crisis": {
|
||||
"title": "No tienes que afrontar esto a solas",
|
||||
@@ -9708,6 +9710,8 @@
|
||||
"deleteConfirmTitle": "¿Eliminar esta métrica personalizada?",
|
||||
"deleteConfirmDescription": "La métrica se quita de tu lista. Los valores registrados se conservan.",
|
||||
"deletedToast": "Métrica personalizada eliminada",
|
||||
"restoredToast": "Valor restaurado",
|
||||
"restoreError": "No se pudo restaurar",
|
||||
"deleteError": "No se pudo eliminar la métrica personalizada",
|
||||
"showAllValues": "Ver todos los valores",
|
||||
"emptyDescription": "Define tus propias mediciones para registrarlas y representarlas.",
|
||||
|
||||
+5
-1
@@ -9053,7 +9053,9 @@
|
||||
"flaggedBadge": "Aide affichée",
|
||||
"chartTitle": "Évolution du score",
|
||||
"totalLabel": "Score",
|
||||
"bandLabel": "Catégorie"
|
||||
"bandLabel": "Catégorie",
|
||||
"answersToggle": "Réponses",
|
||||
"answersUnavailable": "Les réponses enregistrées de ce bilan ne peuvent pas être déchiffrées."
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Tu n'as pas à affronter cela seul(e)",
|
||||
@@ -9708,6 +9710,8 @@
|
||||
"deleteConfirmTitle": "Supprimer cette mesure personnalisée ?",
|
||||
"deleteConfirmDescription": "La mesure est retirée de votre liste. Les valeurs enregistrées sont conservées.",
|
||||
"deletedToast": "Mesure personnalisée supprimée",
|
||||
"restoredToast": "Valeur restaurée",
|
||||
"restoreError": "Échec de la restauration",
|
||||
"deleteError": "Impossible de supprimer la mesure personnalisée",
|
||||
"showAllValues": "Afficher toutes les valeurs",
|
||||
"emptyDescription": "Définissez vos propres mesures pour les suivre et les représenter.",
|
||||
|
||||
+5
-1
@@ -9053,7 +9053,9 @@
|
||||
"flaggedBadge": "Supporto mostrato",
|
||||
"chartTitle": "Andamento del punteggio",
|
||||
"totalLabel": "Punteggio",
|
||||
"bandLabel": "Categoria"
|
||||
"bandLabel": "Categoria",
|
||||
"answersToggle": "Risposte",
|
||||
"answersUnavailable": "Le risposte salvate di questo check-in non possono essere decifrate."
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Non devi affrontare tutto questo da solo/a",
|
||||
@@ -9708,6 +9710,8 @@
|
||||
"deleteConfirmTitle": "Eliminare questa metrica personalizzata?",
|
||||
"deleteConfirmDescription": "La metrica viene rimossa dal tuo elenco. I valori registrati vengono mantenuti.",
|
||||
"deletedToast": "Metrica personalizzata eliminata",
|
||||
"restoredToast": "Valore ripristinato",
|
||||
"restoreError": "Ripristino non riuscito",
|
||||
"deleteError": "Impossibile eliminare la metrica personalizzata",
|
||||
"showAllValues": "Mostra tutti i valori",
|
||||
"emptyDescription": "Definisci le tue misurazioni per monitorarle e rappresentarle.",
|
||||
|
||||
+5
-1
@@ -9053,7 +9053,9 @@
|
||||
"flaggedBadge": "Pokazano wsparcie",
|
||||
"chartTitle": "Przebieg wyniku",
|
||||
"totalLabel": "Wynik",
|
||||
"bandLabel": "Kategoria"
|
||||
"bandLabel": "Kategoria",
|
||||
"answersToggle": "Odpowiedzi",
|
||||
"answersUnavailable": "Nie można odszyfrować zapisanych odpowiedzi tego sprawdzenia."
|
||||
},
|
||||
"crisis": {
|
||||
"title": "Nie musisz mierzyć się z tym sam(a)",
|
||||
@@ -9708,6 +9710,8 @@
|
||||
"deleteConfirmTitle": "Usunąć tę metrykę własną?",
|
||||
"deleteConfirmDescription": "Metryka zostanie usunięta z listy. Zapisane wartości pozostaną.",
|
||||
"deletedToast": "Usunięto metrykę własną",
|
||||
"restoredToast": "Przywrócono wartość",
|
||||
"restoreError": "Nie udało się przywrócić",
|
||||
"deleteError": "Nie udało się usunąć metryki własnej",
|
||||
"showAllValues": "Pokaż wszystkie wartości",
|
||||
"emptyDescription": "Zdefiniuj własne pomiary, aby je śledzić i przedstawiać na wykresie.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTableSort } from "@/hooks/use-table-sort";
|
||||
import { ApiError, apiDelete, apiPatch } from "@/lib/api/api-fetch";
|
||||
import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { formatDateShort } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -48,8 +48,8 @@ function parseDecimal(raw: string): number | null {
|
||||
|
||||
/**
|
||||
* v1.25.5 — reverse-chronological value history for one custom metric, with
|
||||
* full edit + delete. An edit overwrites in place; delete hard-deletes (the
|
||||
* value store has no soft-delete tier).
|
||||
* full edit + delete. An edit overwrites in place; delete tombstones with an
|
||||
* Undo toast (v1.37.20, A3-11 — parity with every other entry surface).
|
||||
*/
|
||||
export function CustomMetricHistoryList({
|
||||
customMetricId,
|
||||
@@ -87,12 +87,31 @@ export function CustomMetricHistoryList({
|
||||
});
|
||||
}
|
||||
|
||||
// v1.37.20 (A3-11) — undo parity with every peer entry surface: the
|
||||
// delete tombstones server-side and the toast offers the restore.
|
||||
async function restoreEntry(id: string) {
|
||||
try {
|
||||
await apiPost(`/api/custom-metrics/${customMetricId}/entries/restore`, {
|
||||
ids: [id],
|
||||
});
|
||||
invalidate();
|
||||
toast.success(t("customMetrics.entry.restoredToast"));
|
||||
} catch {
|
||||
toast.error(t("customMetrics.entry.restoreError"));
|
||||
}
|
||||
}
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiDelete(`/api/custom-metrics/${customMetricId}/entries/${id}`),
|
||||
onSuccess: () => {
|
||||
onSuccess: (_data, id) => {
|
||||
invalidate();
|
||||
toast.success(t("customMetrics.entry.deletedToast"));
|
||||
toast.success(t("customMetrics.entry.deletedToast"), {
|
||||
action: {
|
||||
label: t("common.undo"),
|
||||
onClick: () => void restoreEntry(id),
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: () => toast.error(t("customMetrics.entry.deleteError")),
|
||||
});
|
||||
|
||||
@@ -340,7 +340,7 @@ export const customMetricPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Custom metrics"],
|
||||
summary: "Delete a logged value",
|
||||
description:
|
||||
"Hard-deletes the value. Audits as `customMetricEntry.delete`.",
|
||||
"Tombstones the value (soft delete, v1.37.20); the sibling entries/restore endpoint clears the tombstone. Audits as `customMetricEntry.delete`.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string(), entryId: z.string() }),
|
||||
},
|
||||
@@ -361,4 +361,37 @@ export const customMetricPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/custom-metrics/{id}/entries/restore": {
|
||||
post: {
|
||||
tags: ["Custom metrics"],
|
||||
summary: "Restore tombstoned values (v1.37.20)",
|
||||
description:
|
||||
"Clears deletedAt on every owned, currently-tombstoned entry of this metric named in ids (1..200). A foreign, live or mismatched-parent id is a silent no-op, never an existence leak. Backs the delete toast's Undo. Audits as `customMetricEntry.restore`.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z
|
||||
.object({ ids: z.array(z.string().min(1)).min(1).max(200) })
|
||||
.meta({ id: "RestoreCustomMetricEntriesRequest" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Count of entries restored.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ restored: z.number().int() }),
|
||||
"RestoreCustomMetricEntriesResponse",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user