diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 64f302d78..a3fbda2be 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index 604e98166..2c836d87a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index 846315bde..3e30da5f2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index ba5f61255..72134f305 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index 2035de369..ce9f99f5e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index a3ffb3c95..f24b77f37 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/pl.json b/messages/pl.json index 5d494ecd6..cbc72c276 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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.", diff --git a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts index 84c60839d..aaaa0b7ff 100644 --- a/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/[entryId]/route.ts @@ -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, diff --git a/src/app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts b/src/app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts new file mode 100644 index 000000000..735060c17 --- /dev/null +++ b/src/app/api/custom-metrics/[id]/entries/__tests__/soft-delete-restore.test.ts @@ -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(); + }); +}); diff --git a/src/app/api/custom-metrics/[id]/entries/restore/route.ts b/src/app/api/custom-metrics/[id]/entries/restore/route.ts new file mode 100644 index 000000000..1ff0bea2e --- /dev/null +++ b/src/app/api/custom-metrics/[id]/entries/restore/route.ts @@ -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 }); + }, +); diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts index 2defc447f..71a54f7e0 100644 --- a/src/app/api/custom-metrics/[id]/entries/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/route.ts @@ -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({ diff --git a/src/app/api/custom-metrics/route.ts b/src/app/api/custom-metrics/route.ts index e3c0d948d..d4d98b477 100644 --- a/src/app/api/custom-metrics/route.ts +++ b/src/app/api/custom-metrics/route.ts @@ -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; diff --git a/src/components/custom-metrics/custom-metric-history-list.tsx b/src/components/custom-metrics/custom-metric-history-list.tsx index b3ffd4f94..ee09485b1 100644 --- a/src/components/custom-metrics/custom-metric-history-list.tsx +++ b/src/components/custom-metrics/custom-metric-history-list.tsx @@ -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")), }); diff --git a/src/lib/openapi/routes/custom-metrics.ts b/src/lib/openapi/routes/custom-metrics.ts index ae6d2183b..a150d98f8 100644 --- a/src/lib/openapi/routes/custom-metrics.ts +++ b/src/lib/openapi/routes/custom-metrics.ts @@ -340,7 +340,7 @@ export const customMetricPaths: NonNullable = { 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 = { }, }, }, + "/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, + }, + }, + }, };