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:
Marc-André Bombeck committed 2026-08-14 09:34:43 +02:00
1 parent 689cacc039
commit 7bf8dbd8af
14 files changed
+467 -23

No files matched your search

+100 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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({
+4 -3
View File
@@ -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")),
});
+34 -1
View File
@@ -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,
},
},
},
};