From 2fd62cc115065777f380b2273260839cfb5994a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:05:06 +0200 Subject: [PATCH 01/10] feat(api): carry the request id in meta.requestId on every error envelope --- src/__tests__/helpers/error-envelope.ts | 35 +++++ .../login/__tests__/constant-work.test.ts | 3 +- .../inbound/[id]/chat/__tests__/route.test.ts | 1 + .../[id]/extract/__tests__/route.test.ts | 1 + .../inbound/reindex/__tests__/route.test.ts | 1 + .../intake/import/__tests__/route.test.ts | 1 + .../__tests__/route.test.ts | 1 + .../__tests__/email-test-route.test.ts | 8 +- .../__tests__/private-origin-routes.test.ts | 46 ++++-- .../__tests__/acting-account-resolver.test.ts | 17 ++- src/lib/__tests__/ai-refusal-envelope.test.ts | 1 + .../api-handler-error-request-id.test.ts | 140 ++++++++++++++++++ .../api-handler-key-mismatch.test.ts | 5 +- src/lib/__tests__/auth-error-codes.test.ts | 6 +- src/lib/api-handler.ts | 9 +- src/lib/api-response.ts | 63 ++++++++ .../acting-account-resolver.test.ts | 5 +- .../ai-extraction-capability.test.ts | 6 + .../integration/encryption-key-check.test.ts | 6 +- .../login-insecure-transport.test.ts | 1 + tests/integration/sharing-lifecycle.test.ts | 5 +- tests/integration/sharing-scope-fence.test.ts | 5 +- 22 files changed, 336 insertions(+), 30 deletions(-) create mode 100644 src/__tests__/helpers/error-envelope.ts create mode 100644 src/lib/__tests__/api-handler-error-request-id.test.ts diff --git a/src/__tests__/helpers/error-envelope.ts b/src/__tests__/helpers/error-envelope.ts new file mode 100644 index 000000000..960c78856 --- /dev/null +++ b/src/__tests__/helpers/error-envelope.ts @@ -0,0 +1,35 @@ +/** + * Error-envelope helpers for tests that compare whole response bodies. + * + * `apiHandler` stamps every error envelope with `meta.requestId`, a fresh id + * per request. A test that proves two refusals are byte-identical (an + * enumeration oracle check) compares everything else: the id is random, the + * same for no two requests, and says nothing about the account. + */ + +/** The body text with `meta.requestId` removed, `meta` dropped when empty. */ +export function withoutRequestId(text: string): string { + let body: unknown; + try { + body = JSON.parse(text); + } catch { + return text; + } + if (typeof body !== "object" || body === null || Array.isArray(body)) { + return text; + } + const record = { ...(body as Record) }; + const meta = record.meta; + if (typeof meta === "object" && meta !== null && !Array.isArray(meta)) { + const rest = { ...(meta as Record) }; + delete rest.requestId; + if (Object.keys(rest).length === 0) delete record.meta; + else record.meta = rest; + } + return JSON.stringify(record); +} + +/** A response's body text with `meta.requestId` removed. */ +export async function bodyWithoutRequestId(res: Response): Promise { + return withoutRequestId(await res.text()); +} diff --git a/src/app/api/auth/login/__tests__/constant-work.test.ts b/src/app/api/auth/login/__tests__/constant-work.test.ts index 6490b756a..a41edac7b 100644 --- a/src/app/api/auth/login/__tests__/constant-work.test.ts +++ b/src/app/api/auth/login/__tests__/constant-work.test.ts @@ -80,6 +80,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { verifyPasswordOrDummy } from "@/lib/auth/password"; +import { bodyWithoutRequestId } from "@/__tests__/helpers/error-envelope"; function makeRequest(): NextRequest { return new NextRequest("http://localhost/api/auth/login", { @@ -159,7 +160,7 @@ describe("POST /api/auth/login — constant work across the refusals", () => { vi.mocked(prisma.user.findFirst).mockResolvedValue(account as never); const res = await POST(makeRequest()); statuses.push(res.status); - bodies.push(await res.text()); + bodies.push(await bodyWithoutRequestId(res)); } expect(new Set(statuses)).toEqual(new Set([401])); expect(new Set(bodies).size).toBe(1); diff --git a/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts index c04152cc4..1cee38bac 100644 --- a/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/chat/__tests__/route.test.ts @@ -421,6 +421,7 @@ describe("POST — consent / budget gating", () => { expect(res.status).toBe(403); const body = await res.json(); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "consent.ai.required", capability: "documentAi", reason: "consent_required", diff --git a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts index a03e75fe6..1f143900a 100644 --- a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts @@ -371,6 +371,7 @@ describe("POST /api/documents/inbound/[id]/extract — the documentAi capability expect(res.status).toBe(403); const body = await res.json(); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "assistant.disabled.documentAi", capability: "documentAi", reason: "operator_disabled", diff --git a/src/app/api/documents/inbound/reindex/__tests__/route.test.ts b/src/app/api/documents/inbound/reindex/__tests__/route.test.ts index bcb3dc018..dcdaad6ec 100644 --- a/src/app/api/documents/inbound/reindex/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/reindex/__tests__/route.test.ts @@ -137,6 +137,7 @@ describe("POST /api/documents/inbound/reindex — the documentAi capability", () expect(res.status).toBe(403); const body = await res.json(); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "assistant.disabled.documentAi", capability: "documentAi", reason: "operator_disabled", diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts index a66164c05..cd905a742 100644 --- a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts @@ -344,6 +344,7 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => { await expect(res.json()).resolves.toEqual({ data: null, error: "Medication intake import already in progress", + meta: { requestId: expect.any(String) }, }); expect(prisma.medicationIntakeImportJob.create).not.toHaveBeenCalled(); expect(getGlobalBoss).not.toHaveBeenCalled(); diff --git a/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts b/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts index ce9e2e813..491f52dd5 100644 --- a/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts +++ b/src/app/api/medications/intake/dose-history-import/__tests__/route.test.ts @@ -232,6 +232,7 @@ describe("POST /api/medications/intake/dose-history-import", () => { expect(res.status).toBe(422); const body = await res.json(); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "missing_required_columns", detail: "Medication, Status", }); diff --git a/src/app/api/settings/__tests__/email-test-route.test.ts b/src/app/api/settings/__tests__/email-test-route.test.ts index 2bf5e0684..c54eec97a 100644 --- a/src/app/api/settings/__tests__/email-test-route.test.ts +++ b/src/app/api/settings/__tests__/email-test-route.test.ts @@ -86,7 +86,11 @@ describe("POST /api/settings/email/test", () => { expect(response.status).toBe(502); const json = await response.json(); - expect(json.meta).toEqual({ errorCode: failureCode, smtpCode }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: failureCode, + smtpCode, + }); expect(json.error).toContain(sentence); }, ); @@ -118,7 +122,7 @@ describe("POST /api/settings/email/test", () => { expect(response.status).toBe(500); const json = await response.json(); - expect(json.meta).toBeUndefined(); + expect(json.meta).toEqual({ requestId: expect.any(String) }); }); it("answers a delivered test with 200", async () => { diff --git a/src/app/api/settings/__tests__/private-origin-routes.test.ts b/src/app/api/settings/__tests__/private-origin-routes.test.ts index edcc96bd4..a7c46caf3 100644 --- a/src/app/api/settings/__tests__/private-origin-routes.test.ts +++ b/src/app/api/settings/__tests__/private-origin-routes.test.ts @@ -140,7 +140,10 @@ describe("PUT /api/settings/webhook — private targets", () => { expect(response.status).toBe(422); const json = await response.json(); expect(json.data).toBeNull(); - expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_approved", + }); expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); }); @@ -155,7 +158,10 @@ describe("PUT /api/settings/webhook — private targets", () => { expect(response.status).toBe(422); const json = await response.json(); - expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_approved", + }); expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); }); @@ -181,7 +187,7 @@ describe("PUT /api/settings/webhook — private targets", () => { expect(response.status).toBe(422); const json = await response.json(); expect(json.error).toBe("Invalid data"); - expect(json.meta).toBeUndefined(); + expect(json.meta).toEqual({ requestId: expect.any(String) }); }); it("saves a listed loopback target and refuses an unlisted one with the listable code", async () => { @@ -223,7 +229,10 @@ describe("PUT /api/settings/webhook — private targets", () => { const response = await put("webhook", { url, enabled: true }); expect(response.status).toBe(422); const json = await response.json(); - expect(json.meta).toEqual({ errorCode: "private_origin_not_grantable" }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_grantable", + }); expect(json.error).toMatch(/no operator grant can open/); expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS"); expect(json.details.issues.length).toBeGreaterThan(0); @@ -262,7 +271,10 @@ describe("PUT /api/settings/ntfy — private servers", () => { expect(response.status).toBe(422); const json = await response.json(); - expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_approved", + }); expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); expect(prisma.notificationChannel.upsert).not.toHaveBeenCalled(); }); @@ -291,7 +303,10 @@ describe("PUT /api/settings/ntfy — private servers", () => { expect(response.status).toBe(422); const json = await response.json(); - expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_approved", + }); }); }); @@ -325,7 +340,10 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", expect(response.status).toBe(422); const json = await response.json(); - expect(json.meta).toEqual({ errorCode: "private_origin_not_approved" }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_approved", + }); expect(json.error).toContain("NOTIFICATION_PRIVATE_ORIGINS"); }, ); @@ -347,7 +365,10 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", expect(response.status).toBe(422); const json = await response.json(); - expect(json.meta).toEqual({ errorCode: "private_origin_not_grantable" }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: "private_origin_not_grantable", + }); expect(json.error).toMatch(/no operator grant can open/); expect(json.error).not.toContain("NOTIFICATION_PRIVATE_ORIGINS"); }, @@ -372,6 +393,7 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", expect(response.status).toBe(502); const json = await response.json(); expect(json.meta).toEqual({ + requestId: expect.any(String), errorCode: "upstream_rejected", upstreamStatus: 400, upstreamBody: '{"error":"Bad Request","errorCode":400}', @@ -408,7 +430,11 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", expect(response.status).toBe(502); const json = await response.json(); - expect(json.meta).toEqual({ errorCode: code, upstreamStatus: status }); + expect(json.meta).toEqual({ + requestId: expect.any(String), + errorCode: code, + upstreamStatus: status, + }); }, ); @@ -450,7 +476,7 @@ describe("POST /api/settings/{webhook,ntfy}/test — the refusal names itself", expect(response.status).toBe(500); const json = await response.json(); - expect(json.meta).toBeUndefined(); + expect(json.meta).toEqual({ requestId: expect.any(String) }); expect(emitIfSampledMock).toHaveBeenCalledWith( expect.objectContaining({ level: "error" }), ); diff --git a/src/lib/__tests__/acting-account-resolver.test.ts b/src/lib/__tests__/acting-account-resolver.test.ts index 6ed62b275..458243588 100644 --- a/src/lib/__tests__/acting-account-resolver.test.ts +++ b/src/lib/__tests__/acting-account-resolver.test.ts @@ -130,6 +130,7 @@ import { auditLog } from "@/lib/auth/audit"; import { emitIfSampled } from "@/lib/logging/transports"; import { prisma } from "@/lib/db"; import type { WideEvent } from "@/lib/logging/types"; +import { bodyWithoutRequestId } from "@/__tests__/helpers/error-envelope"; const OWNER = "owner-1"; const DELEGATE = "delegate-1"; @@ -604,7 +605,7 @@ describe("refusals do not enumerate", () => { const missing = await route(() => requireRecordAuth("read", "measurements"), )(); - const missingBody = await missing.text(); + const missingBody = await bodyWithoutRequestId(missing); vi.clearAllMocks(); headerJar.clear(); @@ -616,7 +617,7 @@ describe("refusals do not enumerate", () => { const present = await route(() => requireRecordAuth("read", "measurements"), )(); - const presentBody = await present.text(); + const presentBody = await bodyWithoutRequestId(present); expect(missing.status).toBe(present.status); // Bytes, not statuses. A difference of one word here is an account @@ -631,7 +632,7 @@ describe("refusals do not enumerate", () => { const revoked = await route(() => requireRecordAuth("read", "measurements"), )(); - const revokedBody = await revoked.text(); + const revokedBody = await bodyWithoutRequestId(revoked); vi.clearAllMocks(); headerJar.clear(); @@ -642,7 +643,7 @@ describe("refusals do not enumerate", () => { requireRecordAuth("read", "measurements"), )(); - expect(revokedBody).toBe(await never.text()); + expect(revokedBody).toBe(await bodyWithoutRequestId(never)); }); it("keeps the reason off the wire and on the audit row", async () => { @@ -790,7 +791,7 @@ describe("a scoped grant reaches only the sections it names", () => { const outOfScope = await route(() => requireRecordAuth("read", "measurements"), )(); - const outOfScopeBody = await outOfScope.text(); + const outOfScopeBody = await bodyWithoutRequestId(outOfScope); grantRows.length = 0; liveGrant({ access: "READ" }); @@ -799,7 +800,7 @@ describe("a scoped grant reaches only the sections it names", () => { )(); expect(outOfScope.status).toBe(insufficient.status); - expect(outOfScopeBody).toBe(await insufficient.text()); + expect(outOfScopeBody).toBe(await bodyWithoutRequestId(insufficient)); expect(outOfScopeBody).not.toContain("scope"); expect(outOfScopeBody).not.toContain("section"); }); @@ -954,7 +955,7 @@ describe("the guardian resolver gates on the marker, not the grant", () => { userRows.set(OWNER, user(OWNER, "USER", null)); liveGrant({ access: "MANAGE" }); const fenced = await route(() => requireGuardianAuth())(); - const fencedBody = await fenced.text(); + const fencedBody = await bodyWithoutRequestId(fenced); // Compared against the oldest refusal in the feature: no grant at all. The // two are as far apart in meaning as this feature gets — "you were never @@ -966,7 +967,7 @@ describe("the guardian resolver gates on the marker, not the grant", () => { )(); expect(fenced.status).toBe(insufficient.status); - expect(fencedBody).toBe(await insufficient.text()); + expect(fencedBody).toBe(await bodyWithoutRequestId(insufficient)); expect(fencedBody).not.toContain("guardian"); expect(fencedBody).not.toContain("managed"); }); diff --git a/src/lib/__tests__/ai-refusal-envelope.test.ts b/src/lib/__tests__/ai-refusal-envelope.test.ts index a21e8f390..5b72cb8c1 100644 --- a/src/lib/__tests__/ai-refusal-envelope.test.ts +++ b/src/lib/__tests__/ai-refusal-envelope.test.ts @@ -137,6 +137,7 @@ describe("apiHandler renders it", () => { expect(body.data).toBeNull(); expect(typeof body.error).toBe("string"); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "consent.ai.required", capability: "coach", reason: "consent_required", diff --git a/src/lib/__tests__/api-handler-error-request-id.test.ts b/src/lib/__tests__/api-handler-error-request-id.test.ts new file mode 100644 index 000000000..a76c73493 --- /dev/null +++ b/src/lib/__tests__/api-handler-error-request-id.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/monitoring/glitchtip", () => ({ + sendGlitchtipEvent: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { NextRequest, NextResponse } from "next/server"; +import { apiHandler, HttpError } from "@/lib/api-handler"; +import { apiError, apiSuccess, withErrorRequestId } from "@/lib/api-response"; + +const INCOMING = "3f9a2c1b-0000-4000-8000-000000000001"; + +function call( + handler: () => Promise, + headers: Record = {}, +) { + return apiHandler(handler)( + new NextRequest("http://localhost/api/probe", { headers }), + ); +} + +describe("meta.requestId on every error envelope", () => { + it("adds the request id to an apiError envelope, equal to the header", async () => { + const res = await call(async () => + apiError("Nope", 409, { errorCode: "probe.conflict" }), + ); + const body = await res.json(); + expect(res.status).toBe(409); + expect(body.meta.errorCode).toBe("probe.conflict"); + expect(body.meta.requestId).toBe(res.headers.get("x-request-id")); + expect(body.meta.requestId).toMatch(/^[A-Za-z0-9-]{8,64}$/); + }); + + it("adds it to an envelope without meta and to a hand-built one", async () => { + const plain = await call(async () => apiError("Not found", 404)); + expect((await plain.json()).meta).toEqual({ + requestId: plain.headers.get("x-request-id"), + }); + const hand = await call(async () => + NextResponse.json({ data: null, error: "Gone" }, { status: 410 }), + ); + expect((await hand.json()).meta.requestId).toBe( + hand.headers.get("x-request-id"), + ); + }); + + it("adds it to a thrown HttpError and to an unexpected throw (500)", async () => { + const thrown = await call(async () => { + throw new HttpError(403, "Forbidden"); + }); + expect((await thrown.json()).meta.requestId).toBe( + thrown.headers.get("x-request-id"), + ); + const crash = await call(async () => { + throw new Error("boom"); + }); + expect(crash.status).toBe(500); + expect((await crash.json()).meta.requestId).toBe( + crash.headers.get("x-request-id"), + ); + }); + + it("adopts a well-formed incoming x-request-id and replaces a malformed one", async () => { + const adopted = await call(async () => apiError("Nope", 400), { + "x-request-id": INCOMING, + }); + expect((await adopted.json()).meta.requestId).toBe(INCOMING); + expect(adopted.headers.get("x-request-id")).toBe(INCOMING); + + const forged = ''; + const replaced = await call(async () => apiError("Nope", 400), { + "x-request-id": forged, + }); + const id = (await replaced.json()).meta.requestId; + expect(id).not.toBe(forged); + expect(id).toMatch(/^[A-Za-z0-9-]{8,64}$/); + expect(replaced.headers.get("x-request-id")).toBe(id); + }); + + it("keeps status, headers and set-cookie on the rewritten response", async () => { + const res = await call(async () => { + const r = apiError("Slow down", 429, { + headers: { "Retry-After": "30" }, + }); + r.cookies.set("probe", "1"); + return r; + }); + expect(res.status).toBe(429); + expect(res.headers.get("Retry-After")).toBe("30"); + expect(res.headers.get("set-cookie")).toContain("probe=1"); + }); + + it("leaves a success, a non-envelope error and a non-JSON error untouched", async () => { + const ok = await call(async () => apiSuccess({ fine: true })); + expect(await ok.json()).toEqual({ data: { fine: true }, error: null }); + + const rpc = await call(async () => + NextResponse.json( + { jsonrpc: "2.0", error: { code: -32600, message: "bad" }, id: 1 }, + { status: 400 }, + ), + ); + expect(await rpc.json()).toEqual({ + jsonrpc: "2.0", + error: { code: -32600, message: "bad" }, + id: 1, + }); + + const text = await call( + async () => new Response("plain failure", { status: 502 }), + ); + expect(await text.text()).toBe("plain failure"); + }); + + it("never overwrites an id the handler set itself", async () => { + const res = await withErrorRequestId( + NextResponse.json( + { data: null, error: "x", meta: { requestId: "kept-by-handler" } }, + { status: 500 }, + ), + "ignored-0000", + ); + expect((await res.json()).meta.requestId).toBe("kept-by-handler"); + }); +}); diff --git a/src/lib/__tests__/api-handler-key-mismatch.test.ts b/src/lib/__tests__/api-handler-key-mismatch.test.ts index b4bbf5a63..906a33526 100644 --- a/src/lib/__tests__/api-handler-key-mismatch.test.ts +++ b/src/lib/__tests__/api-handler-key-mismatch.test.ts @@ -41,7 +41,10 @@ describe("apiHandler while the encryption key does not match", () => { expect(response.status).toBe(503); const body = await response.json(); expect(body.data).toBeNull(); - expect(body.meta).toEqual({ errorCode: "encryption.key_mismatch" }); + expect(body.meta).toEqual({ + requestId: expect.any(String), + errorCode: "encryption.key_mismatch", + }); expect(response.headers.get("Retry-After")).toBe("300"); expect(handler).not.toHaveBeenCalled(); }); diff --git a/src/lib/__tests__/auth-error-codes.test.ts b/src/lib/__tests__/auth-error-codes.test.ts index eb608b7e9..76cf3ee13 100644 --- a/src/lib/__tests__/auth-error-codes.test.ts +++ b/src/lib/__tests__/auth-error-codes.test.ts @@ -236,7 +236,11 @@ describe("auth failures carry a machine-readable errorCode", () => { )) as Response; expect(res.status).toBe(404); - expect(await res.json()).toEqual({ data: null, error: "Not found" }); + expect(await res.json()).toEqual({ + data: null, + error: "Not found", + meta: { requestId: expect.any(String) }, + }); }); it("keeps every code distinct and dot.case", () => { diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index c837aec0f..57a0f5c96 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -55,6 +55,7 @@ import { } from "./sharing/record-session-fence"; import { refuseOnKeyMismatch } from "./boot/key-mismatch-refusal"; import { acceptIncomingRequestId } from "./http/request-id"; +import { withErrorRequestId } from "./api-response"; import { capturedRateLimit, rateLimitResponseHeaders, @@ -363,7 +364,13 @@ export function apiHandler Promise>( /* logging must never crash the handler */ } } - const nr = response as NextResponse; + // Every error envelope names its request id in `meta.requestId`, so + // the id survives a proxy that strips the header (see + // `withErrorRequestId`). + const nr = (await withErrorRequestId( + response as Response, + evt.getRequestId(), + )) as NextResponse; nr.headers.set("x-request-id", evt.getRequestId()); if (isBypassRoute) applyBypassRouteHeaders(nr.headers); // v1.37.0 — echo the record context this response was actually served diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index 0f22c09d6..82762766f 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -301,6 +301,69 @@ export function apiError(message: string, status = 400, meta?: ErrorMeta) { return buildJsonErrorResponse({ data: null, error: message }, status, meta); } +/** An error body larger than this is not an envelope worth re-reading. */ +const ERROR_ENVELOPE_MAX_BYTES = 64 * 1024; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Put the request id into `meta.requestId` of an error envelope. + * + * Every 4xx and 5xx a route answers with the `{ data: null, error, meta? }` + * envelope carries the full request id beside the `x-request-id` header, so a + * client behind a proxy that strips headers can still show the id a person + * reads out to the operator, who finds the event in the admin log with it. + * + * Applied once, by `apiHandler`, to whatever the handler answered, so a route + * that builds its error with `apiError`, with `NextResponse.json` by hand or by + * throwing cannot be the one that forgets it. Anything that is not a JSON + * envelope (a redirect, a stream, a FHIR `OperationOutcome`, a body that does + * not parse) is returned untouched, and an id the handler already set wins. + */ +export async function withErrorRequestId( + response: Response, + requestId: string, +): Promise { + if (response.status < 400) return response; + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.toLowerCase().includes("application/json")) return response; + const declared = Number(response.headers.get("content-length") ?? "0"); + if (declared > ERROR_ENVELOPE_MAX_BYTES) return response; + + let body: unknown; + try { + const text = await response.clone().text(); + if (text.length === 0 || text.length > ERROR_ENVELOPE_MAX_BYTES) { + return response; + } + body = JSON.parse(text); + } catch { + return response; + } + // The envelope and nothing else: a JSON-RPC error (`/mcp`) or an RFC-shaped + // OAuth body has no `data: null` beside its `error`, and a member added to + // either would be one its own clients do not expect. + if ( + !isPlainObject(body) || + body.data !== null || + typeof body.error !== "string" + ) { + return response; + } + const meta = body.meta; + if (meta !== undefined && !isPlainObject(meta)) return response; + if (meta && typeof meta.requestId === "string") return response; + + const headers = new Headers(response.headers); + headers.delete("content-length"); + return new NextResponse( + JSON.stringify({ ...body, meta: { ...(meta ?? {}), requestId } }), + { status: response.status, statusText: response.statusText, headers }, + ); +} + /** * Read a request body as text, counting BYTES as they arrive and stopping at * `maxBytes`. A body that declares a larger `Content-Length` is refused before diff --git a/tests/integration/acting-account-resolver.test.ts b/tests/integration/acting-account-resolver.test.ts index 13ace25fb..0c61815a4 100644 --- a/tests/integration/acting-account-resolver.test.ts +++ b/tests/integration/acting-account-resolver.test.ts @@ -75,6 +75,7 @@ import { import { hashToken } from "@/lib/auth/hmac"; import { prisma } from "@/lib/db"; import { acceptGrant, inviteGrant, revokeGrant } from "@/lib/sharing/grants"; +import { bodyWithoutRequestId } from "@/__tests__/helpers/error-envelope"; let counter = 0; @@ -379,11 +380,11 @@ describe("acting-account resolver — refusals do not enumerate", () => { headerJar.set(ACCOUNT_SELECTOR_HEADER, "cmnosuchaccount000000000x"); const missing = await call(); - const missingBody = await missing.text(); + const missingBody = await bodyWithoutRequestId(missing); headerJar.set(ACCOUNT_SELECTOR_HEADER, stranger.id); const present = await call(); - const presentBody = await present.text(); + const presentBody = await bodyWithoutRequestId(present); expect(missing.status).toBe(403); expect(present.status).toBe(403); diff --git a/tests/integration/ai-extraction-capability.test.ts b/tests/integration/ai-extraction-capability.test.ts index a94cc4008..4a326b1cc 100644 --- a/tests/integration/ai-extraction-capability.test.ts +++ b/tests/integration/ai-extraction-capability.test.ts @@ -273,6 +273,7 @@ describe("reading a stored document — POST /api/documents/inbound/[id]/suggest expect(status).toBe(403); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "assistant.disabled.documentAi", capability: "documentAi", reason: "operator_disabled", @@ -303,6 +304,7 @@ describe("reading a stored document — POST /api/documents/inbound/[id]/suggest expect(status).toBe(403); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "consent.ai.required", capability: "documentAi", reason: "consent_required", @@ -336,6 +338,7 @@ describe("reading a stored document — POST /api/documents/inbound/[id]/suggest expect(status).toBe(422); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "documents.inbound.providerUnsupported", capability: "documentAi", reason: "no_provider", @@ -390,6 +393,7 @@ describe("scanning a lab report — POST /api/labs/ocr/extract", () => { expect(status).toBe(403); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "module.disabled", capability: "labsOcr", reason: "module_disabled", @@ -438,6 +442,7 @@ describe("turning medication text into a schedule — POST /api/medications/extr expect(status).toBe(403); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "assistant.disabled.documentAi", capability: "medicationExtract", reason: "operator_disabled", @@ -457,6 +462,7 @@ describe("turning medication text into a schedule — POST /api/medications/extr expect(status).toBe(503); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "ai.provider.none", capability: "medicationExtract", reason: "no_provider", diff --git a/tests/integration/encryption-key-check.test.ts b/tests/integration/encryption-key-check.test.ts index 965509974..51daa67cb 100644 --- a/tests/integration/encryption-key-check.test.ts +++ b/tests/integration/encryption-key-check.test.ts @@ -157,7 +157,10 @@ describe("boot encryption key check (real Postgres)", () => { ); expect(refused.status).toBe(503); const body = await refused.json(); - expect(body.meta).toEqual({ errorCode: "encryption.key_mismatch" }); + expect(body.meta).toEqual({ + requestId: expect.any(String), + errorCode: "encryption.key_mismatch", + }); const { GET: healthGet } = await import("@/app/api/health/route"); const health = await asRoute(healthGet)( @@ -625,6 +628,7 @@ describe("routes outside apiHandler refuse while the key does not match", () => ); expect(res.status).toBe(503); expect((await res.json()).meta).toEqual({ + requestId: expect.any(String), errorCode: "encryption.key_mismatch", }); }); diff --git a/tests/integration/login-insecure-transport.test.ts b/tests/integration/login-insecure-transport.test.ts index 58f495d0c..c23df31ba 100644 --- a/tests/integration/login-insecure-transport.test.ts +++ b/tests/integration/login-insecure-transport.test.ts @@ -139,6 +139,7 @@ describe("sign-in over plain http with Secure-only cookies (real Postgres)", () expect(res.status).toBe(409); const body = await res.json(); expect(body.meta).toEqual({ + requestId: expect.any(String), errorCode: "auth.session.insecure_transport", sessionCookieSecure: true, }); diff --git a/tests/integration/sharing-lifecycle.test.ts b/tests/integration/sharing-lifecycle.test.ts index a948ddc80..1aa9574a0 100644 --- a/tests/integration/sharing-lifecycle.test.ts +++ b/tests/integration/sharing-lifecycle.test.ts @@ -51,6 +51,7 @@ vi.mock("@/lib/db-compat", () => ({ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { hashToken } from "@/lib/auth/hmac"; import { prisma } from "@/lib/db"; +import { bodyWithoutRequestId } from "@/__tests__/helpers/error-envelope"; let counter = 0; @@ -771,7 +772,9 @@ describe("the switch endpoint", () => { const ungranting = await switchTo(stranger.id); expect(unknown.status).toBe(ungranting.status); - expect(await unknown.text()).toBe(await ungranting.text()); + expect(await bodyWithoutRequestId(unknown)).toBe( + await bodyWithoutRequestId(ungranting), + ); }); it("refuses over Bearer, where the carrier is the header", async () => { diff --git a/tests/integration/sharing-scope-fence.test.ts b/tests/integration/sharing-scope-fence.test.ts index 67b3beafe..f8d88f3e8 100644 --- a/tests/integration/sharing-scope-fence.test.ts +++ b/tests/integration/sharing-scope-fence.test.ts @@ -34,6 +34,7 @@ import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; import { SHARE_DOMAINS } from "@/lib/sharing/scope"; import type { ShareDomain } from "@/lib/sharing/scope"; +import { bodyWithoutRequestId } from "@/__tests__/helpers/error-envelope"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -417,7 +418,7 @@ describe("the refusal does not distinguish itself", () => { const delegate = await makeUser("delegate"); await switchInto(owner.id, delegate.id, ["medications"]); const outOfScope = await PROBES.labs.call(); - const outOfScopeBody = await outOfScope.text(); + const outOfScopeBody = await bodyWithoutRequestId(outOfScope); // A stranger naming the same record. Different story, same answer. const stranger = await makeUser("stranger"); @@ -427,7 +428,7 @@ describe("the refusal does not distinguish itself", () => { const noGrant = await PROBES.labs.call(); expect(outOfScope.status).toBe(noGrant.status); - expect(outOfScopeBody).toBe(await noGrant.text()); + expect(outOfScopeBody).toBe(await bodyWithoutRequestId(noGrant)); expect(outOfScopeBody).not.toContain("scope"); // …and the operator's trail tells them apart, which is the half that has From 1af0de5e877b27ba6003fc3925f76bae0785debb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:06:39 +0200 Subject: [PATCH 02/10] feat(logging): keep warnings and errors in their own ring and find them by error id --- .../admin/app-logs/__tests__/route.test.ts | 34 +++++++++++ src/app/api/admin/app-logs/route.ts | 28 ++++++++- .../admin/app-log-preview-section.tsx | 36 +++++++++++- .../__tests__/in-memory-buffer.test.ts | 37 ++++++++++++ src/lib/logging/in-memory-buffer.ts | 57 ++++++++++++++++--- src/lib/logging/index.ts | 2 + src/lib/query-keys/admin.ts | 5 +- 7 files changed, 186 insertions(+), 13 deletions(-) diff --git a/src/app/api/admin/app-logs/__tests__/route.test.ts b/src/app/api/admin/app-logs/__tests__/route.test.ts index adfff8b0f..b20edbc84 100644 --- a/src/app/api/admin/app-logs/__tests__/route.test.ts +++ b/src/app/api/admin/app-logs/__tests__/route.test.ts @@ -136,4 +136,38 @@ describe("GET /api/admin/app-logs", () => { expect(json.error).toBe("Validation failed"); expect(json.details.issues.length).toBeGreaterThan(0); }); + + it("finds an error by the first 8 characters of its id in the problems ring", async () => { + appendLogEvent( + makeEvent({ level: "error", request_id: "3f9a2c1b-1111-4000-8000-0000" }), + ); + for (let i = 0; i < 600; i++) { + appendLogEvent(makeEvent({ level: "info" })); + } + const gone = await GET(req("requestId=3f9a2c1b")); + const goneJson = (await gone.json()) as { data: { events: WideEvent[] } }; + expect(goneJson.data.events).toHaveLength(0); + + const res = await GET(req("requestId=3f9a2c1b&ring=problems")); + const json = (await res.json()) as { + data: { + events: WideEvent[]; + meta: { problemsBufferMax: number; ring: string }; + }; + }; + expect(json.data.events.map((e) => e.request_id)).toEqual([ + "3f9a2c1b-1111-4000-8000-0000", + ]); + expect(json.data.meta.problemsBufferMax).toBe(1000); + expect(json.data.meta.ring).toBe("problems"); + }); + + it("refuses a request id shorter than 8 characters or outside the id alphabet", async () => { + for (const bad of ["3f9a2c1", "3f9a2c1b%20OR%201", "a".repeat(65)]) { + const res = await GET(req(`requestId=${bad}`)); + expect(res.status).toBe(422); + } + const ring = await GET(req("ring=everything")); + expect(ring.status).toBe(422); + }); }); diff --git a/src/app/api/admin/app-logs/route.ts b/src/app/api/admin/app-logs/route.ts index c5e3e3478..2482a1600 100644 --- a/src/app/api/admin/app-logs/route.ts +++ b/src/app/api/admin/app-logs/route.ts @@ -1,7 +1,11 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { readLogBuffer, LOG_BUFFER_MAX } from "@/lib/logging/in-memory-buffer"; +import { + readLogBuffer, + LOG_BUFFER_MAX, + PROBLEMS_BUFFER_MAX, +} from "@/lib/logging/in-memory-buffer"; import { redactSecrets } from "@/lib/logging/redact"; import type { WideEvent } from "@/lib/logging/types"; import { NextRequest } from "next/server"; @@ -10,17 +14,31 @@ import { z } from "zod/v4"; export const dynamic = "force-dynamic"; const querySchema = z.object({ + // The error id a person reads out is the first 8 characters of the request + // id; the full id (up to 64) matches as well. Same alphabet as an accepted + // incoming `x-request-id`. + requestId: z + .string() + .trim() + .regex(/^[A-Za-z0-9-]{8,64}$/) + .optional(), + ring: z.enum(["all", "problems"]).default("all"), traceId: z.string().trim().min(1).max(200).optional(), level: z.enum(["debug", "info", "warn", "error"]).optional(), action: z.string().trim().min(1).max(200).optional(), since: z.string().datetime().optional(), until: z.string().datetime().optional(), - limit: z.coerce.number().int().min(1).max(500).default(200), + limit: z.coerce.number().int().min(1).max(PROBLEMS_BUFFER_MAX).default(200), }); /** * Admin endpoint: tail of the per-process in-memory wide-event ring buffer. * + * `ring=problems` reads the second ring, which keeps the last 1,000 `warn` and + * `error` events only, so a failure a person reported an hour ago is still + * there after the main ring has turned over. `requestId` finds the event + * behind an error id by prefix. + * * The buffer is populated by `transports.emitEvent()` so the result mirrors * what would be shipped to Loki (sampler-gated). Per-process and volatile — * see `src/lib/logging/in-memory-buffer.ts` for the limitations. @@ -36,6 +54,8 @@ export const GET = apiHandler(async (request: NextRequest) => { const { searchParams } = new URL(request.url); const result = querySchema.safeParse({ + requestId: searchParams.get("requestId") ?? undefined, + ring: searchParams.get("ring") ?? undefined, traceId: searchParams.get("traceId") ?? undefined, level: searchParams.get("level") ?? undefined, action: searchParams.get("action") ?? undefined, @@ -47,6 +67,8 @@ export const GET = apiHandler(async (request: NextRequest) => { const parsed = result.data; const events = readLogBuffer({ + ring: parsed.ring, + requestId: parsed.requestId, traceId: parsed.traceId, level: parsed.level, action: parsed.action, @@ -62,6 +84,8 @@ export const GET = apiHandler(async (request: NextRequest) => { meta: { total: redacted.length, bufferMax: LOG_BUFFER_MAX, + problemsBufferMax: PROBLEMS_BUFFER_MAX, + ring: parsed.ring, }, }); }); diff --git a/src/components/admin/app-log-preview-section.tsx b/src/components/admin/app-log-preview-section.tsx index b043500c3..92453a839 100644 --- a/src/components/admin/app-log-preview-section.tsx +++ b/src/components/admin/app-log-preview-section.tsx @@ -67,6 +67,7 @@ interface AppLogsResponse { meta: { total: number; bufferMax: number; + problemsBufferMax?: number; }; } @@ -94,6 +95,7 @@ function levelIcon(level: LogLevel) { export function AppLogPreviewSection() { const { t } = useTranslations(); + const [requestId, setRequestId] = useState(""); const [traceId, setTraceId] = useState(""); const [actionFilter, setActionFilter] = useState(""); const [level, setLevel] = useState("__all__"); @@ -103,16 +105,32 @@ export function AppLogPreviewSection() { const params = useMemo(() => { const p = new URLSearchParams(); + // An error id is the first characters of a request id. The ids people + // read out come from 5xx answers, which are logged at `error`, so a + // search by id, like a filter on warn or error, reads the problems ring: + // it keeps the last 1,000 warnings and errors, where the main ring keeps + // 500 events of every kind and turns over in minutes on a busy host. + const id = requestId.trim(); + if (id.length >= 8) p.set("requestId", id); + if (id.length >= 8 || level === "warn" || level === "error") { + p.set("ring", "problems"); + } if (traceId.trim()) p.set("traceId", traceId.trim()); if (actionFilter.trim()) p.set("action", actionFilter.trim()); if (level !== "__all__") p.set("level", level); const since = rangeToSince(range); if (since) p.set("since", since); return p; - }, [traceId, actionFilter, level, range]); + }, [requestId, traceId, actionFilter, level, range]); const { data, isLoading, refetch, isFetching } = useQuery({ - queryKey: queryKeys.adminAppLogs(traceId, actionFilter, level, range), + queryKey: queryKeys.adminAppLogs( + traceId, + actionFilter, + level, + range, + requestId.trim(), + ), queryFn: async () => { return apiGet( `/api/admin/app-logs?${params.toString()}`, @@ -144,7 +162,19 @@ export function AppLogPreviewSection() { {/* Filters and the refresh share one toolbar row above the table; the header status slot carries status, not actions (design standards §12). */} -
+
+ { + setRequestId(e.target.value); + setPage(1); + }} + aria-label={t("admin.section.app-logs.filterErrorId")} + data-slot="app-logs-error-id-filter" + /> { clearLogBuffer(); expect(readLogBuffer({})).toHaveLength(0); }); + + it("keeps warn and error in the problems ring while info traffic turns the main ring over", () => { + expect(PROBLEMS_BUFFER_MAX).toBe(1000); + appendLogEvent( + makeEvent({ level: "error", request_id: "3f9a2c1b-dead-beef" }), + ); + appendLogEvent(makeEvent({ level: "warn", request_id: "warn-0001" })); + for (let i = 0; i < LOG_BUFFER_MAX + 50; i++) { + appendLogEvent(makeEvent({ level: "info" })); + } + // The main ring lost both; the problems ring kept them. + expect(readLogBuffer({ requestId: "3f9a2c1b" })).toHaveLength(0); + const found = readLogBuffer({ ring: "problems", requestId: "3f9a2c1b" }); + expect(found.map((e) => e.request_id)).toEqual(["3f9a2c1b-dead-beef"]); + expect(getLogBufferSize("problems")).toBe(2); + }); + + it("FIFO-evicts the problems ring at its own cap and never admits info or debug", () => { + appendLogEvent(makeEvent({ level: "debug" })); + for (let i = 0; i < PROBLEMS_BUFFER_MAX + 5; i++) { + appendLogEvent(makeEvent({ level: "error", request_id: `e-${i}` })); + } + const out = readLogBuffer({ ring: "problems" }); + expect(out).toHaveLength(PROBLEMS_BUFFER_MAX); + expect(out[0].request_id).toBe(`e-${PROBLEMS_BUFFER_MAX + 4}`); + expect(out.every((e) => e.level === "error")).toBe(true); + }); + + it("matches a request id by case-insensitive prefix", () => { + appendLogEvent(makeEvent({ request_id: "ABCDEF12-0000" })); + appendLogEvent(makeEvent({ request_id: "abcdef99-0000" })); + expect(readLogBuffer({ requestId: "abcdef12" })).toHaveLength(1); + expect(readLogBuffer({ requestId: "abcdef" })).toHaveLength(2); + expect(readLogBuffer({ requestId: "ABCDEF12-0000" })).toHaveLength(1); + }); }); diff --git a/src/lib/logging/in-memory-buffer.ts b/src/lib/logging/in-memory-buffer.ts index 74f052aa8..5e5120b27 100644 --- a/src/lib/logging/in-memory-buffer.ts +++ b/src/lib/logging/in-memory-buffer.ts @@ -12,6 +12,11 @@ * web buffer doesn't see worker events and vice-versa. The UI surfaces * this caveat in the section header. * - 500-entry cap (~500KB max @ ~1KB/event). FIFO eviction on overflow. + * - A second ring, `problems`, keeps the last 1,000 `warn` and `error` + * events only. On a busy host the 500 entries of the main ring turn over + * in minutes of ordinary traffic, so the one failure a person reports + * was gone by the time the operator looked for it. Info traffic never + * enters the problems ring and cannot evict from it. * - Volatile — restart drops the buffer. For durable storage configure * `LOKI_ENDPOINT` and tail Loki instead. * @@ -23,20 +28,49 @@ import type { WideEvent, LogLevel } from "./types"; export const LOG_BUFFER_MAX = 500; -// Plain array with FIFO eviction. A linked list would be technically tighter -// but for 500 entries the array shift overhead (~microseconds) is invisible +/** Capacity of the warn/error-only ring. */ +export const PROBLEMS_BUFFER_MAX = 1000; + +/** Which ring a read walks: every event, or only `warn` and `error`. */ +export type LogRing = "all" | "problems"; + +// Plain arrays with FIFO eviction. A linked list would be technically tighter +// but for 1,000 entries the array shift overhead (~microseconds) is invisible // next to the rest of the request lifecycle. let buffer: WideEvent[] = []; +let problems: WideEvent[] = []; -/** Append an event to the buffer; evict the oldest entry once cap is hit. */ +/** Whether an event belongs in the problems ring. */ +function isProblem(event: WideEvent): boolean { + return event.level === "warn" || event.level === "error"; +} + +/** + * Append an event to the main ring and, for `warn` and `error`, to the + * problems ring; each evicts its oldest entry once its cap is hit. The same + * object sits in both, so an event costs its memory once. + */ export function appendLogEvent(event: WideEvent): void { buffer.push(event); if (buffer.length > LOG_BUFFER_MAX) { buffer.shift(); } + if (isProblem(event)) { + problems.push(event); + if (problems.length > PROBLEMS_BUFFER_MAX) { + problems.shift(); + } + } } export interface ReadLogBufferOptions { + /** The ring to read; `all` when absent. */ + ring?: LogRing; + /** + * Prefix match on `event.request_id`, case-insensitive. A person reads out + * the first 8 characters of an error id; the full id matches too. + */ + requestId?: string; /** Exact match on `event.trace_id`. */ traceId?: string; /** Exact match on `event.level`. */ @@ -60,11 +94,19 @@ export function readLogBuffer(opts: ReadLogBufferOptions): WideEvent[] { const sinceMs = opts.since?.getTime(); const untilMs = opts.until?.getTime(); const actionLower = opts.action?.toLowerCase(); + const requestIdLower = opts.requestId?.toLowerCase(); + const source = opts.ring === "problems" ? problems : buffer; // Walk back-to-front so the result is newest-first without an extra reverse. const out: WideEvent[] = []; - for (let i = buffer.length - 1; i >= 0; i--) { - const event = buffer[i]; + for (let i = source.length - 1; i >= 0; i--) { + const event = source[i]; + if ( + requestIdLower && + !(event.request_id ?? "").toLowerCase().startsWith(requestIdLower) + ) { + continue; + } if (opts.traceId && event.trace_id !== opts.traceId) continue; if (opts.level && event.level !== opts.level) continue; if (actionLower) { @@ -85,9 +127,10 @@ export function readLogBuffer(opts: ReadLogBufferOptions): WideEvent[] { /** Reset the buffer (test-only — not exported from the package index). */ export function clearLogBuffer(): void { buffer = []; + problems = []; } /** Current buffer size (test-only). */ -export function getLogBufferSize(): number { - return buffer.length; +export function getLogBufferSize(ring: LogRing = "all"): number { + return ring === "problems" ? problems.length : buffer.length; } diff --git a/src/lib/logging/index.ts b/src/lib/logging/index.ts index 88b6df6ba..f7ebb66ba 100644 --- a/src/lib/logging/index.ts +++ b/src/lib/logging/index.ts @@ -17,5 +17,7 @@ export { clearLogBuffer, getLogBufferSize, LOG_BUFFER_MAX, + PROBLEMS_BUFFER_MAX, + type LogRing, type ReadLogBufferOptions, } from "./in-memory-buffer"; diff --git a/src/lib/query-keys/admin.ts b/src/lib/query-keys/admin.ts index 59b402bb4..f3eeda9b7 100644 --- a/src/lib/query-keys/admin.ts +++ b/src/lib/query-keys/admin.ts @@ -32,7 +32,10 @@ export const adminKeys = { action: string | undefined, level: string | undefined, range: string | undefined, - ) => ["admin", "app-logs", traceId, action, level, range] as const, + requestId?: string, + ) => ["admin", "app-logs", traceId, action, level, range, requestId] as const, + /** v1.43 — the redacted support report on the admin status page. */ + adminSupportReport: () => ["admin", "support-report"] as const, adminAssistantFlags: () => ["admin", "settings", "assistant-flags"] as const, /** v1.18.0 — operator-level server-wide module availability matrix. */ adminModuleAvailability: () => From b873ce8bc8be23563a6fc1727f43dd36cc8a7e51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:15:50 +0200 Subject: [PATCH 03/10] feat(admin): fill the redacted support report with its redaction guard --- src/__tests__/contract-stub-inventory.test.ts | 3 +- .../support-report/__tests__/route.test.ts | 277 ++++++ src/app/api/admin/support-report/route.ts | 23 +- .../api-handler-error-request-id.test.ts | 3 +- .../support-report-redaction.test.ts | 309 +++++++ src/lib/support/redact.ts | 164 ++++ src/lib/support/report.ts | 875 ++++++++++++++++++ .../integration/admin-support-report.test.ts | 152 +++ 8 files changed, 1801 insertions(+), 5 deletions(-) create mode 100644 src/app/api/admin/support-report/__tests__/route.test.ts create mode 100644 src/lib/support/__tests__/support-report-redaction.test.ts create mode 100644 src/lib/support/redact.ts create mode 100644 src/lib/support/report.ts create mode 100644 tests/integration/admin-support-report.test.ts diff --git a/src/__tests__/contract-stub-inventory.test.ts b/src/__tests__/contract-stub-inventory.test.ts index 102729345..0d71bcd34 100644 --- a/src/__tests__/contract-stub-inventory.test.ts +++ b/src/__tests__/contract-stub-inventory.test.ts @@ -28,7 +28,6 @@ const CONTRACT_STUBS: Readonly> = { "admin.pulse_day_mean.get", "admin.pulse_day_mean.post", ], - "admin/support-report/route.ts": ["admin.support_report.get"], "anamnesis/audit-c/[id]/route.ts": ["anamnesis.audit_c.delete"], "anamnesis/audit-c/route.ts": ["anamnesis.audit_c.create"], "anamnesis/life-circumstances/ai-sections/route.ts": [ @@ -64,7 +63,7 @@ const CONTRACT_STUBS: Readonly> = { }; /** The size of the register when it was written; see the header. */ -const REGISTER_SIZE = 24; +const REGISTER_SIZE = 23; function routeFiles(): string[] { return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] }) diff --git a/src/app/api/admin/support-report/__tests__/route.test.ts b/src/app/api/admin/support-report/__tests__/route.test.ts new file mode 100644 index 000000000..e74b24c18 --- /dev/null +++ b/src/app/api/admin/support-report/__tests__/route.test.ts @@ -0,0 +1,277 @@ +/** + * `GET /api/admin/support-report` end to end over a mocked database: the + * collector, the renderer and the redaction together. Accounts are seeded + * with distinctive emails, ids, usernames and names, and the failures carry + * a token, a URL and a host in their messages; none of it may reach the text. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const USERS = [ + { + id: "cmownerabcdefghijklmnopq", + username: "ownerperson", + email: "owner.person@example.org", + fullName: "Olive Owner", + aiUsageLevel: null, + }, + { + id: "cmsecondabcdefghijklmnop", + username: "secondperson", + email: "second.person@mailbox.org", + fullName: "Sam Second", + aiUsageLevel: "FULL", + }, +]; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findMany: vi.fn(async () => USERS), + groupBy: vi.fn(async () => [ + { aiProvider: "LOCAL", _count: { _all: 1 } }, + { aiProvider: null, _count: { _all: 1 } }, + ]), + }, + appSettings: { + findUnique: vi.fn(async () => ({ + glitchtipEnabled: true, + glitchtipDsn: "https://key@glitchtip.example.net/1", + webPushVapidPublicKey: null, + webPushVapidPrivateKeyEncrypted: null, + adminAiKeyEncrypted: "v1:ciphertext", + adminCodexConnectionStatus: "disconnected", + aiUsageDefaultLevel: "STANDARD", + aiUsageMaxLevel: "FULL", + })), + }, + $queryRaw: vi.fn(async (strings: TemplateStringsArray) => { + const sql = strings.join("?"); + if (sql.includes("server_version")) { + return [ + { version: "16.4", timezone: "Etc/UTC", size: BigInt(52428800) }, + ]; + } + if (sql.includes("migration_name")) { + return [ + { + migration_name: "0410_relation_links", + finished_at: new Date("2026-10-15T08:00:00Z"), + }, + ]; + } + if (sql.includes("finished_at IS NULL")) return [{ count: 0 }]; + if (sql.includes("pgboss.job")) { + return [{ at: new Date("2026-10-15T09:59:00Z") }]; + } + return []; + }), + integrationWatch: { + findMany: vi.fn(async () => [ + { + userId: "cmsecondabcdefghijklmnop", + integration: "nightscout", + incidentReason: "unexpected_silence", + incidentOpenedAt: new Date("2026-10-14T00:00:00Z"), + alertedAt: null, + }, + ]), + }, + providerHealth: { + findMany: vi.fn(async () => [ + { + providerType: "LOCAL", + lastResult: "hard_failed", + lastStatus: 502, + lastFailureAt: new Date("2026-10-15T07:00:00Z"), + nextRetryAt: new Date(Date.now() + 60_000), + }, + ]), + }, + pushAttempt: { + groupBy: vi.fn(async (args: { by: string[] }) => + args.by.includes("result") + ? [ + { channel: "NTFY", result: "ok", _count: { _all: 9 } }, + { channel: "NTFY", result: "error", _count: { _all: 2 } }, + ] + : [{ channel: "NTFY", reason: "http_500", _count: { _all: 2 } }], + ), + }, + dataBackup: { + aggregate: vi.fn(async () => ({ + _max: { createdAt: new Date("2026-10-12T03:00:00Z") }, + })), + count: vi.fn(async () => 2), + }, + offhostBackupState: { + findMany: vi.fn(async () => [ + { lastSuccessAt: new Date("2026-10-15T02:00:00Z") }, + { lastSuccessAt: null }, + ]), + }, + }, +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + h: T, + ): T => h, + requireAdmin: vi.fn(), + }; +}); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => null), +})); + +vi.mock("@/lib/jobs/job-failures", () => ({ + JOB_FAILURE_WINDOW_HOURS: 72, + readFailingQueues: vi.fn(async () => [ + { + queue: "withings-sync", + failures: 4, + lastFailedAt: "2026-10-15T09:00:00.000Z", + lastError: + "TypeError: fetch https://wbsapi.withings.net/v2?access_token=abc failed for owner.person@example.org", + }, + ]), + readLastQueueRun: vi.fn(async (queue: string) => + queue === "data-restore-drill" + ? { + at: "2026-10-01T04:11:00.000Z", + state: "failed", + error: "connect ECONNREFUSED 10.0.0.12:9000 (s3.nas.lan)", + } + : null, + ), +})); + +vi.mock("@/lib/integrations/connection-health-load", () => ({ + loadConnectionHealth: vi.fn(async (userId: string) => { + const empty = { + withings: null, + whoop: null, + fitbit: null, + "google-health": null, + polar: null, + oura: null, + strava: null, + nightscout: null, + "apple-health": null, + }; + if (userId === "cmownerabcdefghijklmnopq") { + return { + ...empty, + withings: { state: "ok", reason: "data_arriving" }, + }; + } + return { + ...empty, + withings: { state: "action_required", reason: "reauth_required" }, + nightscout: { state: "degraded", reason: "unexpected_silence" }, + }; + }), +})); + +vi.mock("@/lib/geo", () => ({ offlineGeoReady: () => false })); + +import { requireAdmin, HttpError } from "@/lib/api-handler"; +import { appendLogEvent, clearLogBuffer } from "@/lib/logging/in-memory-buffer"; +import { GET } from "../route"; + +const FORBIDDEN = [ + ...USERS.flatMap((u) => [u.id, u.username, u.email, u.fullName]), + "wbsapi.withings.net", + "access_token", + "glitchtip.example.net", + "10.0.0.12", + "s3.nas.lan", + "5d8e1b72-9a8c-49b7-801c-1dd42ba79dcc", + "hlk_leaked_token", +]; + +beforeEach(() => { + vi.mocked(requireAdmin).mockReset(); + clearLogBuffer(); +}); + +async function report(): Promise<{ generatedAt: string; text: string }> { + const res = await GET(); + expect(res.status).toBe(200); + return (await res.json()).data; +} + +describe("GET /api/admin/support-report", () => { + it("refuses a non-admin before reading anything", async () => { + vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden")); + await expect(GET()).rejects.toThrow("Forbidden"); + }); + + it("answers the redacted report with every section", async () => { + appendLogEvent({ + timestamp: "2026-10-15T09:40:00.000Z", + duration_ms: 20, + request_id: "5d8e1b72-9a8c-49b7-801c-1dd42ba79dcc", + trace_id: "t", + level: "error", + kind: "http", + service: "healthlog", + environment: "test", + action: { name: "measurement.batch.ingest" }, + auth: { user_id: "cmownerabcdefghijklmnopq" }, + error: { + type: "TypeError", + message: "Bearer hlk_leaked_token for owner.person@example.org", + }, + }); + + const { generatedAt, text } = await report(); + expect(Date.parse(generatedAt)).not.toBeNaN(); + for (const heading of [ + "## Build", + "## Database", + "## Configuration", + "## Jobs", + "## Connections", + "## AI", + "## Notifications (7 days)", + "## Backups", + "## Recent warnings and errors (this process)", + ]) { + expect(text).toContain(heading); + } + expect(text).toContain("- PostgreSQL: 16.4"); + expect(text).toContain("0410_relation_links"); + expect(text).toContain("- GlitchTip: yes"); + expect(text).toContain("withings-sync: 4 failed"); + expect(text).toContain("(TypeError)"); + expect(text).toContain( + "- withings: action_required / reauth_required: 1 (Account 2)", + ); + expect(text).toContain("- withings: ok / data_arriving: 1"); + expect(text).toContain("nightscout: unexpected_silence since"); + expect(text).toContain("Account 2"); + expect(text).toContain( + "- Accounts by usage level: FULL 1, instance default 1", + ); + expect(text).toContain( + "LOCAL: ok 0, failing 1, credential refused 0, paused 1", + ); + expect(text).toContain("- NTFY: ok 9, failed 2, skipped 0; http_500 2"); + expect(text).toContain( + "Last restore drill: failed at 2026-10-01T04:11:00.000Z (ECONNREFUSED)", + ); + expect(text).toContain("- error measurement.batch.ingest: 1x"); + + for (const value of FORBIDDEN) { + expect(text.toLowerCase(), value).not.toContain(value.toLowerCase()); + } + }); +}); diff --git a/src/app/api/admin/support-report/route.ts b/src/app/api/admin/support-report/route.ts index 08fec25cb..472f8e886 100644 --- a/src/app/api/admin/support-report/route.ts +++ b/src/app/api/admin/support-report/route.ts @@ -5,13 +5,32 @@ * backups and the most frequent recent error actions. Never an email address, * a user id, a value, a token or a foreign host name; accounts appear as * "Account 1..n". Admin only, cookie session only. + * + * What goes in and how it is redacted lives in `src/lib/support/report.ts`; + * the redaction guard is `src/lib/support/__tests__/support-report-redaction.test.ts`. */ import { apiHandler, requireAdmin } from "@/lib/api-handler"; -import { notImplemented } from "@/lib/http/not-implemented"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { + buildSupportReportText, + collectSupportReportFacts, +} from "@/lib/support/report"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { await requireAdmin(); - return notImplemented("admin.support_report.get"); + annotate({ action: { name: "admin.support_report.get" } }); + + const { facts, context } = await collectSupportReportFacts(); + const text = buildSupportReportText(facts, context); + + annotate({ + meta: { + support_report_accounts: facts.accounts.total, + support_report_bytes: text.length, + }, + }); + return apiSuccess({ generatedAt: facts.generatedAt, text }); }); diff --git a/src/lib/__tests__/api-handler-error-request-id.test.ts b/src/lib/__tests__/api-handler-error-request-id.test.ts index a76c73493..ca32c21b0 100644 --- a/src/lib/__tests__/api-handler-error-request-id.test.ts +++ b/src/lib/__tests__/api-handler-error-request-id.test.ts @@ -29,7 +29,8 @@ function call( handler: () => Promise, headers: Record = {}, ) { - return apiHandler(handler)( + const route = apiHandler(async (_request: NextRequest) => handler()); + return route( new NextRequest("http://localhost/api/probe", { headers }), ); } diff --git a/src/lib/support/__tests__/support-report-redaction.test.ts b/src/lib/support/__tests__/support-report-redaction.test.ts new file mode 100644 index 000000000..d21046b33 --- /dev/null +++ b/src/lib/support/__tests__/support-report-redaction.test.ts @@ -0,0 +1,309 @@ +/** + * Redaction guard for the operator's support report. + * + * The report is pasted into public issues. This seeds every free-text slot it + * carries with something it must never print (an account's email, id and + * username, a token, a URL, a foreign host, an address, a request id) and + * proves two things: + * + * 1. the finished text contains none of them; + * 2. every redaction step is load-bearing: with any single step dropped, + * the seed of its class reaches the output. A step that can be removed + * without a test going red would be a step nobody notices is gone. + */ +import { describe, expect, it } from "vitest"; + +import { + buildSupportReportText, + errorClassOf, + renderSupportReport, + summariseProblemEvents, + type SupportReportFacts, +} from "../report"; +import { + redactSupportText, + SUPPORT_REDACTION_STEPS, + type SupportRedactionContext, +} from "../redact"; +import type { WideEvent } from "@/lib/logging/types"; + +const ACCOUNT_EMAIL = "anna.schmidt@example.org"; +const ACCOUNT_ID = "cmq7x0abcdefghijklmnopqrs"; +const ACCOUNT_USERNAME = "annaschmidt"; +const ACCOUNT_NAME = "Anna Schmidt"; + +/** One seed per step; each is caught by its own step and by no other. */ +const SEEDS = { + "known-values": ACCOUNT_USERNAME, + secrets: "sk-ant-api03-Zq9Xw8Vu7Ts6", + urls: "smb://nas/backups", + emails: "someone@", + ids: "5d8e1b72-9a8c-49b7-801c-1dd42ba79dcc", + "ip-addresses": "192.168.17.42", + "host-names": "gotify.home.arpa", +} as const; + +const CONTEXT: SupportRedactionContext = { + knownValues: [ACCOUNT_ID, ACCOUNT_USERNAME, ACCOUNT_EMAIL, ACCOUNT_NAME], +}; + +function seededFacts(): SupportReportFacts { + return { + generatedAt: "2026-10-15T10:00:00.000Z", + build: { + version: "1.43.0", + buildSha: "abc1234", + builtAt: "2026-10-15T08:00:00.000Z", + node: "v22.11.0", + processType: "web,worker", + heapLimitMb: 792, + uptimeSeconds: 7260, + }, + database: { + version: "16.4", + timezone: "Etc/UTC", + sizeBytes: 104857600, + lastMigration: "0410_relation_links", + lastMigrationAt: "2026-10-15T08:05:00.000Z", + unfinishedMigrations: 0, + }, + config: [ + ["Secure cookie flag", "yes"], + // A value that should only ever be yes/no, carrying a URL anyway. + ["AI provider types chosen by accounts", `OPENAI 1, ${SEEDS.urls}`], + ], + jobs: { + workerInThisProcess: true, + workerRunning: true, + workerLastHeartbeat: "2026-10-15T09:59:00.000Z", + newestFinishedJobAt: "2026-10-15T09:59:30.000Z", + failing: [ + { + queue: "environment-fetch", + failures: 3, + lastFailedAt: "2026-10-15T09:00:00.000Z", + errorClass: `Error for ${ACCOUNT_EMAIL}`, + }, + ], + }, + accounts: { total: 2, read: 2 }, + connections: [ + { + provider: "withings", + state: "action_required", + reason: "reauth_required", + accounts: [2], + }, + { + provider: "oura", + state: "degraded", + reason: `provider_errors ${SEEDS["ip-addresses"]}`, + accounts: [1], + }, + ], + incidents: [ + { + provider: "nightscout", + reason: `unexpected_silence ${SEEDS["host-names"]}`, + openedAt: "2026-10-14T00:00:00.000Z", + announced: true, + account: 1, + }, + ], + ai: { + defaultLevel: "STANDARD", + maxLevel: "FULL", + levels: [["instance default", 2]], + providers: [ + { + provider: "LOCAL", + ok: 1, + failing: 1, + authFailed: 0, + paused: 0, + lastFailureAt: "2026-10-15T07:00:00.000Z", + lastStatus: 502, + }, + ], + }, + notifications: [ + { + channel: "NTFY", + ok: 10, + error: 2, + skipped: 0, + topReasons: [[`http_500 ${SEEDS.ids}`, 2]], + }, + ], + backups: { + newestLocalAt: "2026-10-12T03:00:00.000Z", + localCopies: 2, + lastLocalRun: { + at: "2026-10-12T03:10:00.000Z", + state: "completed", + errorClass: null, + }, + offhostConfigured: true, + offhostNewestAt: "2026-10-15T02:00:00.000Z", + offhostOldestAt: "2026-10-15T01:00:00.000Z", + offhostAccounts: 2, + offhostNeverStored: 0, + lastOffhostRun: null, + lastRestoreDrill: { + at: "2026-10-01T04:11:00.000Z", + state: "failed", + errorClass: `TypeError Bearer ${SEEDS.secrets}`, + }, + }, + errors: { + ringSize: 3, + ringMax: 1000, + top: [ + { + action: `integration.sync.${ACCOUNT_ID}`, + level: "error", + count: 2, + lastSeen: "2026-10-15T09:30:00.000Z", + errorClass: `${ACCOUNT_USERNAME} Error`, + }, + { + action: "notification.send", + level: "warn", + count: 1, + lastSeen: "2026-10-15T09:20:00.000Z", + errorClass: `someone@mailbox.org ${ACCOUNT_NAME}`, + }, + ], + }, + }; +} + +const FORBIDDEN = [ + ACCOUNT_EMAIL, + ACCOUNT_ID, + ACCOUNT_USERNAME, + ACCOUNT_NAME, + ...Object.values(SEEDS), +]; + +describe("support report redaction", () => { + it("prints none of the seeded values", () => { + const text = buildSupportReportText(seededFacts(), CONTEXT); + for (const value of FORBIDDEN) { + expect(text.toLowerCase(), value).not.toContain(value.toLowerCase()); + } + // The report itself survived: sections, accounts as numbers, dates. + expect(text).toContain("# HealthLog support report"); + expect(text).toContain("Account 2"); + expect(text).toContain("2026-10-15T09:30:00.000Z"); + expect(text).toContain("0410_relation_links"); + expect(text).toContain("environment-fetch"); + }); + + it("the seeds really are in the unredacted text", () => { + const raw = renderSupportReport(seededFacts()); + for (const value of FORBIDDEN) expect(raw).toContain(value); + }); + + it.each(SUPPORT_REDACTION_STEPS.map((s) => s.name))( + "goes red without the %s step", + (name) => { + const seed = SEEDS[name as keyof typeof SEEDS]; + expect(seed, `no seed for step ${name}`).toBeDefined(); + const without = SUPPORT_REDACTION_STEPS.filter((s) => s.name !== name); + const text = redactSupportText( + renderSupportReport(seededFacts()), + CONTEXT, + without, + ); + expect(text.toLowerCase()).toContain(seed.toLowerCase()); + }, + ); + + it("has a seed for every step, so a new step needs one", () => { + expect(Object.keys(SEEDS).sort()).toEqual( + SUPPORT_REDACTION_STEPS.map((s) => s.name).sort(), + ); + }); + + it("leaves dates, versions and clock times alone", () => { + const text = redactSupportText( + "- Node: v22.11.0\n- Built at: 2026-10-15T08:00:00.000Z\n- Version: 1.43.0", + CONTEXT, + ); + expect(text).toContain("v22.11.0"); + expect(text).toContain("2026-10-15T08:00:00.000Z"); + expect(text).toContain("1.43.0"); + }); +}); + +describe("error classes never carry the message", () => { + it("names the class or code and drops the rest", () => { + expect( + errorClassOf( + `TypeError: fetch failed for https://gotify.home.arpa/?token=abc (${ACCOUNT_EMAIL})`, + ), + ).toBe("TypeError"); + expect(errorClassOf("connect ECONNREFUSED 192.168.17.42:5432")).toBe( + "ECONNREFUSED", + ); + expect( + errorClassOf("Invalid `prisma.user.update()` invocation: P2002 unique"), + ).toBe("P2002"); + expect(errorClassOf("Withings answered HTTP 503 for annaschmidt")).toBe( + "503", + ); + expect(errorClassOf("job timed out: did not finish within 1 h")).toBe( + "timeout", + ); + expect(errorClassOf("anything at all, an email a@b.co")).toBe("Error"); + expect(errorClassOf(null)).toBe("unknown"); + }); + + it("groups problem events by action without reading their messages", () => { + const event = (over: Partial): WideEvent => ({ + timestamp: "2026-10-15T09:00:00.000Z", + duration_ms: 5, + request_id: SEEDS.ids, + trace_id: "t", + level: "error", + kind: "http", + service: "healthlog", + environment: "test", + ...over, + }); + const top = summariseProblemEvents([ + event({ + action: { name: "measurement.batch.ingest" }, + error: { + type: "PrismaClientKnownRequestError", + code: "P2002", + message: `duplicate for ${ACCOUNT_EMAIL}`, + }, + }), + event({ + timestamp: "2026-10-15T09:05:00.000Z", + action: { name: "measurement.batch.ingest" }, + error: { type: "Error", message: "Bearer hlk_secret_value_here" }, + }), + event({ + level: "warn", + http: { method: "GET", path: "/x", route: "/x", status: 404 }, + }), + ]); + expect(top[0]).toEqual({ + action: "measurement.batch.ingest", + level: "error", + count: 2, + lastSeen: "2026-10-15T09:05:00.000Z", + errorClass: "Error", + }); + expect(top[1]).toMatchObject({ + action: "http GET 404", + errorClass: "HTTP 404", + }); + expect(JSON.stringify(top)).not.toContain(ACCOUNT_EMAIL); + expect(JSON.stringify(top)).not.toContain("hlk_secret"); + expect(JSON.stringify(top)).not.toContain(SEEDS.ids); + }); +}); diff --git a/src/lib/support/redact.ts b/src/lib/support/redact.ts new file mode 100644 index 000000000..28eb95bd5 --- /dev/null +++ b/src/lib/support/redact.ts @@ -0,0 +1,164 @@ +/** + * Redaction for the support report. + * + * The report is meant to be pasted into a public issue. It is built from + * counts, states and closed codes, so in the ordinary case there is nothing + * here to remove. These steps are the second layer: the free-text fields the + * report does carry (an error class, a delivery reason, an action name) come + * from places a future change could fill with something it should not, and + * the whole rendered text runs through every step before it leaves. + * + * The steps are an ordered list rather than one function so the guard test + * can drop each one in turn and prove it is load-bearing: with any single + * step removed, a seeded value of its class reaches the output. + * + * Never an email address, an account id, a token, a host name, an address or + * a value an account wrote. + */ +import { redactSecrets } from "@/lib/logging/redact"; + +/** Values that belong to this instance's accounts and must never appear. */ +export interface SupportRedactionContext { + /** Email addresses, usernames, display names, account ids. */ + knownValues: readonly string[]; +} + +export interface SupportRedactionStep { + name: string; + apply: (text: string, context: SupportRedactionContext) => string; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Shortest known value replaced. Shorter usernames (`ann`, `max`) are left to + * the structural rules: the report prints no username anywhere, and a + * three-letter match would rewrite ordinary words of the report itself. + */ +const KNOWN_VALUE_MIN_LENGTH = 5; + +/** Top-level domains a host name in a self-hosted setup ends in. */ +const HOST_TLDS = [ + "com", + "net", + "org", + "io", + "dev", + "app", + "cloud", + "info", + "me", + "de", + "at", + "ch", + "eu", + "uk", + "fr", + "es", + "it", + "pl", + "nl", + "kr", + "us", + "local", + "lan", + "home", + "internal", + "arpa", + "localdomain", + "ts", +].join("|"); + +export const SUPPORT_REDACTION_STEPS: readonly SupportRedactionStep[] = [ + { + // Exact values of this instance's accounts, wherever they turn up. + name: "known-values", + apply: (text, context) => { + let out = text; + const values = [...new Set(context.knownValues)] + .map((v) => v.trim()) + .filter((v) => v.length >= KNOWN_VALUE_MIN_LENGTH) + // Longest first, so an email is replaced before the username in it. + .sort((a, b) => b.length - a.length); + for (const value of values) { + out = out.replace(new RegExp(escapeRegExp(value), "gi"), "[account]"); + } + return out; + }, + }, + { + // Bearer tokens, API keys, path and query secrets: the shared scrubber. + name: "secrets", + apply: (text) => redactSecrets(text), + }, + { + name: "urls", + apply: (text) => + text.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s)>\]]+/gi, "[url]"), + }, + { + name: "emails", + apply: (text) => + text.replace( + /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, + "[email]", + ), + }, + { + // UUIDs and cuid-shaped ids: account, record and request ids. + name: "ids", + apply: (text) => + text + .replace( + /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, + "[id]", + ) + .replace(/\bc[a-z0-9]{20,32}\b/g, "[id]"), + }, + { + name: "ip-addresses", + apply: (text) => + text + .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[ip]") + // Four groups or more, so a clock time (`10:00:00`) is not one. + .replace(/\b(?:[0-9a-f]{1,4}:){3,7}[0-9a-f]{1,4}\b/gi, "[ip]") + .replace(/(?:\b[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:?)+/gi, "[ip]"), + }, + { + name: "host-names", + apply: (text) => + text.replace( + new RegExp( + `\\b(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+(?:${HOST_TLDS})\\b`, + "gi", + ), + "[host]", + ), + }, +]; + +/** Run every step (or the given list) over the text, in order. */ +export function redactSupportText( + text: string, + context: SupportRedactionContext, + steps: readonly SupportRedactionStep[] = SUPPORT_REDACTION_STEPS, +): string { + return steps.reduce((acc, step) => step.apply(acc, context), text); +} + +/** + * One line of free text, made safe to stand in the report: control + * characters out, whitespace folded, cut to `max`. The content itself is + * left to the redaction steps, which see the whole report. + */ +export function supportLine(value: unknown, max = 80): string { + if (typeof value !== "string" && typeof value !== "number") return ""; + const folded = String(value) + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u001f\u007f]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + return folded.length > max ? `${folded.slice(0, max - 1)}…` : folded; +} diff --git a/src/lib/support/report.ts b/src/lib/support/report.ts new file mode 100644 index 000000000..8dae07ce3 --- /dev/null +++ b/src/lib/support/report.ts @@ -0,0 +1,875 @@ +/** + * The operator's support report: a redacted plain-text summary of what this + * instance looks like, for pasting into a bug report. + * + * Three stages, kept apart so each can be tested on its own: + * + * 1. `collectSupportReportFacts()` reads the database, the queue and this + * process. Accounts leave it as numbers ("Account 1..n", oldest first); + * no account id, username or email is part of the facts. + * 2. `renderSupportReport()` turns the facts into text. Pure. + * 3. `redactSupportText()` runs every redaction step over that text, with + * the instance's own emails, usernames, names and ids as known values. + * + * What it never contains: an email address, an account id, a value anyone + * recorded, a token, the host name of a foreign service. Configuration is yes + * or no, never the setting itself. Errors appear as an action name and an + * error class, never as their message. + * + * The report is English on purpose: it is read by whoever answers the issue. + */ +import { getHeapStatistics } from "node:v8"; + +import { prisma } from "@/lib/db"; +import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; +import { isOidcConfigured, isOidcOnly } from "@/lib/auth/oidc"; +import { getLoggingConfig } from "@/lib/logging/config"; +import { + readLogBuffer, + PROBLEMS_BUFFER_MAX, +} from "@/lib/logging/in-memory-buffer"; +import type { WideEvent } from "@/lib/logging/types"; +import { + offhostBackupConfigured, + OFFHOST_BACKUP_QUEUE, +} from "@/lib/jobs/offhost-backup"; +import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy"; +import { RESTORE_DRILL_QUEUE } from "@/lib/jobs/restore-drill"; +import { + JOB_FAILURE_WINDOW_HOURS, + readFailingQueues, + readLastQueueRun, + type LastQueueRun, +} from "@/lib/jobs/job-failures"; +import { getWorkerStatus } from "@/lib/jobs/worker-status"; +import { getProcessType, shouldRunWorker } from "@/lib/process-type"; +import { offlineGeoReady } from "@/lib/geo"; +import { configuredNotificationPrivateOrigins } from "@/lib/notifications/egress-policy"; +import { isEmailConfigured } from "@/lib/notifications/senders/email-config"; +import { loadConnectionHealth } from "@/lib/integrations/connection-health-load"; +import { CONNECTION_KEYS } from "@/lib/integrations/connection-health"; +import { + redactSupportText, + supportLine, + type SupportRedactionContext, +} from "./redact"; +import packageJson from "../../../package.json"; + +/** Accounts whose connections the report reads; the rest are counted only. */ +export const SUPPORT_REPORT_ACCOUNT_CAP = 200; +/** How far back the notification tallies reach. */ +export const SUPPORT_REPORT_NOTIFICATION_DAYS = 7; +/** How many error actions the report lists. */ +export const SUPPORT_REPORT_TOP_ERRORS = 20; + +// ─── Facts ─────────────────────────────────────────────────────────────── + +export interface QueueRunFact { + at: string; + state: LastQueueRun["state"]; + errorClass: string | null; +} + +export interface SupportReportFacts { + generatedAt: string; + build: { + version: string; + buildSha: string | null; + builtAt: string | null; + node: string; + processType: string; + heapLimitMb: number; + uptimeSeconds: number; + }; + database: { + version: string | null; + timezone: string | null; + sizeBytes: number | null; + lastMigration: string | null; + lastMigrationAt: string | null; + unfinishedMigrations: number | null; + }; + /** Label and a yes / no / count answer. Never the setting itself. */ + config: Array<[string, string]>; + jobs: { + workerInThisProcess: boolean; + workerRunning: boolean; + workerLastHeartbeat: string | null; + newestFinishedJobAt: string | null; + /** `null` when the queue tables could not be read. */ + failing: Array<{ + queue: string; + failures: number; + lastFailedAt: string; + errorClass: string; + }> | null; + }; + accounts: { total: number; read: number }; + connections: Array<{ + provider: string; + state: string; + reason: string; + accounts: number[]; + }>; + incidents: Array<{ + provider: string; + reason: string; + openedAt: string | null; + announced: boolean; + account: number | null; + }>; + ai: { + defaultLevel: string | null; + maxLevel: string | null; + /** Level (or `instance default`) to number of accounts. */ + levels: Array<[string, number]>; + providers: Array<{ + provider: string; + ok: number; + failing: number; + authFailed: number; + paused: number; + lastFailureAt: string | null; + lastStatus: number | null; + }>; + }; + notifications: Array<{ + channel: string; + ok: number; + error: number; + skipped: number; + topReasons: Array<[string, number]>; + }>; + backups: { + newestLocalAt: string | null; + localCopies: number; + lastLocalRun: QueueRunFact | null; + offhostConfigured: boolean; + offhostNewestAt: string | null; + offhostOldestAt: string | null; + offhostAccounts: number; + offhostNeverStored: number; + lastOffhostRun: QueueRunFact | null; + lastRestoreDrill: QueueRunFact | null; + }; + errors: { + ringSize: number; + ringMax: number; + top: Array<{ + action: string; + level: string; + count: number; + lastSeen: string; + errorClass: string; + }>; + }; +} + +// ─── Error classes ─────────────────────────────────────────────────────── + +/** + * A short class for a failure text: never the message. Timeouts and lost + * heartbeats by name, otherwise the thrown class (`TypeError`), a Prisma code + * (`P2002`), a system code (`ECONNREFUSED`) or an HTTP status, whichever the + * text names; plain `Error` when it names none. + */ +export function errorClassOf(text: string | null | undefined): string { + if (!text) return "unknown"; + const trimmed = text.trim(); + if (/^(job timed out|handler execution exceeded)/i.test(trimmed)) { + return "timeout"; + } + if (/^job heartbeat timeout/i.test(trimmed)) return "heartbeat_timeout"; + const parts: string[] = []; + const named = /^([A-Z][A-Za-z0-9]{1,60}(?:Error|Exception))\b/.exec(trimmed); + if (named) parts.push(named[1]); + const code = + /\b(P\d{4})\b/.exec(trimmed) ?? + /\b(E[A-Z][A-Z_]{2,30})\b/.exec(trimmed) ?? + /\b(?:HTTP|status)\s*:?\s*([1-5]\d{2})\b/i.exec(trimmed); + if (code) parts.push(code[1]); + return parts.length > 0 ? parts.join(" ") : "Error"; +} + +/** The class of a wide event's failure: thrown class and code, else its level. */ +export function eventErrorClass(event: WideEvent): string { + if (event.error) { + const type = supportLine(event.error.type, 60) || "Error"; + const code = + event.error.code !== undefined && event.error.code !== null + ? supportLine(String(event.error.code), 40) + : ""; + return code ? `${type} ${code}` : type; + } + const status = event.http?.status; + return typeof status === "number" && status >= 400 ? `HTTP ${status}` : "-"; +} + +/** The name an event is grouped under: its action, else its kind and status. */ +function eventActionName(event: WideEvent): string { + const name = supportLine(event.action?.name, 80); + if (name) return name; + if (event.kind === "background") { + return supportLine(event.background?.task_name, 80) || "background"; + } + const status = event.http?.status; + return `http ${event.http?.method ?? ""} ${status ?? ""}` + .replace(/\s+/g, " ") + .trim(); +} + +/** The most frequent warn/error actions in the problems ring. */ +export function summariseProblemEvents( + events: readonly WideEvent[], + limit: number = SUPPORT_REPORT_TOP_ERRORS, +): SupportReportFacts["errors"]["top"] { + const groups = new Map< + string, + { + action: string; + level: string; + count: number; + lastSeen: string; + errorClass: string; + } + >(); + for (const event of events) { + const action = eventActionName(event); + const key = `${event.level}\u0000${action}`; + const existing = groups.get(key); + if (!existing) { + groups.set(key, { + action, + level: event.level, + count: 1, + lastSeen: event.timestamp, + errorClass: eventErrorClass(event), + }); + continue; + } + existing.count += 1; + if (event.timestamp > existing.lastSeen) { + existing.lastSeen = event.timestamp; + existing.errorClass = eventErrorClass(event); + } + } + return [...groups.values()] + .sort((a, b) => b.count - a.count || b.lastSeen.localeCompare(a.lastSeen)) + .slice(0, limit); +} + +// ─── Collect ───────────────────────────────────────────────────────────── + +function iso(value: Date | null | undefined): string | null { + return value ? value.toISOString() : null; +} + +function runFact(run: LastQueueRun | null): QueueRunFact | null { + if (!run) return null; + return { + at: run.at, + state: run.state, + errorClass: run.error ? errorClassOf(run.error) : null, + }; +} + +function yesNo(value: boolean): string { + return value ? "yes" : "no"; +} + +function envSet(name: string): boolean { + return (process.env[name] ?? "").trim().length > 0; +} + +async function readDatabaseFacts(): Promise { + const out: SupportReportFacts["database"] = { + version: null, + timezone: null, + sizeBytes: null, + lastMigration: null, + lastMigrationAt: null, + unfinishedMigrations: null, + }; + try { + const [row] = await prisma.$queryRaw< + Array<{ version: string; timezone: string; size: bigint }> + >` + SELECT current_setting('server_version') AS version, + current_setting('TimeZone') AS timezone, + pg_database_size(current_database()) AS size + `; + if (row) { + out.version = row.version; + out.timezone = row.timezone; + out.sizeBytes = Number(row.size); + } + } catch { + /* reported as unknown */ + } + try { + const [last] = await prisma.$queryRaw< + Array<{ migration_name: string; finished_at: Date | null }> + >` + SELECT migration_name, finished_at + FROM _prisma_migrations + WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL + ORDER BY finished_at DESC, migration_name DESC + LIMIT 1 + `; + const [unfinished] = await prisma.$queryRaw>` + SELECT count(*)::int AS count + FROM _prisma_migrations + WHERE finished_at IS NULL AND rolled_back_at IS NULL + `; + out.lastMigration = last?.migration_name ?? null; + out.lastMigrationAt = iso(last?.finished_at); + out.unfinishedMigrations = unfinished?.count ?? null; + } catch { + /* no migrations table: reported as unknown */ + } + return out; +} + +async function readNewestFinishedJobAt(): Promise { + try { + const [row] = await prisma.$queryRaw>` + SELECT max(completed_on) AS at FROM pgboss.job WHERE state = 'completed' + `; + return iso(row?.at); + } catch { + return null; + } +} + +/** Read everything the report shows. Accounts leave here as numbers only. */ +export async function collectSupportReportFacts( + now: Date = new Date(), +): Promise<{ facts: SupportReportFacts; context: SupportRedactionContext }> { + const users = await prisma.user.findMany({ + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { + id: true, + username: true, + email: true, + fullName: true, + aiUsageLevel: true, + }, + }); + const accountOf = new Map(); + users.forEach((u, i) => accountOf.set(u.id, i + 1)); + const knownValues: string[] = []; + for (const u of users) { + knownValues.push(u.id, u.username); + if (u.email) knownValues.push(u.email); + if (u.fullName) knownValues.push(u.fullName); + } + + const appSettings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { + glitchtipEnabled: true, + glitchtipDsn: true, + webPushVapidPublicKey: true, + webPushVapidPrivateKeyEncrypted: true, + adminAiKeyEncrypted: true, + adminCodexConnectionStatus: true, + aiUsageDefaultLevel: true, + aiUsageMaxLevel: true, + }, + }); + + const providerTypes = await prisma.user.groupBy({ + by: ["aiProvider"], + _count: { _all: true }, + }); + + const config: Array<[string, string]> = [ + ["Secure cookie flag", yesNo(shouldEmitSecureCookie())], + ["OIDC sign-in", yesNo(isOidcConfigured())], + ["OIDC only", yesNo(isOidcOnly())], + ["Loki", yesNo(Boolean(getLoggingConfig().lokiEndpoint))], + [ + "GlitchTip", + yesNo(Boolean(appSettings?.glitchtipEnabled && appSettings.glitchtipDsn)), + ], + ["Off-host backup", yesNo(offhostBackupConfigured())], + [ + "APNs", + yesNo( + envSet("APNS_KEY_ID") && + envSet("APNS_TEAM_ID") && + envSet("APNS_BUNDLE_ID") && + (envSet("APNS_KEY_B64") || + envSet("APNS_KEY") || + envSet("APNS_KEY_FILE")), + ), + ], + [ + "Web Push (VAPID)", + yesNo( + Boolean( + appSettings?.webPushVapidPublicKey && + appSettings.webPushVapidPrivateKeyEncrypted, + ) || + (envSet("VAPID_PUBLIC_KEY") && envSet("VAPID_PRIVATE_KEY")), + ), + ], + ["SMTP", yesNo(isEmailConfigured())], + ["GeoLite2 databases", yesNo(offlineGeoReady())], + [ + "NOTIFICATION_PRIVATE_ORIGINS entries", + String(configuredNotificationPrivateOrigins().size), + ], + ["Operator AI key", yesNo(Boolean(appSettings?.adminAiKeyEncrypted))], + [ + "Central Codex connected", + yesNo(appSettings?.adminCodexConnectionStatus === "connected"), + ], + [ + "AI provider types chosen by accounts", + providerTypes + .filter((row) => row.aiProvider) + .map((row) => `${supportLine(row.aiProvider, 30)} ${row._count._all}`) + .sort() + .join(", ") || "none", + ], + ["Demo mode", yesNo(process.env.DEMO_MODE === "true")], + ]; + + const workerStatus = getWorkerStatus(); + const [database, failing, newestFinishedJobAt] = await Promise.all([ + readDatabaseFacts(), + readFailingQueues(JOB_FAILURE_WINDOW_HOURS), + readNewestFinishedJobAt(), + ]); + + // Connections: one health read per account, capped. + const readUsers = users.slice(0, SUPPORT_REPORT_ACCOUNT_CAP); + const connectionGroups = new Map< + string, + { provider: string; state: string; reason: string; accounts: number[] } + >(); + for (const user of readUsers) { + let health; + try { + health = await loadConnectionHealth(user.id); + } catch { + continue; + } + for (const key of CONNECTION_KEYS) { + const h = health[key]; + if (!h) continue; + const groupKey = `${key}|${h.state}|${h.reason}`; + const group = connectionGroups.get(groupKey) ?? { + provider: key, + state: h.state, + reason: h.reason, + accounts: [], + }; + group.accounts.push(accountOf.get(user.id) ?? 0); + connectionGroups.set(groupKey, group); + } + } + + const incidentRows = await prisma.integrationWatch.findMany({ + where: { incidentReason: { not: null } }, + select: { + userId: true, + integration: true, + incidentReason: true, + incidentOpenedAt: true, + alertedAt: true, + }, + orderBy: { incidentOpenedAt: "desc" }, + take: 100, + }); + + const providerRows = await prisma.providerHealth.findMany({ + select: { + providerType: true, + lastResult: true, + lastStatus: true, + lastFailureAt: true, + nextRetryAt: true, + }, + }); + const providers = new Map< + string, + SupportReportFacts["ai"]["providers"][number] + >(); + for (const row of providerRows) { + const p = providers.get(row.providerType) ?? { + provider: row.providerType, + ok: 0, + failing: 0, + authFailed: 0, + paused: 0, + lastFailureAt: null, + lastStatus: null, + }; + if (row.lastResult === "ok") p.ok += 1; + else if (row.lastResult === "auth_failed") p.authFailed += 1; + else p.failing += 1; + if (row.nextRetryAt && row.nextRetryAt.getTime() > now.getTime()) { + p.paused += 1; + } + const failedAt = iso(row.lastFailureAt); + if (failedAt && (!p.lastFailureAt || failedAt > p.lastFailureAt)) { + p.lastFailureAt = failedAt; + p.lastStatus = row.lastStatus; + } + providers.set(row.providerType, p); + } + + const levelCounts = new Map(); + for (const u of users) { + const level = u.aiUsageLevel ?? "instance default"; + levelCounts.set(level, (levelCounts.get(level) ?? 0) + 1); + } + + const since = new Date( + now.getTime() - SUPPORT_REPORT_NOTIFICATION_DAYS * 24 * 60 * 60 * 1000, + ); + const [attemptCounts, reasonCounts] = await Promise.all([ + prisma.pushAttempt.groupBy({ + by: ["channel", "result"], + where: { createdAt: { gte: since } }, + _count: { _all: true }, + }), + prisma.pushAttempt.groupBy({ + by: ["channel", "reason"], + where: { createdAt: { gte: since }, result: { not: "ok" } }, + _count: { _all: true }, + }), + ]); + const channels = new Map< + string, + SupportReportFacts["notifications"][number] + >(); + const channelOf = (channel: string) => { + const c = channels.get(channel) ?? { + channel, + ok: 0, + error: 0, + skipped: 0, + topReasons: [], + }; + channels.set(channel, c); + return c; + }; + for (const row of attemptCounts) { + const c = channelOf(row.channel); + if (row.result === "ok") c.ok += row._count._all; + else if (row.result === "error") c.error += row._count._all; + else c.skipped += row._count._all; + } + for (const row of reasonCounts) { + channelOf(row.channel).topReasons.push([ + supportLine(row.reason ?? "unspecified", 60), + row._count._all, + ]); + } + for (const c of channels.values()) { + c.topReasons = c.topReasons.sort((a, b) => b[1] - a[1]).slice(0, 3); + } + + const [ + localAggregate, + localCopies, + offhostRows, + lastLocalRun, + lastOffhostRun, + lastDrill, + ] = await Promise.all([ + prisma.dataBackup.aggregate({ _max: { createdAt: true } }), + prisma.dataBackup.count(), + prisma.offhostBackupState.findMany({ select: { lastSuccessAt: true } }), + readLastQueueRun(DATA_BACKUP_QUEUE), + readLastQueueRun(OFFHOST_BACKUP_QUEUE), + readLastQueueRun(RESTORE_DRILL_QUEUE), + ]); + const offhostDates = offhostRows + .map((r) => r.lastSuccessAt) + .filter((d): d is Date => d !== null) + .map((d) => d.toISOString()) + .sort(); + + const problemEvents = readLogBuffer({ ring: "problems" }); + + const facts: SupportReportFacts = { + generatedAt: now.toISOString(), + build: { + version: + process.env.NEXT_PUBLIC_APP_VERSION?.trim() || packageJson.version, + buildSha: process.env.NEXT_PUBLIC_APP_BUILD_SHA?.trim() || null, + builtAt: process.env.NEXT_PUBLIC_APP_BUILT_AT?.trim() || null, + node: process.version, + processType: getProcessType(), + heapLimitMb: Math.round( + getHeapStatistics().heap_size_limit / 1024 / 1024, + ), + uptimeSeconds: Math.round(process.uptime()), + }, + database, + config, + jobs: { + workerInThisProcess: shouldRunWorker(), + workerRunning: workerStatus.running, + workerLastHeartbeat: workerStatus.lastHeartbeat, + newestFinishedJobAt, + failing: + failing === null + ? null + : failing.map((q) => ({ + queue: q.queue, + failures: q.failures, + lastFailedAt: q.lastFailedAt, + errorClass: errorClassOf(q.lastError), + })), + }, + accounts: { total: users.length, read: readUsers.length }, + connections: [...connectionGroups.values()].sort( + (a, b) => + a.provider.localeCompare(b.provider) || + a.state.localeCompare(b.state) || + a.reason.localeCompare(b.reason), + ), + incidents: incidentRows.map((row) => ({ + provider: row.integration, + reason: row.incidentReason ?? "", + openedAt: iso(row.incidentOpenedAt), + announced: row.alertedAt !== null, + account: accountOf.get(row.userId) ?? null, + })), + ai: { + defaultLevel: appSettings?.aiUsageDefaultLevel ?? null, + maxLevel: appSettings?.aiUsageMaxLevel ?? null, + levels: [...levelCounts.entries()].sort((a, b) => + a[0].localeCompare(b[0]), + ), + providers: [...providers.values()].sort((a, b) => + a.provider.localeCompare(b.provider), + ), + }, + notifications: [...channels.values()].sort((a, b) => + a.channel.localeCompare(b.channel), + ), + backups: { + newestLocalAt: iso(localAggregate._max.createdAt), + localCopies, + lastLocalRun: runFact(lastLocalRun), + offhostConfigured: offhostBackupConfigured(), + offhostNewestAt: offhostDates.at(-1) ?? null, + offhostOldestAt: offhostDates[0] ?? null, + offhostAccounts: offhostRows.length, + offhostNeverStored: offhostRows.length - offhostDates.length, + lastOffhostRun: runFact(lastOffhostRun), + lastRestoreDrill: runFact(lastDrill), + }, + errors: { + ringSize: problemEvents.length, + ringMax: PROBLEMS_BUFFER_MAX, + top: summariseProblemEvents(problemEvents), + }, + }; + + return { facts, context: { knownValues } }; +} + +// ─── Render ────────────────────────────────────────────────────────────── + +function or(value: string | number | null | undefined, fallback = "unknown") { + return value === null || value === undefined || value === "" + ? fallback + : String(value); +} + +function megabytes(bytes: number | null): string { + return bytes === null ? "unknown" : `${Math.round(bytes / 1024 / 1024)} MB`; +} + +function runLine(run: QueueRunFact | null): string { + if (!run) return "no run on record"; + return run.errorClass + ? `${run.state} at ${run.at} (${run.errorClass})` + : `${run.state} at ${run.at}`; +} + +function accountList(accounts: readonly number[]): string { + const sorted = [...accounts].sort((a, b) => a - b); + const shown = sorted.slice(0, 10).map((n) => `Account ${n}`); + const more = sorted.length - shown.length; + return more > 0 ? `${shown.join(", ")} and ${more} more` : shown.join(", "); +} + +/** The report as plain text, before redaction. */ +export function renderSupportReport(facts: SupportReportFacts): string { + const lines: string[] = []; + const section = (title: string) => { + if (lines.length > 0) lines.push(""); + lines.push(`## ${title}`); + }; + const row = (label: string, value: string | number | null) => + lines.push(`- ${label}: ${or(value)}`); + + lines.push("# HealthLog support report"); + lines.push(`Generated: ${facts.generatedAt}`); + + section("Build"); + row("Version", facts.build.version); + row("Build SHA", facts.build.buildSha); + row("Built at", facts.build.builtAt); + row("Node", facts.build.node); + row("Process type", facts.build.processType); + row("Heap limit", `${facts.build.heapLimitMb} MB`); + row( + "Uptime", + `${Math.floor(facts.build.uptimeSeconds / 3600)} h ${Math.floor((facts.build.uptimeSeconds % 3600) / 60)} min`, + ); + + section("Database"); + row("PostgreSQL", facts.database.version); + row("Server time zone", facts.database.timezone); + row("Size", megabytes(facts.database.sizeBytes)); + row( + "Last migration", + facts.database.lastMigration + ? `${facts.database.lastMigration} (${or(facts.database.lastMigrationAt)})` + : null, + ); + row("Unfinished migrations", facts.database.unfinishedMigrations); + + section("Configuration"); + for (const [label, value] of facts.config) row(label, value); + + section("Jobs"); + row("Worker in this process", yesNo(facts.jobs.workerInThisProcess)); + if (facts.jobs.workerInThisProcess) { + row("Worker running", yesNo(facts.jobs.workerRunning)); + row("Worker last heartbeat", facts.jobs.workerLastHeartbeat); + } + row("Newest finished job", facts.jobs.newestFinishedJobAt); + if (facts.jobs.failing === null) { + row( + `Failed jobs (${JOB_FAILURE_WINDOW_HOURS} h)`, + "queue tables not readable", + ); + } else if (facts.jobs.failing.length === 0) { + row(`Failed jobs (${JOB_FAILURE_WINDOW_HOURS} h)`, "none"); + } else { + lines.push(`- Failed jobs (${JOB_FAILURE_WINDOW_HOURS} h):`); + for (const q of facts.jobs.failing) { + lines.push( + ` - ${supportLine(q.queue, 60)}: ${q.failures} failed, last ${q.lastFailedAt} (${q.errorClass})`, + ); + } + } + + section("Connections"); + row( + "Accounts", + facts.accounts.read < facts.accounts.total + ? `${facts.accounts.total} (connections read for the oldest ${facts.accounts.read})` + : facts.accounts.total, + ); + if (facts.connections.length === 0) { + row("Connected sources", "none"); + } else { + for (const c of facts.connections) { + const who = c.state === "ok" ? "" : ` (${accountList(c.accounts)})`; + lines.push( + `- ${c.provider}: ${c.state} / ${c.reason}: ${c.accounts.length}${who}`, + ); + } + } + if (facts.incidents.length === 0) { + row("Open watchdog incidents", "none"); + } else { + lines.push("- Open watchdog incidents:"); + for (const i of facts.incidents) { + lines.push( + ` - ${supportLine(i.provider, 40)}: ${supportLine(i.reason, 40)} since ${or(i.openedAt)}, ${i.announced ? "announced" : "not announced"}${i.account ? `, Account ${i.account}` : ""}`, + ); + } + } + + section("AI"); + row("Usage level default", facts.ai.defaultLevel); + row("Usage level ceiling", facts.ai.maxLevel); + row( + "Accounts by usage level", + facts.ai.levels.map(([level, n]) => `${level} ${n}`).join(", ") || "none", + ); + if (facts.ai.providers.length === 0) { + row("Provider ledger", "empty"); + } else { + lines.push("- Provider ledger (accounts):"); + for (const p of facts.ai.providers) { + const last = p.lastFailureAt + ? `, last failure ${p.lastFailureAt}${p.lastStatus ? ` (HTTP ${p.lastStatus})` : ""}` + : ""; + lines.push( + ` - ${supportLine(p.provider, 30)}: ok ${p.ok}, failing ${p.failing}, credential refused ${p.authFailed}, paused ${p.paused}${last}`, + ); + } + } + + section(`Notifications (${SUPPORT_REPORT_NOTIFICATION_DAYS} days)`); + if (facts.notifications.length === 0) { + row("Delivery attempts", "none"); + } else { + for (const c of facts.notifications) { + const reasons = + c.topReasons.length > 0 + ? `; ${c.topReasons.map(([r, n]) => `${r} ${n}`).join(", ")}` + : ""; + lines.push( + `- ${supportLine(c.channel, 20)}: ok ${c.ok}, failed ${c.error}, skipped ${c.skipped}${reasons}`, + ); + } + } + + section("Backups"); + row("Newest in-database copy", facts.backups.newestLocalAt ?? "none"); + row("In-database copies", facts.backups.localCopies); + row("Last weekly run", runLine(facts.backups.lastLocalRun)); + row("Off-host configured", yesNo(facts.backups.offhostConfigured)); + if (facts.backups.offhostAccounts > 0) { + row("Off-host newest", facts.backups.offhostNewestAt ?? "none"); + row( + "Off-host oldest account copy", + facts.backups.offhostOldestAt ?? "none", + ); + row( + "Off-host accounts never stored", + `${facts.backups.offhostNeverStored} of ${facts.backups.offhostAccounts}`, + ); + } + row("Last off-host run", runLine(facts.backups.lastOffhostRun)); + row("Last restore drill", runLine(facts.backups.lastRestoreDrill)); + + section("Recent warnings and errors (this process)"); + row( + "Problems ring", + `${facts.errors.ringSize} of ${facts.errors.ringMax} entries`, + ); + if (facts.errors.top.length === 0) { + row("Top actions", "none"); + } else { + for (const e of facts.errors.top) { + lines.push( + `- ${e.level} ${e.action}: ${e.count}x, last ${e.lastSeen}, ${e.errorClass}`, + ); + } + } + + return `${lines.join("\n")}\n`; +} + +/** Facts to finished, redacted text. */ +export function buildSupportReportText( + facts: SupportReportFacts, + context: SupportRedactionContext, +): string { + return redactSupportText(renderSupportReport(facts), context); +} diff --git a/tests/integration/admin-support-report.test.ts b/tests/integration/admin-support-report.test.ts new file mode 100644 index 000000000..8a05ac7a5 --- /dev/null +++ b/tests/integration/admin-support-report.test.ts @@ -0,0 +1,152 @@ +/** + * `GET /api/admin/support-report` against real Postgres. + * + * - admin only, through the real `apiHandler` and `requireAdmin`; + * - the raw reads (server version, time zone, size, the migrations table, + * the queue) and the group-bys run against the real schema; + * - accounts with distinctive usernames, emails and names appear only as + * "Account n", and their ids never. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { GET } from "@/app/api/admin/support-report/route"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); +} + +function call() { + return GET(new NextRequest("http://localhost/api/admin/support-report")); +} + +describe("GET /api/admin/support-report (integration)", () => { + it("refuses a signed-in non-admin", async () => { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { username: "plainmember", email: "plain.member@example.test" }, + }); + await signIn(user.id); + const res = await call(); + expect(res.status).toBe(403); + }); + + it("answers a redacted report built from the real schema", async () => { + const prisma = getPrismaClient(); + const admin = await prisma.user.create({ + data: { + username: "operatorwithaname", + email: "operator.distinct@example.test", + fullName: "Ottilie Operatorson", + role: "ADMIN", + }, + }); + const member = await prisma.user.create({ + data: { + username: "memberwithaname", + email: "member.distinct@example.test", + fullName: "Mathilda Memberson", + }, + }); + await prisma.providerHealth.create({ + data: { + userId: member.id, + providerType: "LOCAL", + lastResult: "auth_failed", + lastStatus: 401, + consecutiveFailures: 3, + lastFailureAt: new Date(), + nextRetryAt: new Date(Date.now() + 3_600_000), + }, + }); + await prisma.integrationWatch.create({ + data: { + userId: member.id, + integration: "nightscout", + incidentReason: "unexpected_silence", + incidentOpenedAt: new Date(), + }, + }); + await prisma.pushAttempt.create({ + data: { + userId: member.id, + channel: "NTFY", + eventType: "MEDICATION_REMINDER", + result: "error", + reason: "http_500", + }, + }); + await signIn(admin.id); + + const res = await call(); + expect(res.status).toBe(200); + const body = await res.json(); + const text: string = body.data.text; + + expect(text).toMatch(/- PostgreSQL: \d+/); + expect(text).toMatch(/- Server time zone: \S+/); + expect(text).toMatch(/- Size: \d+ MB/); + expect(text).toMatch(/- Last migration: \d{4}_\w+ \(/); + expect(text).toContain("- Unfinished migrations: 0"); + expect(text).toContain( + "- LOCAL: ok 0, failing 0, credential refused 1, paused 1", + ); + expect(text).toContain("nightscout: unexpected_silence since"); + expect(text).toContain("Account 2"); + expect(text).toContain("- NTFY: ok 0, failed 1, skipped 0; http_500 1"); + expect(text).toContain("- Accounts: 2"); + + for (const value of [ + admin.id, + member.id, + admin.username, + member.username, + admin.email!, + member.email!, + "Ottilie", + "Mathilda", + ]) { + expect(text.toLowerCase(), value).not.toContain(value.toLowerCase()); + } + }); +}); From 800bebd45cbbe08c6d817b3e4c00322f3b285b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:18:29 +0200 Subject: [PATCH 04/10] chore(support): drop an unused lint directive --- src/lib/support/redact.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/support/redact.ts b/src/lib/support/redact.ts index 28eb95bd5..78b7b7c63 100644 --- a/src/lib/support/redact.ts +++ b/src/lib/support/redact.ts @@ -156,7 +156,6 @@ export function redactSupportText( export function supportLine(value: unknown, max = 80): string { if (typeof value !== "string" && typeof value !== "number") return ""; const folded = String(value) - // eslint-disable-next-line no-control-regex .replace(/[\u0000-\u001f\u007f]+/g, " ") .replace(/\s+/g, " ") .trim(); From f7386742b4a5f1fa76aa959a4b4481f397172631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:31:51 +0200 Subject: [PATCH 05/10] feat(web): show the error id on server failures and add the support info and report surfaces --- messages/de.json | 28 +++- messages/en.json | 28 +++- messages/es.json | 28 +++- messages/fr.json | 28 +++- messages/it.json | 28 +++- messages/ko.json | 28 +++- messages/pl.json | 28 +++- .../error-display-helper-guard.test.ts | 103 +++++++++++++ src/app/admin/[section]/renderer.tsx | 3 + src/app/global-error.tsx | 51 ++++++ src/components/admin/_shared.tsx | 4 +- .../admin/ai-server-key-section.tsx | 4 +- src/components/admin/assistant-section.tsx | 4 +- src/components/admin/backups-section.tsx | 19 ++- .../admin/central-codex-section.tsx | 10 +- src/components/admin/encryption-section.tsx | 6 +- src/components/admin/glitchtip-section.tsx | 4 +- .../admin/invite-tokens-section.tsx | 13 +- src/components/admin/key-backup-card.tsx | 11 +- .../admin/login-overview-section.tsx | 5 +- .../admin/measurement-maintenance-card.tsx | 6 +- .../admin/module-availability-section.tsx | 4 +- src/components/admin/reminders-section.tsx | 9 +- src/components/admin/support-report-card.tsx | 128 ++++++++++++++++ src/components/admin/umami-section.tsx | 4 +- .../admin/user-management-section.tsx | 7 +- .../admin/web-push-vapid-section.tsx | 7 +- .../custom-metrics/custom-metric-detail.tsx | 4 +- .../custom-metric-history-list.tsx | 8 +- src/components/cycle/use-cycle.ts | 13 +- .../documents/document-detail-sheet.tsx | 17 +- .../documents/document-facts-review.tsx | 4 +- .../documents/document-link-picker.tsx | 6 +- src/components/documents/documents-view.tsx | 23 ++- src/components/encounters/encounter-sheet.tsx | 8 +- src/components/error-details.tsx | 8 +- src/components/errors/error-id-line.tsx | 73 +++++++++ src/components/illness/episode-menu.tsx | 4 +- .../coach-panel/coach-conversation.tsx | 19 ++- .../coach-panel/copy-table-button.tsx | 5 +- .../insights/coach-panel/memory-note.tsx | 3 +- .../insights/coach-panel/message-actions.tsx | 11 +- .../coach-panel/reminder-suggestion-card.tsx | 5 +- .../insights/coach-panel/sources-rail.tsx | 4 +- .../coach-panel/suggested-action-card.tsx | 4 +- .../insights/coach-panel/use-coach.ts | 5 +- .../insights/insights-edit-mode.tsx | 6 +- .../insights/insights-tab-strip.tsx | 3 +- .../insights/pattern-dismiss-action.tsx | 9 +- src/components/labs/biomarker-manager.tsx | 7 +- src/components/labs/lab-biomarker-detail.tsx | 4 +- src/components/labs/lab-history-list.tsx | 8 +- src/components/labs/ocr-review-dialog.tsx | 11 +- .../measurements/measurement-list.tsx | 13 +- .../detail/efficacy/efficacy-tab.tsx | 7 +- .../medications/dose-history-add-dialog.tsx | 8 +- .../medications/dose-history-ledger.tsx | 24 ++- .../medications/glp1-medication-card.tsx | 5 +- .../medications/intake-edit-dialog.tsx | 7 +- .../medications/intake-history-editable.tsx | 13 +- .../medications/log-intake-dialog.tsx | 3 +- .../medications/medication-card.tsx | 5 +- .../medication-categories-sheet.tsx | 19 ++- .../scheduling/schedule-history-timeline.tsx | 12 +- .../scheduling/schedule-times-editor.tsx | 7 +- .../medications/sections/api-tokens-row.tsx | 10 +- .../sections/auto-mark-taken-section.tsx | 7 +- .../medications/sections/courses-section.tsx | 5 +- .../sections/destructive-zone-section.tsx | 25 ++- .../sections/intake-tracking-section.tsx | 7 +- .../sections/inventory-dialogs.tsx | 19 ++- .../sections/inventory-section.tsx | 7 +- .../sections/notifications-section.tsx | 7 +- .../sections/phase-config-sheet.tsx | 4 +- .../medications/sections/settings-section.tsx | 13 +- .../medications/side-effects-section.tsx | 7 +- src/components/medications/take-all-due.ts | 5 +- .../medications/use-medication-intake.ts | 19 ++- .../mood/__tests__/mood-qol-fixes.test.tsx | 6 +- .../mood/manage/archived-tags-card.tsx | 13 +- .../mood/manage/tag-editor-sheet.tsx | 8 +- .../mood/manage/tag-groups-card.tsx | 16 +- .../mood/manage/tag-manager-card.tsx | 16 +- src/components/mood/mood-list.tsx | 13 +- src/components/onboarding/baseline-form.tsx | 5 +- src/components/onboarding/confirm-screen.tsx | 5 +- src/components/onboarding/done-screen.tsx | 5 +- .../onboarding/first-result-screen.tsx | 9 +- src/components/onboarding/question-screen.tsx | 5 +- src/components/onboarding/units-screen.tsx | 5 +- src/components/onboarding/welcome-screen.tsx | 5 +- src/components/outcome/outcome-toast.ts | 3 +- .../practitioners/practitioner-list.tsx | 6 +- .../practitioners/practitioner-sheet.tsx | 3 +- src/components/providers.tsx | 3 +- .../records/about-me-note-manager.tsx | 3 +- .../records/ai-profile-inclusion-manager.tsx | 3 +- .../records/allergy-free-text-note.tsx | 3 +- src/components/records/allergy-manager.tsx | 4 +- src/components/records/conditions-manager.tsx | 5 +- .../records/emergency-profile-manager.tsx | 4 +- .../records/family-history-manager.tsx | 4 +- .../records/health-profile-facts-manager.tsx | 6 +- src/components/settings/about-section.tsx | 4 + .../access/managed-profile-handover.tsx | 7 +- .../settings/account-section/setup-card.tsx | 5 +- .../settings/ai/runtime-actions-row.tsx | 5 +- src/components/settings/api-section.tsx | 11 +- .../settings/coach-memory-section.tsx | 11 +- .../settings/dashboard-layout-section.tsx | 3 +- .../settings/environment-section.tsx | 38 +++-- .../settings/insights-pill-order-section.tsx | 3 +- .../integrations/connections-panel.tsx | 5 +- src/components/settings/mcp-section.tsx | 24 +-- src/components/settings/score-section.tsx | 15 +- src/components/settings/sources-section.tsx | 6 +- src/components/settings/support-info-card.tsx | 123 +++++++++++++++ .../settings/thresholds-editor-section.tsx | 7 +- src/components/targets/target-edit-sheet.tsx | 7 +- .../vaccinations/booster-mint-prompt.tsx | 4 +- .../vaccinations/custom-vaccine-sheet.tsx | 9 +- .../vaccination-document-suggestion.tsx | 6 +- .../vaccinations/vaccination-sheet.tsx | 9 +- src/hooks/use-account-switch.ts | 4 +- src/hooks/use-auth.ts | 4 +- src/hooks/use-module-toggle.ts | 3 +- src/lib/api/__tests__/api-fetch.test.ts | 48 ++++++ src/lib/api/api-fetch.ts | 30 +++- src/lib/errors/__tests__/error-id.test.ts | 98 ++++++++++++ src/lib/errors/__tests__/error-toast.test.tsx | 48 ++++++ src/lib/errors/error-id.ts | 129 ++++++++++++++++ src/lib/errors/error-toast.tsx | 64 ++++++++ src/lib/queries/use-documents-layout.ts | 5 +- src/lib/queries/use-medication-list-layout.ts | 5 +- .../support/__tests__/support-info.test.ts | 123 +++++++++++++++ src/lib/support/redact.ts | 3 +- src/lib/support/support-info.ts | 145 ++++++++++++++++++ .../integration/admin-support-report.test.ts | 3 +- 138 files changed, 1982 insertions(+), 318 deletions(-) create mode 100644 src/__tests__/error-display-helper-guard.test.ts create mode 100644 src/components/admin/support-report-card.tsx create mode 100644 src/components/errors/error-id-line.tsx create mode 100644 src/components/settings/support-info-card.tsx create mode 100644 src/lib/errors/__tests__/error-id.test.ts create mode 100644 src/lib/errors/__tests__/error-toast.test.tsx create mode 100644 src/lib/errors/error-id.ts create mode 100644 src/lib/errors/error-toast.tsx create mode 100644 src/lib/support/__tests__/support-info.test.ts create mode 100644 src/lib/support/support-info.ts diff --git a/messages/de.json b/messages/de.json index 5e17d1bf6..f467801d0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours} Std. {minutes} Min.", "durationMinutes": "{minutes} Min.", "durationClockMinutes": "{time} Min.", - "durationClockHours": "{time} Std." + "durationClockHours": "{time} Std.", + "errorIdLabel": "Fehler-ID", + "errorIdCopy": "Fehler-ID kopieren", + "errorIdCopied": "Fehler-ID kopiert" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "Dokumentation", "credits": "Credits & Datenquellen", "linksHeading": "Quellen & Dokumentation", - "newerAvailable": "Neue Version: {tag}" + "newerAvailable": "Neue Version: {tag}", + "support": { + "title": "Support", + "description": "Eine Zusammenfassung für eine Fehlermeldung, ohne Namen, E-Mail oder Gesundheitswerte.", + "copy": "Support-Info kopieren", + "copied": "Kopiert", + "copyFailed": "Die Support-Info konnte nicht zusammengestellt werden." + } }, "testConnection": { "test": "Verbindung testen", @@ -7753,7 +7763,8 @@ "eventDetails": "Event-Details", "closeDetails": "Details schließen", "processDetail": "Der Puffer ist flüchtig und auf 500 Einträge begrenzt.", - "cardTitle": "Letzte Ereignisse" + "cardTitle": "Letzte Ereignisse", + "filterErrorId": "Fehler-ID" }, "backups": { "title": "Backups", @@ -8120,7 +8131,16 @@ "codes": "Für den Betrieb: API-Anfragen antworten mit 503 und dem Fehlercode {code}, /api/health meldet den Grund {reason}." }, "loginOverviewCardTitle": "Anmeldeereignisse", - "userColumn": "Nutzer" + "userColumn": "Nutzer", + "supportReport": { + "title": "Support-Bericht", + "description": "Eine Zusammenfassung dieser Instanz für eine Fehlermeldung, ohne E-Mails, IDs oder Werte.", + "body": "Konten erscheinen als „Account 1“, „Account 2“ und so weiter. Lies den Bericht, bevor du ihn teilst.", + "copy": "Kopieren", + "copied": "Kopiert", + "download": "Herunterladen", + "loadError": "Der Support-Bericht konnte nicht erstellt werden." + } }, "achievements": { "title": "Erfolge", diff --git a/messages/en.json b/messages/en.json index b08ee23b9..6afd60c39 100644 --- a/messages/en.json +++ b/messages/en.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours}h {minutes}m", "durationMinutes": "{minutes}m", "durationClockMinutes": "{time} min", - "durationClockHours": "{time} h" + "durationClockHours": "{time} h", + "errorIdLabel": "Error ID", + "errorIdCopy": "Copy error ID", + "errorIdCopied": "Error ID copied" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "Documentation", "credits": "Credits & data attributions", "linksHeading": "Sources & docs", - "newerAvailable": "Newer version: {tag}" + "newerAvailable": "Newer version: {tag}", + "support": { + "title": "Support", + "description": "A summary for a bug report, without your name, email or health values.", + "copy": "Copy support info", + "copied": "Copied", + "copyFailed": "The support info could not be put together." + } }, "testConnection": { "test": "Test connection", @@ -7753,7 +7763,8 @@ "eventDetails": "Event details", "closeDetails": "Close details", "processDetail": "Buffer is volatile and capped at 500 entries.", - "cardTitle": "Recent events" + "cardTitle": "Recent events", + "filterErrorId": "Error ID" }, "backups": { "title": "Backups", @@ -8120,7 +8131,16 @@ "codes": "For the operator: API requests answer 503 with error code {code}, and /api/health reports reason {reason}." }, "loginOverviewCardTitle": "Sign-in events", - "userColumn": "User" + "userColumn": "User", + "supportReport": { + "title": "Support report", + "description": "A summary of this instance for a bug report, without emails, IDs or values.", + "body": "Accounts appear as “Account 1”, “Account 2” and so on. Read the report before you share it.", + "copy": "Copy", + "copied": "Copied", + "download": "Download", + "loadError": "The support report could not be created." + } }, "achievements": { "title": "Achievements", diff --git a/messages/es.json b/messages/es.json index 9bf9001d7..8a5ba0bf7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours} h {minutes} min", "durationMinutes": "{minutes} min", "durationClockMinutes": "{time} min", - "durationClockHours": "{time} h" + "durationClockHours": "{time} h", + "errorIdLabel": "ID de error", + "errorIdCopy": "Copiar ID de error", + "errorIdCopied": "ID de error copiado" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "Documentación", "credits": "Créditos y atribuciones de datos", "linksHeading": "Fuentes y documentación", - "newerAvailable": "Nueva versión: {tag}" + "newerAvailable": "Nueva versión: {tag}", + "support": { + "title": "Soporte", + "description": "Un resumen para un informe de error, sin tu nombre, correo ni valores de salud.", + "copy": "Copiar información de soporte", + "copied": "Copiado", + "copyFailed": "No se pudo reunir la información de soporte." + } }, "testConnection": { "test": "Probar conexión", @@ -7753,7 +7763,8 @@ "eventDetails": "Detalles del evento", "closeDetails": "Cerrar detalles", "processDetail": "El búfer es volátil y está limitado a 500 entradas.", - "cardTitle": "Eventos recientes" + "cardTitle": "Eventos recientes", + "filterErrorId": "ID de error" }, "backups": { "title": "Copias de seguridad", @@ -8120,7 +8131,16 @@ "codes": "Para el administrador: las peticiones a la API responden 503 con el código de error {code}, y /api/health indica el motivo {reason}." }, "loginOverviewCardTitle": "Eventos de inicio de sesión", - "userColumn": "Usuario" + "userColumn": "Usuario", + "supportReport": { + "title": "Informe de soporte", + "description": "Un resumen de esta instancia para un informe de error, sin correos, ID ni valores.", + "body": "Las cuentas aparecen como «Account 1», «Account 2», etc. Lee el informe antes de compartirlo.", + "copy": "Copiar", + "copied": "Copiado", + "download": "Descargar", + "loadError": "No se pudo crear el informe de soporte." + } }, "achievements": { "title": "Logros", diff --git a/messages/fr.json b/messages/fr.json index 878a0384b..5d3bd5fd1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours} h {minutes} min", "durationMinutes": "{minutes} min", "durationClockMinutes": "{time} min", - "durationClockHours": "{time} h" + "durationClockHours": "{time} h", + "errorIdLabel": "ID d'erreur", + "errorIdCopy": "Copier l'ID d'erreur", + "errorIdCopied": "ID d'erreur copié" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "Documentation", "credits": "Crédits et attributions de données", "linksHeading": "Sources et documentation", - "newerAvailable": "Nouvelle version : {tag}" + "newerAvailable": "Nouvelle version : {tag}", + "support": { + "title": "Assistance", + "description": "Un résumé pour un signalement de bug, sans votre nom, votre e-mail ni vos valeurs de santé.", + "copy": "Copier les infos d'assistance", + "copied": "Copié", + "copyFailed": "Les infos d'assistance n'ont pas pu être rassemblées." + } }, "testConnection": { "test": "Tester la connexion", @@ -7753,7 +7763,8 @@ "eventDetails": "Détails de l'événement", "closeDetails": "Fermer les détails", "processDetail": "Le tampon est volatil et limité à 500 entrées.", - "cardTitle": "Événements récents" + "cardTitle": "Événements récents", + "filterErrorId": "ID d'erreur" }, "backups": { "title": "Sauvegardes", @@ -8120,7 +8131,16 @@ "codes": "Pour l'administrateur : les requêtes API répondent 503 avec le code d'erreur {code}, et /api/health indique la raison {reason}." }, "loginOverviewCardTitle": "Événements de connexion", - "userColumn": "Utilisateur" + "userColumn": "Utilisateur", + "supportReport": { + "title": "Rapport d'assistance", + "description": "Un résumé de cette instance pour un signalement de bug, sans e-mails, identifiants ni valeurs.", + "body": "Les comptes apparaissent comme « Account 1 », « Account 2 », etc. Lisez le rapport avant de le partager.", + "copy": "Copier", + "copied": "Copié", + "download": "Télécharger", + "loadError": "Le rapport d'assistance n'a pas pu être créé." + } }, "achievements": { "title": "Réussites", diff --git a/messages/it.json b/messages/it.json index 2c3c7df29..adaf75f83 100644 --- a/messages/it.json +++ b/messages/it.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours} h {minutes} min", "durationMinutes": "{minutes} min", "durationClockMinutes": "{time} min", - "durationClockHours": "{time} h" + "durationClockHours": "{time} h", + "errorIdLabel": "ID errore", + "errorIdCopy": "Copia ID errore", + "errorIdCopied": "ID errore copiato" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "Documentazione", "credits": "Crediti e attribuzioni dei dati", "linksHeading": "Fonti e documentazione", - "newerAvailable": "Nuova versione: {tag}" + "newerAvailable": "Nuova versione: {tag}", + "support": { + "title": "Assistenza", + "description": "Un riepilogo per una segnalazione di errore, senza nome, email o valori di salute.", + "copy": "Copia le info di assistenza", + "copied": "Copiato", + "copyFailed": "Non è stato possibile raccogliere le info di assistenza." + } }, "testConnection": { "test": "Prova connessione", @@ -7753,7 +7763,8 @@ "eventDetails": "Dettagli dell'evento", "closeDetails": "Chiudi dettagli", "processDetail": "Il buffer è volatile e limitato a 500 voci.", - "cardTitle": "Eventi recenti" + "cardTitle": "Eventi recenti", + "filterErrorId": "ID errore" }, "backups": { "title": "Backup", @@ -8120,7 +8131,16 @@ "codes": "Per chi gestisce il server: le richieste API rispondono 503 con il codice di errore {code}, e /api/health indica il motivo {reason}." }, "loginOverviewCardTitle": "Eventi di accesso", - "userColumn": "Utente" + "userColumn": "Utente", + "supportReport": { + "title": "Rapporto di assistenza", + "description": "Un riepilogo di questa istanza per una segnalazione di errore, senza email, ID o valori.", + "body": "Gli account compaiono come “Account 1”, “Account 2” e così via. Leggi il rapporto prima di condividerlo.", + "copy": "Copia", + "copied": "Copiato", + "download": "Scarica", + "loadError": "Non è stato possibile creare il rapporto di assistenza." + } }, "achievements": { "title": "Obiettivi", diff --git a/messages/ko.json b/messages/ko.json index 1d65cae91..e958a666a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours}시간 {minutes}분", "durationMinutes": "{minutes}분", "durationClockMinutes": "{time}분", - "durationClockHours": "{time}시간" + "durationClockHours": "{time}시간", + "errorIdLabel": "오류 ID", + "errorIdCopy": "오류 ID 복사", + "errorIdCopied": "오류 ID 복사됨" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "문서", "credits": "크레딧 및 데이터 출처", "linksHeading": "출처 및 문서", - "newerAvailable": "새 버전: {tag}" + "newerAvailable": "새 버전: {tag}", + "support": { + "title": "지원", + "description": "버그 신고용 요약이며, 이름, 이메일, 건강 수치는 포함되지 않습니다.", + "copy": "지원 정보 복사", + "copied": "복사됨", + "copyFailed": "지원 정보를 만들 수 없습니다." + } }, "testConnection": { "test": "연결 테스트", @@ -7729,7 +7739,8 @@ "eventDetails": "이벤트 상세", "closeDetails": "상세 닫기", "processDetail": "버퍼는 휘발성이고 최대 500개까지만 담아요.", - "cardTitle": "최근 이벤트" + "cardTitle": "최근 이벤트", + "filterErrorId": "오류 ID" }, "backups": { "title": "백업", @@ -8120,7 +8131,16 @@ "codes": "운영자용: API 요청은 오류 코드 {code}와 함께 503으로 응답하고, /api/health는 원인 {reason}을 알려 줍니다." }, "loginOverviewCardTitle": "로그인 이벤트", - "userColumn": "사용자" + "userColumn": "사용자", + "supportReport": { + "title": "지원 보고서", + "description": "버그 신고용 이 인스턴스의 요약이며, 이메일, ID, 수치는 포함되지 않습니다.", + "body": "계정은 “Account 1”, “Account 2”처럼 표시됩니다. 공유하기 전에 보고서를 확인하세요.", + "copy": "복사", + "copied": "복사됨", + "download": "다운로드", + "loadError": "지원 보고서를 만들 수 없습니다." + } }, "achievements": { "title": "업적", diff --git a/messages/pl.json b/messages/pl.json index 6093866f9..4cf8b75a8 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -51,7 +51,10 @@ "durationHoursMinutes": "{hours} godz. {minutes} min", "durationMinutes": "{minutes} min", "durationClockMinutes": "{time} min", - "durationClockHours": "{time} godz." + "durationClockHours": "{time} godz.", + "errorIdLabel": "ID błędu", + "errorIdCopy": "Kopiuj ID błędu", + "errorIdCopied": "Skopiowano ID błędu" }, "apiErrors": { "measurement": { @@ -6646,7 +6649,14 @@ "docs": "Dokumentacja", "credits": "Autorzy i źródła danych", "linksHeading": "Źródła i dokumentacja", - "newerAvailable": "Nowa wersja: {tag}" + "newerAvailable": "Nowa wersja: {tag}", + "support": { + "title": "Pomoc", + "description": "Podsumowanie do zgłoszenia błędu, bez imienia, adresu e-mail i wartości zdrowotnych.", + "copy": "Kopiuj informacje dla pomocy", + "copied": "Skopiowano", + "copyFailed": "Nie udało się zebrać informacji dla pomocy." + } }, "testConnection": { "test": "Testuj połączenie", @@ -7753,7 +7763,8 @@ "eventDetails": "Szczegóły zdarzenia", "closeDetails": "Zamknij szczegóły", "processDetail": "Bufor jest ulotny i ograniczony do 500 wpisów.", - "cardTitle": "Ostatnie zdarzenia" + "cardTitle": "Ostatnie zdarzenia", + "filterErrorId": "ID błędu" }, "backups": { "title": "Kopie zapasowe", @@ -8120,7 +8131,16 @@ "codes": "Dla administratora: żądania API odpowiadają 503 z kodem błędu {code}, a /api/health podaje powód {reason}." }, "loginOverviewCardTitle": "Zdarzenia logowania", - "userColumn": "Użytkownik" + "userColumn": "Użytkownik", + "supportReport": { + "title": "Raport dla pomocy", + "description": "Podsumowanie tej instancji do zgłoszenia błędu, bez adresów e-mail, identyfikatorów i wartości.", + "body": "Konta pojawiają się jako „Account 1”, „Account 2” itd. Przeczytaj raport, zanim go udostępnisz.", + "copy": "Kopiuj", + "copied": "Skopiowano", + "download": "Pobierz", + "loadError": "Nie udało się utworzyć raportu dla pomocy." + } }, "achievements": { "title": "Osiągnięcia", diff --git a/src/__tests__/error-display-helper-guard.test.ts b/src/__tests__/error-display-helper-guard.test.ts new file mode 100644 index 000000000..bb1f2b20c --- /dev/null +++ b/src/__tests__/error-display-helper-guard.test.ts @@ -0,0 +1,103 @@ +/** + * Every error display goes through the one helper that shows an error id. + * + * A self-hoster reporting a failure needs the id the operator finds it by. The + * web shows it on a server failure with a copy button, through two doors only: + * + * - toasts: `showErrorToast` (`src/lib/errors/error-toast.tsx`), which adds + * the id when its `cause` is a 5xx and is otherwise exactly `toast.error`; + * - error boundaries: `ErrorDetails` / `ErrorIdLine`, or `errorIdOf` for the + * root boundary that renders without any provider. + * + * A raw `toast.error(` anywhere else is a toast that can never carry the id, + * and a route boundary that skips the helper is a crash screen without it. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); +const HELPER = "lib/errors/error-toast.tsx"; + +/** Whitespace-tolerant: `toast.error(`, `toast .error (`, `toast\n.error(`. */ +const RAW_TOAST_ERROR = /\btoast\s*\.\s*error\s*\(/g; +const HELPER_CALL = /\bshowErrorToast\s*\(/g; + +/** + * Route boundaries that deliberately show no error id, with the reason. Each + * entry is a decision, not a convenience. + */ +const BOUNDARY_EXEMPT: Readonly> = { + // The clinician share view is opened by someone outside the instance, with + // no account and no way to reach its operator; it shows a retry and nothing + // a probe could learn from, by design (see the file's header). + "app/c/error.tsx": "public share view, no operator relationship", +}; + +const files = walkSourceFiles(SRC, { floor: 2500 }).filter( + (rel) => + !rel.includes("__tests__/") && + !/\.test\.tsx?$/.test(rel) && + !rel.startsWith("generated/"), +); + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +describe("error display helper guard", () => { + it("finds the helper's own toast.error, so the matcher works", () => { + const own = read(HELPER).match(RAW_TOAST_ERROR) ?? []; + expect(own.length).toBeGreaterThan(0); + }); + + it("allows no toast.error outside the helper", () => { + const offenders = files + .filter((rel) => rel !== HELPER) + .filter((rel) => (read(rel).match(RAW_TOAST_ERROR) ?? []).length > 0) + .sort(); + expect(offenders).toEqual([]); + }); + + it("routes the app's error toasts through showErrorToast", () => { + const calls = files.reduce( + (n, rel) => n + (read(rel).match(HELPER_CALL) ?? []).length, + 0, + ); + // 200 call sites when the helper landed; a count far below it means the + // matcher or the walk stopped seeing them. + expect(calls).toBeGreaterThan(180); + }); + + it("renders every route error boundary through the error id helper", () => { + const boundaries = files.filter((rel) => + /^app\/(.*\/)?(error|global-error)\.tsx$/.test(rel), + ); + expect(boundaries.length).toBeGreaterThanOrEqual(3); + const missing = boundaries + .filter((rel) => !(rel in BOUNDARY_EXEMPT)) + .filter((rel) => { + const source = read(rel); + return !( + / { + const source = read("components/error-details.tsx"); + expect(source).toMatch(/ + {/* v1.43 — the redacted report for a bug report. */} + {/* v1.42 — the table maintenance after the tombstone purge. */} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 536dbe0aa..0bcb37ce8 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; +import { errorIdOf, recordErrorId, shortErrorId } from "@/lib/errors/error-id"; import { reloadOnceForChunkError } from "@/lib/pwa/chunk-reload"; /** @@ -22,12 +23,29 @@ export default function GlobalError({ reloadOnceForChunkError(error); }, [error]); + // The id the operator finds this failure by. No i18n provider exists at + // this level, so the line is the same static bilingual pair as the heading. + const errorId = errorIdOf(error, { unexpected: true }); + useEffect(() => { + if (errorId) recordErrorId(errorId); + }, [errorId]); + + async function handleCopyId() { + if (!errorId) return; + try { + await navigator.clipboard.writeText(errorId); + } catch { + window.prompt("Error ID", errorId); + } + } + async function handleCopy() { // Strip query string — OAuth callback routes carry sensitive tokens. const loc = typeof window !== "undefined" ? window.location : null; const payload = { message: error.message, digest: error.digest, + errorId, name: error.name, stack: error.stack?.split("\n").slice(0, 10).join("\n"), urlPath: loc?.pathname ?? null, @@ -91,6 +109,39 @@ export default function GlobalError({ > {error.message || "Unknown error"} + {errorId && ( +

+ Fehler-ID / Error ID + {shortErrorId(errorId)} + +

+ )}
+ + + + ); +} diff --git a/src/components/admin/umami-section.tsx b/src/components/admin/umami-section.tsx index b1b109739..7be6ec909 100644 --- a/src/components/admin/umami-section.tsx +++ b/src/components/admin/umami-section.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { Activity, Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -50,10 +51,11 @@ export function UmamiSection() { toast.success(message); }, onError: (error) => { - toast.error( + showErrorToast( error instanceof Error ? error.message : t("admin.monitoringTestFailed"), + { cause: error }, ); }, }); diff --git a/src/components/admin/user-management-section.tsx b/src/components/admin/user-management-section.tsx index e6b0ff13a..9fa1176e5 100644 --- a/src/components/admin/user-management-section.tsx +++ b/src/components/admin/user-management-section.tsx @@ -12,6 +12,7 @@ import { Users, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Badge } from "@/components/ui/badge"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Button } from "@/components/ui/button"; @@ -99,10 +100,11 @@ export function UserManagementSection() { toast.success(t("common.saved")); }, onError: (err) => { - toast.error( + showErrorToast( err instanceof Error && err.message ? err.message : t("admin.settingsSaveError"), + { cause: err }, ); }, }); @@ -144,10 +146,11 @@ export function UserManagementSection() { setLogoutTarget(null); }, onError: (err) => { - toast.error( + showErrorToast( err instanceof Error && err.message ? err.message : t("admin.section.users.forceLogoutFailed"), + { cause: err }, ); }, }); diff --git a/src/components/admin/web-push-vapid-section.tsx b/src/components/admin/web-push-vapid-section.tsx index e56efc591..6de2dc105 100644 --- a/src/components/admin/web-push-vapid-section.tsx +++ b/src/components/admin/web-push-vapid-section.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { BellRing, KeyRound, Loader2 } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -96,7 +97,7 @@ export function WebPushVapidSection() { } if (!res.ok) { - toast.error(t("admin.webPushVapidGenerateFailed")); + showErrorToast(t("admin.webPushVapidGenerateFailed"), { cause: res }); return; } @@ -113,8 +114,8 @@ export function WebPushVapidSection() { queryKey: queryKeys.adminSettings(), }); toast.success(t("admin.webPushVapidGenerated")); - } catch { - toast.error(t("admin.webPushVapidGenerateFailed")); + } catch (failure) { + showErrorToast(t("admin.webPushVapidGenerateFailed"), { cause: failure }); } finally { setGenerating(false); } diff --git a/src/components/custom-metrics/custom-metric-detail.tsx b/src/components/custom-metrics/custom-metric-detail.tsx index 485f4dde3..c31201cf9 100644 --- a/src/components/custom-metrics/custom-metric-detail.tsx +++ b/src/components/custom-metrics/custom-metric-detail.tsx @@ -11,6 +11,7 @@ import { } from "@tanstack/react-query"; import { Gauge, ListOrdered, Pencil, Plus } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; @@ -162,7 +163,8 @@ export function CustomMetricDetail({ queryClient.invalidateQueries({ queryKey: queryKeys.customMetrics() }); router.push("/measurements"); }, - onError: () => toast.error(t("customMetrics.deleteError")), + onError: (failure) => + showErrorToast(t("customMetrics.deleteError"), { cause: failure }), }); const description = metric?.description?.trim() diff --git a/src/components/custom-metrics/custom-metric-history-list.tsx b/src/components/custom-metrics/custom-metric-history-list.tsx index d71ab8982..aadfe0b95 100644 --- a/src/components/custom-metrics/custom-metric-history-list.tsx +++ b/src/components/custom-metrics/custom-metric-history-list.tsx @@ -4,6 +4,7 @@ import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2, Pencil } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DeleteButton, SortableHead } from "@/components/data-list"; import { Button } from "@/components/ui/button"; @@ -96,8 +97,8 @@ export function CustomMetricHistoryList({ }); invalidate(); toast.success(t("customMetrics.entry.restoredToast")); - } catch { - toast.error(t("customMetrics.entry.restoreError")); + } catch (failure) { + showErrorToast(t("customMetrics.entry.restoreError"), { cause: failure }); } } @@ -113,7 +114,8 @@ export function CustomMetricHistoryList({ }, }); }, - onError: () => toast.error(t("customMetrics.entry.deleteError")), + onError: (failure) => + showErrorToast(t("customMetrics.entry.deleteError"), { cause: failure }), }); const updateMutation = useMutation({ diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index 1377d3ffd..213a2b193 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -12,6 +12,7 @@ import { useMemo } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { ApiError, @@ -233,7 +234,8 @@ export function useDeleteCustomSymptom() { onSuccess: () => invalidateKeys(qc, cycleDependentKeys), // v1.16.4 — a failed delete used to fail silently (the sheet clears the // chip optimistically), so surface the rejection. - onError: () => toast.error(t("cycle.deleteError")), + onError: (failure) => + showErrorToast(t("cycle.deleteError"), { cause: failure }), }); } @@ -355,7 +357,8 @@ export function useStartPeriod() { }, // v1.16.4 — period boundaries had no failure signal at all (the sheet // simply stayed open); a toast names the rejection. - onError: () => toast.error(t("cycle.saveError")), + onError: (failure) => + showErrorToast(t("cycle.saveError"), { cause: failure }), }); } @@ -375,7 +378,8 @@ export function useEndPeriod() { void invalidateKeys(qc, cycleDependentKeys); toastWrittenOutcome("success", t("cycle.periodEndSaved")); }, - onError: () => toast.error(t("cycle.saveError")), + onError: (failure) => + showErrorToast(t("cycle.saveError"), { cause: failure }), }); } @@ -391,7 +395,8 @@ export function useDeleteDayLog() { void invalidateKeys(qc, cycleDependentKeys); toastWrittenOutcome("success", t("cycle.deleteSuccess")); }, - onError: () => toast.error(t("cycle.deleteError")), + onError: (failure) => + showErrorToast(t("cycle.deleteError"), { cause: failure }), }); } diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 5ac47bbc9..56052eb88 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -35,6 +35,7 @@ import { import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useIllnessEpisodes } from "@/components/illness/use-illness"; import { useAuth } from "@/hooks/use-auth"; @@ -567,11 +568,11 @@ export function DocumentDetailSheet({ ? error.meta.reason : ""; if (reason === "purged") { - toast.error(t("documents.error.purged")); + showErrorToast(t("documents.error.purged"), { cause: error }); } else if (reason === "duplicateExists") { - toast.error(t("documents.error.duplicateExists")); + showErrorToast(t("documents.error.duplicateExists"), { cause: error }); } else { - toast.error(t("documents.toast.restoreFailed")); + showErrorToast(t("documents.toast.restoreFailed"), { cause: error }); } }, }); @@ -681,16 +682,17 @@ export function DocumentDetailSheet({ onSuccess: (result) => { if ("summary" in result) { if (result.persistence === "withheld") { - toast.error(result.summary); + showErrorToast(result.summary); } else if (result.persistence === "failed") { - toast.error(t("documents.detail.saveError")); + showErrorToast(t("documents.detail.saveError")); } } void queryClient.invalidateQueries({ queryKey: queryKeys.inboundDocument(documentId), }); }, - onError: (error) => toast.error(aiErrorText(error)), + onError: (error) => + showErrorToast(aiErrorText(error), { cause: error }), }, ); }; @@ -709,7 +711,8 @@ export function DocumentDetailSheet({ ? tCount("documents.ai.readStaged", result.labFactsStaged) : t("documents.ai.readDone"), ), - onError: (error) => toast.error(aiErrorText(error)), + onError: (error) => + showErrorToast(aiErrorText(error), { cause: error }), }, ); }; diff --git a/src/components/documents/document-facts-review.tsx b/src/components/documents/document-facts-review.tsx index c17019dad..2bf29dc82 100644 --- a/src/components/documents/document-facts-review.tsx +++ b/src/components/documents/document-facts-review.tsx @@ -28,6 +28,7 @@ import { useCalendarDate } from "@/hooks/use-calendar-date"; import { useState } from "react"; import { FlaskConical, Loader2, ScanSearch } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { Button } from "@/components/ui/button"; @@ -635,7 +636,8 @@ export function DocumentFactsSection({ toast.info(t("documents.review.extractEmpty")); } }, - onError: (error) => toast.error(aiErrorText(error)), + onError: (error) => + showErrorToast(aiErrorText(error), { cause: error }), }, ) } diff --git a/src/components/documents/document-link-picker.tsx b/src/components/documents/document-link-picker.tsx index 3cc1edcc4..59076672f 100644 --- a/src/components/documents/document-link-picker.tsx +++ b/src/components/documents/document-link-picker.tsx @@ -17,6 +17,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Search } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Input } from "@/components/ui/input"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -92,10 +93,11 @@ export function DocumentLinkPicker({ ), onSuccess: (data) => { if (data.results.some((r) => !r.ok)) { - toast.error(t("documents.bulk.failed")); + showErrorToast(t("documents.bulk.failed")); } }, - onError: () => toast.error(t("documents.bulk.failed")), + onError: (failure) => + showErrorToast(t("documents.bulk.failed"), { cause: failure }), onSettled: () => { void invalidateKeys(queryClient, [queryKeys.documents()]); }, diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 8959f6888..b313056cc 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -33,6 +33,7 @@ import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useIllnessEpisodes } from "@/components/illness/use-illness"; import { Button } from "@/components/ui/button"; @@ -488,7 +489,10 @@ export function DocumentsView() { }) : t("documents.contentIndex.indexAllNothing"), ), - onError: () => toast.error(t("documents.contentIndex.indexAllError")), + onError: (failure) => + showErrorToast(t("documents.contentIndex.indexAllError"), { + cause: failure, + }), }); }, [reindexAll, t]); @@ -665,7 +669,7 @@ export function DocumentsView() { (results: DocumentBulkResultDto[], successMessage: string) => { const failed = results.filter((r) => !r.ok).length; if (failed > 0) { - toast.error( + showErrorToast( t("documents.bulk.partialFailure", { failed, total: results.length, @@ -700,7 +704,8 @@ export function DocumentsView() { ); clearSelection(); }, - onError: () => toast.error(t("documents.bulk.failed")), + onError: (failure) => + showErrorToast(t("documents.bulk.failed"), { cause: failure }), }, ); }, @@ -719,7 +724,10 @@ export function DocumentsView() { count: results.filter((r) => r.ok).length, }), ), - onError: () => toast.error(t("documents.toast.restoreFailed")), + onError: (failure) => + showErrorToast(t("documents.toast.restoreFailed"), { + cause: failure, + }), }, ); }, @@ -735,7 +743,7 @@ export function DocumentsView() { const okIds = results.filter((r) => r.ok).map((r) => r.id); const failed = results.length - okIds.length; if (failed > 0) { - toast.error( + showErrorToast( t("documents.bulk.partialFailure", { failed, total: results.length, @@ -756,7 +764,8 @@ export function DocumentsView() { } clearSelection(); }, - onError: () => toast.error(t("documents.bulk.failed")), + onError: (failure) => + showErrorToast(t("documents.bulk.failed"), { cause: failure }), }, ); }, @@ -794,7 +803,7 @@ export function DocumentsView() { t("documents.card.untitled"), ); if (resolved.overCap) { - toast.error( + showErrorToast( t("documents.bulk.shareTooMany", { max: SHARE_LINK_MAX_DOCUMENTS }), ); return; diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx index 2f2a3a21a..463442272 100644 --- a/src/components/encounters/encounter-sheet.tsx +++ b/src/components/encounters/encounter-sheet.tsx @@ -34,6 +34,7 @@ import { useState } from "react"; import { Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -130,7 +131,7 @@ export function EncounterSheet({ const message = err instanceof Error ? err.message : t("encounters.saveFailed"); setError(message); - toast.error(message); + showErrorToast(message, { cause: err }); } }; @@ -205,7 +206,10 @@ export function EncounterSheet({ onClick={() => { remove.mutate(encounter.id, { onSuccess: () => onOpenChange(false), - onError: () => toast.error(t("encounters.deleteFailed")), + onError: (failure) => + showErrorToast(t("encounters.deleteFailed"), { + cause: failure, + }), }); }} > diff --git a/src/components/error-details.tsx b/src/components/error-details.tsx index f15466ad0..2de077ddb 100644 --- a/src/components/error-details.tsx +++ b/src/components/error-details.tsx @@ -3,6 +3,8 @@ import { useState } from "react"; import { Copy, CheckCircle2, RotateCcw } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { ErrorIdLine } from "@/components/errors/error-id-line"; +import { errorIdOf } from "@/lib/errors/error-id"; import { useTranslations } from "@/lib/i18n/context"; export interface ErrorDetailsProps { @@ -29,6 +31,9 @@ export function ErrorDetails({ }: ErrorDetailsProps) { const { t, locale } = useTranslations(); const [copied, setCopied] = useState(false); + // The id the operator finds this failure by: the request id of an API + // failure that reached the boundary, else the server render's digest. + const errorId = requestId ?? errorIdOf(error, { unexpected: true }); async function handleCopy() { // Only capture the pathname — some flows carry OAuth codes / state tokens @@ -47,7 +52,7 @@ export function ErrorDetails({ digest: error.digest, name: error.name, stack: error.stack?.split("\n").slice(0, 10).join("\n"), - requestId: requestId ?? null, + requestId: errorId, urlPath, searchParamCount, userAgent: typeof navigator !== "undefined" ? navigator.userAgent : null, @@ -81,6 +86,7 @@ export function ErrorDetails({
{error.message || t("common.unknownError")}
+ {errorId && }
{reset && ( + + ); +} diff --git a/src/components/illness/episode-menu.tsx b/src/components/illness/episode-menu.tsx index 6cfa233c5..1625f820e 100644 --- a/src/components/illness/episode-menu.tsx +++ b/src/components/illness/episode-menu.tsx @@ -10,6 +10,7 @@ import { useState } from "react"; import { MoreVertical, Pencil, CheckCircle2, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { @@ -69,7 +70,8 @@ export function EpisodeMenu({ label: t("common.undo"), onClick: () => { restore.mutate(episode.id, { - onError: () => toast.error(t("illness.restoreError")), + onError: (failure) => + showErrorToast(t("illness.restoreError"), { cause: failure }), }); }, }, diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 51302a748..0ac00a043 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -9,6 +9,7 @@ import { } from "@tanstack/react-query"; import { Paperclip, Sparkles } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; @@ -335,8 +336,10 @@ export function CoachConversation({ onSuccess: (next) => { queryClient.setQueryData(queryKeys.coachAboutMeQuestions(), next); }, - onError: () => { - toast.error(t("insights.coach.guided.dismissError")); + onError: (failure) => { + showErrorToast(t("insights.coach.guided.dismissError"), { + cause: failure, + }); }, }); @@ -451,7 +454,7 @@ export function CoachConversation({ } useEffect(() => { if (!invalidToastKey) return; - toast.error(t("insights.coach.attach.notFoundError")); + showErrorToast(t("insights.coach.attach.notFoundError")); }, [invalidToastKey, t]); // The pills row above the composer. On a fresh chat every pill is a staged id; @@ -559,8 +562,10 @@ export function CoachConversation({ headers: { "Idempotency-Key": randomId() }, body: form, }); - } catch { - toast.error(t("insights.coach.attach.uploadError")); + } catch (failure) { + showErrorToast(t("insights.coach.attach.uploadError"), { + cause: failure, + }); continue; } const parsed = parseUploadResponse( @@ -568,7 +573,9 @@ export function CoachConversation({ await response.text(), ); if (!parsed.ok) { - toast.error(t("insights.coach.attach.uploadError")); + showErrorToast(t("insights.coach.attach.uploadError"), { + cause: response, + }); continue; } const doc = parsed.document; diff --git a/src/components/insights/coach-panel/copy-table-button.tsx b/src/components/insights/coach-panel/copy-table-button.tsx index 29758f854..da1995278 100644 --- a/src/components/insights/coach-panel/copy-table-button.tsx +++ b/src/components/insights/coach-panel/copy-table-button.tsx @@ -12,6 +12,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Check, Copy } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DropdownMenu, @@ -61,8 +62,8 @@ export function CopyTableButton({ grid, caption }: CopyTableButtonProps) { setCopied(false); timer.current = null; }, COPY_CONFIRM_MS); - } catch { - toast.error(t(COACH_RESULT_UI_KEYS.copyFailed)); + } catch (failure) { + showErrorToast(t(COACH_RESULT_UI_KEYS.copyFailed), { cause: failure }); } }, [grid, caption, t], diff --git a/src/components/insights/coach-panel/memory-note.tsx b/src/components/insights/coach-panel/memory-note.tsx index 9b0f87fba..24b04f4b0 100644 --- a/src/components/insights/coach-panel/memory-note.tsx +++ b/src/components/insights/coach-panel/memory-note.tsx @@ -17,6 +17,7 @@ */ import { useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -83,7 +84,7 @@ export function CoachMemoryNote({ const facts = useCoachFacts({ enabled: saved && !liveFact && !undone }); const forget = useForgetCoachFact({ onSuccess: () => setUndone(true), - onError: () => toast.error(t("settings.ai.coachMemory.forgotError")), + onError: () => showErrorToast(t("settings.ai.coachMemory.forgotError")), }); if (!saved || undone || !note.factId) return null; const fact = diff --git a/src/components/insights/coach-panel/message-actions.tsx b/src/components/insights/coach-panel/message-actions.tsx index eba8cb999..31354db88 100644 --- a/src/components/insights/coach-panel/message-actions.tsx +++ b/src/components/insights/coach-panel/message-actions.tsx @@ -18,6 +18,7 @@ import { useCallback, useRef, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { BookmarkCheck, BookmarkPlus, @@ -139,8 +140,8 @@ function CopyMessageButton({ await navigator.clipboard.writeText(text); setCopied(true); window.setTimeout(() => setCopied(false), 1500); - } catch { - toast.error(t("insights.coach.copyMessageError")); + } catch (failure) { + showErrorToast(t("insights.coach.copyMessageError"), { cause: failure }); } }, [content, strip, t]); // Absent where the Clipboard API is (plain-HTTP self-hosts) rather than @@ -309,8 +310,10 @@ function RememberMessageButton({ messageId }: { messageId: string }) { ), ); }, - onError: () => { - toast.error(t("insights.coach.rememberMessage.failed")); + onError: (failure) => { + showErrorToast(t("insights.coach.rememberMessage.failed"), { + cause: failure, + }); }, }); diff --git a/src/components/insights/coach-panel/reminder-suggestion-card.tsx b/src/components/insights/coach-panel/reminder-suggestion-card.tsx index 789ebbba7..476c6277a 100644 --- a/src/components/insights/coach-panel/reminder-suggestion-card.tsx +++ b/src/components/insights/coach-panel/reminder-suggestion-card.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { BellPlus, Check, Loader2 } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -60,8 +61,8 @@ export function ReminderSuggestionCard({ : "dismissed", ); }, - onError: () => { - toast.error(t("coach.reminderSuggestion.failed")); + onError: (failure) => { + showErrorToast(t("coach.reminderSuggestion.failed"), { cause: failure }); }, }); diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx index 404da80a7..a55fbbd20 100644 --- a/src/components/insights/coach-panel/sources-rail.tsx +++ b/src/components/insights/coach-panel/sources-rail.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Eye, Loader2, Target } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; @@ -98,7 +99,8 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) { onSettled: () => setPending(null), // v1.16.4 — a failed save used to revert the switch silently once the // spinner cleared; a toast names the rejection. - onError: () => toast.error(t("insights.coach.prefsSaveError")), + onError: (failure) => + showErrorToast(t("insights.coach.prefsSaveError"), { cause: failure }), }); } diff --git a/src/components/insights/coach-panel/suggested-action-card.tsx b/src/components/insights/coach-panel/suggested-action-card.tsx index fa5694bd6..5ba5cf205 100644 --- a/src/components/insights/coach-panel/suggested-action-card.tsx +++ b/src/components/insights/coach-panel/suggested-action-card.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Check, Loader2, Sparkles } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -53,7 +54,8 @@ export function SuggestedActionCard({ } setSettled("applied"); }, - onError: () => toast.error(t("coach.suggestedAction.failed")), + onError: (failure) => + showErrorToast(t("coach.suggestedAction.failed"), { cause: failure }), }); if (settled) { diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 3aa90e76a..c0c77d30a 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -16,6 +16,7 @@ import { } from "@tanstack/react-query"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import type { CoachActivity, @@ -521,7 +522,7 @@ export function useDeleteCoachConversationWithUndo() { const failedMessage = t("insights.coach.historyDeleteFailed"); commitRef.current = (id) => commitCoachConversationDelete(queryClient, id, owner, () => - toast.error(failedMessage), + showErrorToast(failedMessage), ); }); @@ -596,7 +597,7 @@ export function useRenameCoachConversation() { }), onError: (_error, _input, snapshot) => { if (snapshot) restoreCoachConversationRename(queryClient, snapshot); - toast.error(t("insights.coach.rename.error")); + showErrorToast(t("insights.coach.rename.error"), { cause: _error }); }, onSettled: (_data, _error, input) => { void invalidateCoachConversationRename(queryClient, input.id); diff --git a/src/components/insights/insights-edit-mode.tsx b/src/components/insights/insights-edit-mode.tsx index 3aa6bb8b9..ecb985c3b 100644 --- a/src/components/insights/insights-edit-mode.tsx +++ b/src/components/insights/insights-edit-mode.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Eye, EyeOff, @@ -195,7 +196,7 @@ export function InsightsEditMode({ toast.message(t("common.conflictReloaded")); return; } - toast.error(t("insights.editMode.saveError")); + showErrorToast(t("insights.editMode.saveError"), { cause: err }); }, }); @@ -212,7 +213,8 @@ export function InsightsEditMode({ setDraftState(seedArrangeDraft(saved.sections)); toast.success(t("insights.editMode.resetSuccess")); }, - onError: () => toast.error(t("insights.editMode.saveError")), + onError: (failure) => + showErrorToast(t("insights.editMode.saveError"), { cause: failure }), }); const busy = saveMutation.isPending || resetMutation.isPending; diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 3e8c9f920..7719277d0 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -12,6 +12,7 @@ import { Wrench, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Popover, @@ -724,7 +725,7 @@ function InsightsTabStripImpl({ const decision = regenerateToastKind(regenerateOutcome); if (decision) { if (decision.kind === "error") { - toast.error(t(decision.messageKey)); + showErrorToast(t(decision.messageKey)); } else { toast.success(t(decision.messageKey)); } diff --git a/src/components/insights/pattern-dismiss-action.tsx b/src/components/insights/pattern-dismiss-action.tsx index 83118062c..9b00478ab 100644 --- a/src/components/insights/pattern-dismiss-action.tsx +++ b/src/components/insights/pattern-dismiss-action.tsx @@ -4,6 +4,7 @@ import { QueryClientContext, type QueryClient } from "@tanstack/react-query"; import { EyeOff, RotateCcw, X } from "lucide-react"; import { useContext, useReducer, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { apiPatch } from "@/lib/api/api-fetch"; @@ -138,8 +139,8 @@ export function PatternDismissButton({ { dismissed: true }, ); await applyPatternDecisionSuccess(queryClient, onDismissed, onSettled); - } catch { - toast.error(t("insights.pattern.dismissError")); + } catch (failure) { + showErrorToast(t("insights.pattern.dismissError"), { cause: failure }); } finally { setPending(false); } @@ -184,8 +185,8 @@ export function PatternDismissedNotice({ { dismissed: false }, ); await applyPatternDecisionSuccess(queryClient, onRestored, onSettled); - } catch { - toast.error(t("insights.pattern.restoreError")); + } catch (failure) { + showErrorToast(t("insights.pattern.restoreError"), { cause: failure }); } finally { setPending(false); } diff --git a/src/components/labs/biomarker-manager.tsx b/src/components/labs/biomarker-manager.tsx index 18cc9f5a5..8bb1e00ce 100644 --- a/src/components/labs/biomarker-manager.tsx +++ b/src/components/labs/biomarker-manager.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Eye, EyeOff, FlaskConical, Pencil, Plus } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; @@ -61,7 +62,8 @@ export function BiomarkerManager() { queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); }, - onError: () => toast.error(t("labs.biomarker.deleteError")), + onError: (failure) => + showErrorToast(t("labs.biomarker.deleteError"), { cause: failure }), }); const hiddenMutation = useMutation({ @@ -75,7 +77,8 @@ export function BiomarkerManager() { ); queryClient.invalidateQueries({ queryKey: queryKeys.biomarkers() }); }, - onError: () => toast.error(t("labs.biomarker.hideError")), + onError: (failure) => + showErrorToast(t("labs.biomarker.hideError"), { cause: failure }), }); function openNew() { diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index 9518eb0b8..36573bea5 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -19,6 +19,7 @@ import { Plus, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useAuth } from "@/hooks/use-auth"; import { DeleteButton } from "@/components/data-list"; @@ -283,7 +284,8 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { queryClient.invalidateQueries({ queryKey: queryKeys.labResults() }); router.push("/labs"); }, - onError: () => toast.error(t("labs.biomarker.deleteError")), + onError: (failure) => + showErrorToast(t("labs.biomarker.deleteError"), { cause: failure }), }); // v1.25.1 — the per-marker description leads the page, mirroring the metric diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 109d32de6..efaf0d32e 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -6,6 +6,7 @@ import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2, Pencil } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DeleteButton, SortableHead } from "@/components/data-list"; import { Button } from "@/components/ui/button"; @@ -156,8 +157,8 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { await apiPost("/api/labs/restore", { ids: [id] }); invalidate(); toast.success(t("labs.restoredToast")); - } catch { - toast.error(t("labs.restoreError")); + } catch (failure) { + showErrorToast(t("labs.restoreError"), { cause: failure }); } } @@ -172,7 +173,8 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { }, }); }, - onError: () => toast.error(t("labs.deleteError")), + onError: (failure) => + showErrorToast(t("labs.deleteError"), { cause: failure }), }); const updateMutation = useMutation({ diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx index 46db94312..df911daac 100644 --- a/src/components/labs/ocr-review-dialog.tsx +++ b/src/components/labs/ocr-review-dialog.tsx @@ -14,6 +14,7 @@ import { useEffect, useId, useMemo, useRef, useState } from "react"; import { AlertCircle, Loader2, ScanLine, Upload } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { Button } from "@/components/ui/button"; @@ -233,21 +234,22 @@ export function OcrReviewDialog({ onSuccess: (data) => { const seeded = seedReviewRows(data.rows, data.reportDate); if (seeded.length === 0) { - toast.error(t("labs.ocr.noRows")); + showErrorToast(t("labs.ocr.noRows")); return; } setRows(seeded); setPageCoverage(data.pageCoverage ?? null); setStage("review"); }, - onError: (err) => toast.error(extractErrorMessage(err, t)), + onError: (err) => + showErrorToast(extractErrorMessage(err, t), { cause: err }), }); } function onSave() { const plan = planSave(rows); if (plan.kind === "nothing-selected") { - toast.error(t("labs.ocr.nothingToSave")); + showErrorToast(t("labs.ocr.nothingToSave")); return; } if (plan.kind === "blocked") { @@ -296,7 +298,8 @@ export function OcrReviewDialog({ onCommitted(); handleClose(false); }, - onError: () => toast.error(t("labs.ocr.saveFailed")), + onError: (failure) => + showErrorToast(t("labs.ocr.saveFailed"), { cause: failure }), }, ); } diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 942909da7..e8b8b570b 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -65,6 +65,7 @@ import { useTableSort } from "@/hooks/use-table-sort"; import { useUrlFilterSync } from "@/hooks/use-url-filter-sync"; import { createPortal } from "react-dom"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { formatDateOrRelative, formatDateTime } from "@/lib/format"; import { DayLink, DayLinkAt } from "@/components/day/day-link"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -687,8 +688,8 @@ export function MeasurementList({ await invalidateKeys(queryClient, measurementDependentKeys); await refetchInactiveDailyReads(queryClient); toast.success(t("measurements.restoredToast")); - } catch { - toast.error(t("measurements.restoreError")); + } catch (failure) { + showErrorToast(t("measurements.restoreError"), { cause: failure }); } }, [queryClient, t], @@ -711,8 +712,8 @@ export function MeasurementList({ // v1.16.4 — a failed row delete used to fail silently (no onError // handler); the edit-dialog path surfaced its own inline error but // the row-level confirm had no feedback at all. - onError: () => { - toast.error(t("measurements.deleteError")); + onError: (failure) => { + showErrorToast(t("measurements.deleteError"), { cause: failure }); }, }); @@ -744,8 +745,8 @@ export function MeasurementList({ }, ); }, - onError: () => { - toast.error(t("measurements.bulkDeleteError")); + onError: (failure) => { + showErrorToast(t("measurements.bulkDeleteError"), { cause: failure }); }, }); diff --git a/src/components/medications/detail/efficacy/efficacy-tab.tsx b/src/components/medications/detail/efficacy/efficacy-tab.tsx index 9cbc2cc3b..cde93df12 100644 --- a/src/components/medications/detail/efficacy/efficacy-tab.tsx +++ b/src/components/medications/detail/efficacy/efficacy-tab.tsx @@ -14,6 +14,7 @@ import dynamic from "next/dynamic"; import { Activity, TrendingUp } from "lucide-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; import { SegmentedToggle } from "@/components/medications/detail/segmented-toggle"; @@ -434,12 +435,14 @@ function RetargetControl({ await apiPut(`/api/medications/${medicationId}/efficacy/target`, body); onChanged(); if (clear) setValue(""); - } catch { + } catch (failure) { // v1.30 UI audit (M9) — the save previously failed silently: the // buttons re-enabled with no toast, no inline alert, and the // rejection escaped as an unhandled promise. The user could not // tell the override was not saved. - toast.error(t("medications.efficacy.retarget.error")); + showErrorToast(t("medications.efficacy.retarget.error"), { + cause: failure, + }); } finally { setBusy(false); } diff --git a/src/components/medications/dose-history-add-dialog.tsx b/src/components/medications/dose-history-add-dialog.tsx index 1e81fce04..04789ed39 100644 --- a/src/components/medications/dose-history-add-dialog.tsx +++ b/src/components/medications/dose-history-add-dialog.tsx @@ -21,6 +21,7 @@ import { useId, useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -202,8 +203,11 @@ export function LedgerAddDialog({ await refetchInactiveDailyReads(queryClient); setNudgeOpen(false); onOpenChange(false); - } catch { - toast.error(t("medications.intakeToastFailed", { name: medicationName })); + } catch (failure) { + showErrorToast( + t("medications.intakeToastFailed", { name: medicationName }), + { cause: failure }, + ); } finally { setBusy(false); } diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index 084bf9e2f..e52462fd2 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -61,6 +61,7 @@ import { Unlink, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -307,10 +308,11 @@ export function DoseHistoryLedger({ // reads (Today hero + dashboard) are unmounted behind the detail // page, so force their inactive refetch too (v1.32.19). await refetchInactiveDailyReads(queryClient); - } catch { + } catch (failure) { if (prev) queryClient.setQueryData(queryKey, prev); - toast.error( + showErrorToast( t("medications.intakeToastFailed", { name: medicationName }), + { cause: failure }, ); } finally { setMarking(null); @@ -359,8 +361,10 @@ export function DoseHistoryLedger({ // page, so force their inactive refetch too (v1.32.19). await refetchInactiveDailyReads(queryClient); toast.success(t("medications.detail.intake.edit.savedToast")); - } catch { - toast.error(t("medications.detail.intake.edit.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.intake.edit.failed"), { + cause: failure, + }); } }, [medicationId, queryClient, queryKey, t], @@ -393,8 +397,10 @@ export function DoseHistoryLedger({ // reads (Today hero + dashboard) are unmounted behind the detail // page, so force their inactive refetch too (v1.32.19). await refetchInactiveDailyReads(queryClient); - } catch { - toast.error(t("medications.detail.intake.edit.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.intake.edit.failed"), { + cause: failure, + }); } }, [medicationId, queryClient, queryKey, t], @@ -412,8 +418,10 @@ export function DoseHistoryLedger({ // reads (Today hero + dashboard) are unmounted behind the detail // page, so force their inactive refetch too (v1.32.19). await refetchInactiveDailyReads(queryClient); - } catch { - toast.error(t("medications.detail.intake.deleteRow.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.intake.deleteRow.failed"), { + cause: failure, + }); } } diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 91cd2f779..06e430cb8 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -7,6 +7,7 @@ import { import { useEffect, useReducer, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { MedicationCardMenu } from "@/components/medications/medication-card-menu"; import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges"; @@ -321,7 +322,9 @@ export function Glp1MedicationCard({ injectionSite: site, }); } catch (err) { - toast.error(t("medications.logInjectionSiteSaveFailed")); + showErrorToast(t("medications.logInjectionSiteSaveFailed"), { + cause: err, + }); throw err; } await invalidateKeys(queryClient, medicationDependentKeys); diff --git a/src/components/medications/intake-edit-dialog.tsx b/src/components/medications/intake-edit-dialog.tsx index f60d3d623..afe03c1ea 100644 --- a/src/components/medications/intake-edit-dialog.tsx +++ b/src/components/medications/intake-edit-dialog.tsx @@ -24,6 +24,7 @@ import { useId, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -135,8 +136,10 @@ function IntakeEditDialogBody({ await invalidateMedicationReads(queryClient); toast.success(t("medications.detail.intake.edit.savedToast")); onClose(); - } catch { - toast.error(t("medications.detail.intake.edit.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.intake.edit.failed"), { + cause: failure, + }); } finally { setBusy(false); } diff --git a/src/components/medications/intake-history-editable.tsx b/src/components/medications/intake-history-editable.tsx index 04aa954e6..04d3f960a 100644 --- a/src/components/medications/intake-history-editable.tsx +++ b/src/components/medications/intake-history-editable.tsx @@ -22,6 +22,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -102,8 +103,10 @@ export function IntakeHistoryEditable({ toast.success(t("medications.detail.intake.bulkDelete.toast")); setBulkConfirmOpen(false); clearSelection(); - } catch { - toast.error(t("medications.detail.intake.bulkDelete.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.intake.bulkDelete.failed"), { + cause: failure, + }); } finally { setBulkBusy(false); } @@ -126,8 +129,10 @@ export function IntakeHistoryEditable({ next.delete(id); return next; }); - } catch { - toast.error(t("medications.detail.intake.deleteRow.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.intake.deleteRow.failed"), { + cause: failure, + }); } finally { setRowDeleteBusy(false); } diff --git a/src/components/medications/log-intake-dialog.tsx b/src/components/medications/log-intake-dialog.tsx index 71f4d0162..bcc7d306e 100644 --- a/src/components/medications/log-intake-dialog.tsx +++ b/src/components/medications/log-intake-dialog.tsx @@ -24,6 +24,7 @@ import { useId, useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -195,7 +196,7 @@ export function LogIntakeDialog({ if (slot !== NO_SLOT) { const iso = slotInstantIso(takenAt, slot); if (!iso) { - toast.error(t("medications.logIntake.slotError")); + showErrorToast(t("medications.logIntake.slotError")); return; } scheduledFor = iso; diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 3a3f37cfc..07e73bbf8 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -7,6 +7,7 @@ import { import { useState, useEffect, useReducer } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useMedicationIntake } from "@/components/medications/use-medication-intake"; import { MedicationCardMenu } from "@/components/medications/medication-card-menu"; import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges"; @@ -243,7 +244,9 @@ export function MedicationCard({ injectionSite: site, }); } catch (err) { - toast.error(t("medications.logInjectionSiteSaveFailed")); + showErrorToast(t("medications.logInjectionSiteSaveFailed"), { + cause: err, + }); throw err; } await invalidateKeys(queryClient, medicationDependentKeys); diff --git a/src/components/medications/medication-categories-sheet.tsx b/src/components/medications/medication-categories-sheet.tsx index 94d944111..fd6cdefe4 100644 --- a/src/components/medications/medication-categories-sheet.tsx +++ b/src/components/medications/medication-categories-sheet.tsx @@ -9,6 +9,7 @@ import { Eye, EyeOff, Loader2, Pencil, Tag, Trash2 } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -55,8 +56,10 @@ function CategoryRow({ try { await update.mutateAsync({ key: category.key, label: trimmed }); setEditing(false); - } catch { - toast.error(t("medications.category.custom.saveFailed")); + } catch (failure) { + showErrorToast(t("medications.category.custom.saveFailed"), { + cause: failure, + }); } } @@ -66,8 +69,10 @@ function CategoryRow({ key: category.key, isActive: !category.isActive, }); - } catch { - toast.error(t("medications.category.custom.saveFailed")); + } catch (failure) { + showErrorToast(t("medications.category.custom.saveFailed"), { + cause: failure, + }); } } @@ -221,8 +226,10 @@ export function MedicationCategoriesSheet({ count: String(result.movedCount), }), ); - } catch { - toast.error(t("medications.category.custom.saveFailed")); + } catch (failure) { + showErrorToast(t("medications.category.custom.saveFailed"), { + cause: failure, + }); } } diff --git a/src/components/medications/scheduling/schedule-history-timeline.tsx b/src/components/medications/scheduling/schedule-history-timeline.tsx index 73ab6f2e2..82d8ee2be 100644 --- a/src/components/medications/scheduling/schedule-history-timeline.tsx +++ b/src/components/medications/scheduling/schedule-history-timeline.tsx @@ -39,6 +39,7 @@ import { Trash2, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -143,8 +144,10 @@ export function ScheduleHistoryTimeline({ queryKey: queryKeys.medicationDetail(medicationId), }); }, - onError: () => { - toast.error(t("medications.detail.zeitplan.history.deleteError")); + onError: (failure) => { + showErrorToast(t("medications.detail.zeitplan.history.deleteError"), { + cause: failure, + }); }, }); @@ -468,11 +471,12 @@ function EraDialog({ reset(); onSaved(); }, - onError: () => { - toast.error( + onError: (failure) => { + showErrorToast( target ? t("medications.detail.zeitplan.history.updateError") : t("medications.detail.zeitplan.history.createError"), + { cause: failure }, ); }, }); diff --git a/src/components/medications/scheduling/schedule-times-editor.tsx b/src/components/medications/scheduling/schedule-times-editor.tsx index ce7eef19f..fac183869 100644 --- a/src/components/medications/scheduling/schedule-times-editor.tsx +++ b/src/components/medications/scheduling/schedule-times-editor.tsx @@ -20,6 +20,7 @@ import { useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -170,8 +171,10 @@ export function ScheduleTimesEditor({ await apiPut(`/api/medications/${medicationId}`, body); await invalidateKeys(queryClient, medicationDependentKeys); toast.success(t("medications.detail.zeitplan.saved")); - } catch { - toast.error(t("medications.detail.zeitplan.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.zeitplan.failed"), { + cause: failure, + }); } finally { setBusy(false); } diff --git a/src/components/medications/sections/api-tokens-row.tsx b/src/components/medications/sections/api-tokens-row.tsx index 617a17ea9..6ce63e5aa 100644 --- a/src/components/medications/sections/api-tokens-row.tsx +++ b/src/components/medications/sections/api-tokens-row.tsx @@ -19,6 +19,7 @@ import { useEffect, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { ChevronDown, ChevronUp, Copy, KeyRound } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -142,8 +143,10 @@ export function ApiTokensRow({ try { await navigator.clipboard.writeText(text); toast.success(label); - } catch { - toast.error(t("medications.detail.api.copyFailed")); + } catch (failure) { + showErrorToast(t("medications.detail.api.copyFailed"), { + cause: failure, + }); } } @@ -178,10 +181,11 @@ export function ApiTokensRow({ : (status?.activeTokenCount ?? 0) + 1, }); } catch (err) { - toast.error( + showErrorToast( err instanceof ApiError && err.message ? err.message : t("medications.detail.api.mintFailed"), + { cause: err }, ); } finally { setBusy(false); diff --git a/src/components/medications/sections/auto-mark-taken-section.tsx b/src/components/medications/sections/auto-mark-taken-section.tsx index dff300198..45f44fa1b 100644 --- a/src/components/medications/sections/auto-mark-taken-section.tsx +++ b/src/components/medications/sections/auto-mark-taken-section.tsx @@ -13,6 +13,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Switch } from "@/components/ui/switch"; import { useTranslations } from "@/lib/i18n/context"; @@ -55,9 +56,11 @@ export function AutoMarkTakenBody({ ? t("medications.autoMarkTaken.enabledToast") : t("medications.autoMarkTaken.disabledToast"), ); - } catch { + } catch (failure) { setLocalOn(previous); - toast.error(t("medications.autoMarkTaken.toggleFailed")); + showErrorToast(t("medications.autoMarkTaken.toggleFailed"), { + cause: failure, + }); } finally { setSubmitting(false); } diff --git a/src/components/medications/sections/courses-section.tsx b/src/components/medications/sections/courses-section.tsx index a60d5959e..98886eff9 100644 --- a/src/components/medications/sections/courses-section.tsx +++ b/src/components/medications/sections/courses-section.tsx @@ -15,6 +15,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -222,8 +223,8 @@ export function CoursesRow({ await apiDelete(`/api/medications/${medicationId}/courses/${course.id}`); await invalidateMedicationReads(queryClient); setDeleting(null); - } catch { - toast.error(t("medications.course.error.generic")); + } catch (failure) { + showErrorToast(t("medications.course.error.generic"), { cause: failure }); } finally { setDeleteBusy(false); } diff --git a/src/components/medications/sections/destructive-zone-section.tsx b/src/components/medications/sections/destructive-zone-section.tsx index 4240c3346..bd3635cc8 100644 --- a/src/components/medications/sections/destructive-zone-section.tsx +++ b/src/components/medications/sections/destructive-zone-section.tsx @@ -30,6 +30,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Archive, Loader2, Trash2 } from "lucide-react"; import { @@ -92,9 +93,11 @@ export function LifecycleManageBody({ : t("medications.detail.zone.pause.resumedToast"), ); onAfterAction?.(); - } catch { + } catch (failure) { setPaused(previous); - toast.error(t("medications.detail.zone.pause.failed")); + showErrorToast(t("medications.detail.zone.pause.failed"), { + cause: failure, + }); } finally { setTier1Busy(false); } @@ -110,8 +113,10 @@ export function LifecycleManageBody({ toast.success(t("medications.detail.zone.end.toast")); setEndDialogOpen(false); onAfterAction?.(); - } catch { - toast.error(t("medications.detail.zone.end.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.zone.end.failed"), { + cause: failure, + }); } finally { setTier2Busy(false); } @@ -246,8 +251,10 @@ export function DangerZoneBody({ toast.success(t("medications.detail.zone.purge.toast")); setPurgeDialogOpen(false); onAfterAction?.(); - } catch { - toast.error(t("medications.detail.zone.purge.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.zone.purge.failed"), { + cause: failure, + }); } finally { setTier3aBusy(false); } @@ -278,8 +285,10 @@ export function DangerZoneBody({ // analytics / compliance / inline chart). Not awaited: navigation // already happened and a transient refetch must not block it. void invalidateMedicationReads(queryClient); - } catch { - toast.error(t("medications.detail.zone.delete.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.zone.delete.failed"), { + cause: failure, + }); setTier3bBusy(false); } } diff --git a/src/components/medications/sections/intake-tracking-section.tsx b/src/components/medications/sections/intake-tracking-section.tsx index 21b86e397..624dba62c 100644 --- a/src/components/medications/sections/intake-tracking-section.tsx +++ b/src/components/medications/sections/intake-tracking-section.tsx @@ -13,6 +13,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Switch } from "@/components/ui/switch"; import { useTranslations } from "@/lib/i18n/context"; @@ -55,9 +56,11 @@ export function IntakeTrackingBody({ ? t("medications.trackIntake.enabledToast") : t("medications.trackIntake.disabledToast"), ); - } catch { + } catch (failure) { setLocalTracked(previous); - toast.error(t("medications.trackIntake.toggleFailed")); + showErrorToast(t("medications.trackIntake.toggleFailed"), { + cause: failure, + }); } finally { setSubmitting(false); } diff --git a/src/components/medications/sections/inventory-dialogs.tsx b/src/components/medications/sections/inventory-dialogs.tsx index 73939f220..fbbc61941 100644 --- a/src/components/medications/sections/inventory-dialogs.tsx +++ b/src/components/medications/sections/inventory-dialogs.tsx @@ -18,6 +18,7 @@ import { useId, useState, type FormEvent } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -145,8 +146,10 @@ export function AddInventoryDialog({ await invalidateSupplyQueries(queryClient, medicationId); toast.success(t("medications.detail.bestand.addSuccess")); onClose(); - } catch { - toast.error(t("medications.detail.bestand.addFailed")); + } catch (failure) { + showErrorToast(t("medications.detail.bestand.addFailed"), { + cause: failure, + }); } finally { setBusy(false); } @@ -399,8 +402,10 @@ export function AdjustInventoryDialog({ await invalidateSupplyQueries(queryClient, medicationId); toast.success(t("medications.detail.bestand.adjustSuccess")); onClose(); - } catch { - toast.error(t("medications.detail.bestand.adjustFailed")); + } catch (failure) { + showErrorToast(t("medications.detail.bestand.adjustFailed"), { + cause: failure, + }); } finally { setBusy(false); } @@ -573,8 +578,10 @@ export function PackagingDialog({ ]); toast.success(t("medications.detail.bestand.packagingSuccess")); onClose(); - } catch { - toast.error(t("medications.detail.bestand.packagingFailed")); + } catch (failure) { + showErrorToast(t("medications.detail.bestand.packagingFailed"), { + cause: failure, + }); } finally { setBusy(false); } diff --git a/src/components/medications/sections/inventory-section.tsx b/src/components/medications/sections/inventory-section.tsx index f946a6481..7af96d638 100644 --- a/src/components/medications/sections/inventory-section.tsx +++ b/src/components/medications/sections/inventory-section.tsx @@ -48,6 +48,7 @@ import { useState } from "react"; import Link from "next/link"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2, PackageOpen, Plus } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -129,8 +130,10 @@ export function InventorySection({ await apiDelete(`/api/medications/${medicationId}/inventory/${item.id}`); await invalidateSupplyQueries(queryClient, medicationId); toast.success(t("medications.detail.bestand.deleteSuccess")); - } catch { - toast.error(t("medications.detail.bestand.deleteFailed")); + } catch (failure) { + showErrorToast(t("medications.detail.bestand.deleteFailed"), { + cause: failure, + }); } } diff --git a/src/components/medications/sections/notifications-section.tsx b/src/components/medications/sections/notifications-section.tsx index fa78453b3..a8c0a3aa1 100644 --- a/src/components/medications/sections/notifications-section.tsx +++ b/src/components/medications/sections/notifications-section.tsx @@ -23,6 +23,7 @@ import { useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Badge } from "@/components/ui/badge"; import { Switch } from "@/components/ui/switch"; @@ -137,9 +138,11 @@ export function NotificationsBody({ ? t("medications.detail.notifications.enabledToast") : t("medications.detail.notifications.disabledToast"), ); - } catch { + } catch (failure) { setLocalEnabled(previous); - toast.error(t("medications.detail.notifications.toggleFailed")); + showErrorToast(t("medications.detail.notifications.toggleFailed"), { + cause: failure, + }); } finally { setSubmitting(false); } diff --git a/src/components/medications/sections/phase-config-sheet.tsx b/src/components/medications/sections/phase-config-sheet.tsx index b40933e85..bb2bed055 100644 --- a/src/components/medications/sections/phase-config-sheet.tsx +++ b/src/components/medications/sections/phase-config-sheet.tsx @@ -28,6 +28,7 @@ import { useMemo, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -227,10 +228,11 @@ function PhaseConfigForm({ toast.success(t("medications.phaseSaved")); onClose(); } catch (err) { - toast.error( + showErrorToast( err instanceof ApiError && err.message ? err.message : t("medications.detail.phases.saveFailed"), + { cause: err }, ); } finally { setSaving(false); diff --git a/src/components/medications/sections/settings-section.tsx b/src/components/medications/sections/settings-section.tsx index 64856df27..ebb127d47 100644 --- a/src/components/medications/sections/settings-section.tsx +++ b/src/components/medications/sections/settings-section.tsx @@ -21,6 +21,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -124,8 +125,10 @@ export function GraceRow({ await apiPut(`/api/medications/${medicationId}`, body); await invalidateKeys(queryClient, medicationDependentKeys); toast.success(t("medications.detail.settings.grace.saved")); - } catch { - toast.error(t("medications.detail.settings.grace.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.settings.grace.failed"), { + cause: failure, + }); } finally { setGraceBusy(false); } @@ -207,8 +210,10 @@ export function DrugCodingRow({ }); await invalidateKeys(queryClient, medicationDependentKeys); toast.success(t("medications.detail.settings.codes.saved")); - } catch { - toast.error(t("medications.detail.settings.codes.failed")); + } catch (failure) { + showErrorToast(t("medications.detail.settings.codes.failed"), { + cause: failure, + }); } finally { setBusy(false); } diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index 140fb8edb..a2554c2fe 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -9,6 +9,7 @@ import { useQueryClient, } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2, Plus, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -215,8 +216,10 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { }, // v1.16.4 — a failed delete used to fail silently, leaving the row // in place with no signal that the request was rejected. - onError: () => { - toast.error(t("medications.sideEffects.deleteError")); + onError: (failure) => { + showErrorToast(t("medications.sideEffects.deleteError"), { + cause: failure, + }); }, }); diff --git a/src/components/medications/take-all-due.ts b/src/components/medications/take-all-due.ts index 6ce7997c8..04bea56f4 100644 --- a/src/components/medications/take-all-due.ts +++ b/src/components/medications/take-all-due.ts @@ -47,6 +47,7 @@ import type { QueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { apiPost } from "@/lib/api/api-fetch"; import { intakeToastOptions } from "@/components/medications/use-medication-intake"; @@ -286,9 +287,9 @@ export async function runTakeAllDue(deps: { toast.success(message); } } else if (taken > 0) { - toast.error(t("medications.takeAllDue.partialToast", { taken, failed })); + showErrorToast(t("medications.takeAllDue.partialToast", { taken, failed })); } else { - toast.error(t("medications.takeAllDue.failedToast")); + showErrorToast(t("medications.takeAllDue.failedToast")); } return { taken, failed }; diff --git a/src/components/medications/use-medication-intake.ts b/src/components/medications/use-medication-intake.ts index bf1abbdaa..6abb424b9 100644 --- a/src/components/medications/use-medication-intake.ts +++ b/src/components/medications/use-medication-intake.ts @@ -4,6 +4,7 @@ import { useActiveRecordName } from "@/hooks/use-record-capabilities"; import { useState } from "react"; import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateMedicationReads } from "@/lib/query-keys"; @@ -203,8 +204,11 @@ export async function runRecordIntake(deps: { ); await invalidateMedicationReads(queryClient); onRecorded?.(eventId, skipped); - } catch { - toast.error(t("medications.intakeToastFailed", { name: medication.name })); + } catch (failure) { + showErrorToast( + t("medications.intakeToastFailed", { name: medication.name }), + { cause: failure }, + ); } finally { setIntakeLoading(null); } @@ -300,8 +304,11 @@ export async function runLogIntake(deps: { toast.success(message); } return true; - } catch { - toast.error(t("medications.intakeToastFailed", { name: medication.name })); + } catch (failure) { + showErrorToast( + t("medications.intakeToastFailed", { name: medication.name }), + { cause: failure }, + ); return false; } } @@ -321,8 +328,8 @@ export async function runUndoIntake(deps: { await apiDelete(`/api/medications/${medication.id}/intake/${eventId}`); await invalidateMedicationReads(queryClient); toast.success(t("medications.intakeUndone")); - } catch { - toast.error(t("medications.intakeUndoFailed")); + } catch (failure) { + showErrorToast(t("medications.intakeUndoFailed"), { cause: failure }); } } diff --git a/src/components/mood/__tests__/mood-qol-fixes.test.tsx b/src/components/mood/__tests__/mood-qol-fixes.test.tsx index 26e0d8cf2..1a79b18ec 100644 --- a/src/components/mood/__tests__/mood-qol-fixes.test.tsx +++ b/src/components/mood/__tests__/mood-qol-fixes.test.tsx @@ -193,9 +193,11 @@ describe("MoodList — level A on the edit path (v1.37)", () => { describe("MoodList — delete failure toast (v1.11.5)", () => { it("surfaces a toast on a rejected delete instead of failing silently", () => { - expect(listSrc).toContain('import { toast } from "sonner"'); + expect(listSrc).toContain( + 'import { showErrorToast } from "@/lib/errors/error-toast"', + ); expect(listSrc).toMatch( - /deleteMutation = useMutation\(\{[\s\S]*onError: \(\) => \{[\s\S]*toast\.error\(t\("mood\.deleteError"\)\)/, + /deleteMutation = useMutation\(\{[\s\S]*onError: \((\w+)\) => \{[\s\S]*showErrorToast\(t\("mood\.deleteError"\), \{ cause: \1 \}\)/, ); }); }); diff --git a/src/components/mood/manage/archived-tags-card.tsx b/src/components/mood/manage/archived-tags-card.tsx index 1b095e126..21bef8aa3 100644 --- a/src/components/mood/manage/archived-tags-card.tsx +++ b/src/components/mood/manage/archived-tags-card.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { ArchiveRestore, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -70,9 +71,12 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) { } catch (err) { rollback(); if (err instanceof ApiError && err.status === 422) { - toast.error(t("mood.manage.tagLimitReached")); + showErrorToast(t("mood.manage.tagLimitReached"), { cause: err }); } else { - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); } } finally { void invalidateMoodTagCaches(queryClient); @@ -89,7 +93,10 @@ export function ArchivedTagsCard({ catalog }: ArchivedTagsCardProps) { toast.success(t("mood.manage.purged")); } catch (err) { rollback(); - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); } finally { void invalidateMoodTagCaches(queryClient); } diff --git a/src/components/mood/manage/tag-editor-sheet.tsx b/src/components/mood/manage/tag-editor-sheet.tsx index bb678c8ac..c2014b3db 100644 --- a/src/components/mood/manage/tag-editor-sheet.tsx +++ b/src/components/mood/manage/tag-editor-sheet.tsx @@ -4,6 +4,7 @@ import { useId, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -141,9 +142,12 @@ export function TagEditorSheet({ void invalidateMoodTagCaches(queryClient); } catch (err) { if (err instanceof ApiError && err.status === 422) { - toast.error(t("mood.manage.tagLimitReached")); + showErrorToast(t("mood.manage.tagLimitReached"), { cause: err }); } else { - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); } } finally { setSaving(false); diff --git a/src/components/mood/manage/tag-groups-card.tsx b/src/components/mood/manage/tag-groups-card.tsx index 329a2483b..6c87547c0 100644 --- a/src/components/mood/manage/tag-groups-card.tsx +++ b/src/components/mood/manage/tag-groups-card.tsx @@ -30,6 +30,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -173,7 +174,10 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) { toast.message(t("common.conflictReloaded")); return false; } - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); return false; } finally { void invalidateMoodTagCaches(queryClient); @@ -354,8 +358,9 @@ export function TagGroupsCard({ catalog }: TagGroupsCardProps) { ); toast.success(t("mood.manage.groupDeleted")); } catch (err) { - toast.error( + showErrorToast( err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, ); } finally { void invalidateMoodTagCaches(queryClient); @@ -554,9 +559,12 @@ function GroupEditorSheet({ void invalidateMoodTagCaches(queryClient); } catch (err) { if (err instanceof ApiError && err.status === 422) { - toast.error(t("mood.manage.groupLimitReached")); + showErrorToast(t("mood.manage.groupLimitReached"), { cause: err }); } else { - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); } } finally { setSaving(false); diff --git a/src/components/mood/manage/tag-manager-card.tsx b/src/components/mood/manage/tag-manager-card.tsx index e83e14e2e..9f39d4bae 100644 --- a/src/components/mood/manage/tag-manager-card.tsx +++ b/src/components/mood/manage/tag-manager-card.tsx @@ -33,6 +33,7 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -166,7 +167,10 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) { toast.message(t("common.conflictReloaded")); return false; } - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); return false; } finally { void invalidateMoodTagCaches(queryClient); @@ -238,7 +242,10 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) { } } catch (err) { rollback(); - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); } finally { void invalidateMoodTagCaches(queryClient); } @@ -275,7 +282,10 @@ export function TagManagerCard({ catalog }: TagManagerCardProps) { toast.message(t("common.conflictReloaded")); return; } - toast.error(err instanceof ApiError ? err.message : t("common.error")); + showErrorToast( + err instanceof ApiError ? err.message : t("common.error"), + { cause: err }, + ); } finally { void invalidateMoodTagCaches(queryClient); } diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index 03602aad7..f9035cd54 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -69,6 +69,7 @@ import { // reads newest-first by default; every other column opens ascending. const MOOD_DESC_COLUMNS: ReadonlySet = new Set(["moodLoggedAt"]); import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { formatDateTime } from "@/lib/format"; import { DayLink } from "@/components/day/day-link"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -384,8 +385,8 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { await invalidateKeys(queryClient, moodDependentKeys); await refetchInactiveDailyReads(queryClient); toast.success(t("mood.restoredToast")); - } catch { - toast.error(t("mood.restoreError")); + } catch (failure) { + showErrorToast(t("mood.restoreError"), { cause: failure }); } }, [queryClient, t], @@ -409,8 +410,8 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { // leaving the row in place with no signal that the request was rejected. // Surface the failure so the user knows to retry. Mirrors the v1.11.3 // medication-card silent-failure fix. - onError: () => { - toast.error(t("mood.deleteError")); + onError: (failure) => { + showErrorToast(t("mood.deleteError"), { cause: failure }); }, }); @@ -439,8 +440,8 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) { }, ); }, - onError: () => { - toast.error(t("mood.bulkDeleteError")); + onError: (failure) => { + showErrorToast(t("mood.bulkDeleteError"), { cause: failure }); }, }); diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index d85f732c3..ba06c061b 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { BaselineFields, @@ -234,7 +235,9 @@ export function BaselineForm({ await onConfirmed(); }, (err) => - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")), + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }), ); } diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx index 2f8e9160f..fc1012f26 100644 --- a/src/components/onboarding/confirm-screen.tsx +++ b/src/components/onboarding/confirm-screen.tsx @@ -4,6 +4,7 @@ import { useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { BaselineForm } from "@/components/onboarding/baseline-form"; import { StepHeading } from "@/components/onboarding/step-heading"; @@ -189,7 +190,9 @@ export function ConfirmScreen({ /** The managed arm's two buttons: nothing above them reports a failure. */ function finishReporting(managedRecordId?: string) { void finish(managedRecordId).catch((err) => { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); }); } diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx index 02462c2ba..58aab06f1 100644 --- a/src/components/onboarding/done-screen.tsx +++ b/src/components/onboarding/done-screen.tsx @@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { CheckCircle2, Compass, @@ -171,10 +172,10 @@ export function DoneScreen({ state }: { state: OnboardingStateDto }) { queryKey: queryKeys.aiConsentReceipt("ai_full"), }); }, - onError: () => { + onError: (failure) => { // The button stays. A failed grant is a failed grant; the screen must // not read as though consent were on file. - toast.error(t("onboarding.ai.offer.error")); + showErrorToast(t("onboarding.ai.offer.error"), { cause: failure }); }, }); diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx index 948ba61c0..86b3f2300 100644 --- a/src/components/onboarding/first-result-screen.tsx +++ b/src/components/onboarding/first-result-screen.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2 } from "lucide-react"; import { MeasurementForm } from "@/components/measurements/measurement-form"; @@ -109,7 +110,9 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) { }); setCompleted(true); } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); } } @@ -118,7 +121,9 @@ export function FirstResultScreen({ state }: { state: OnboardingStateDto }) { await answer.mutateAsync({ step: "first-result", status: "skipped" }); router.push(screenHref("done")); } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); } } diff --git a/src/components/onboarding/question-screen.tsx b/src/components/onboarding/question-screen.tsx index a151437ee..37577e5ae 100644 --- a/src/components/onboarding/question-screen.tsx +++ b/src/components/onboarding/question-screen.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Activity, Brain, @@ -110,7 +111,9 @@ export function QuestionScreen({ const written = await answer.mutateAsync(input); router.push(screenHref(nextScreen(written, step) ?? "confirm")); } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); } } diff --git a/src/components/onboarding/units-screen.tsx b/src/components/onboarding/units-screen.tsx index e4be7672d..1eb517c62 100644 --- a/src/components/onboarding/units-screen.tsx +++ b/src/components/onboarding/units-screen.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { ChoiceChip } from "@/components/onboarding/choice-chip"; import { StepActions } from "@/components/onboarding/step-actions"; @@ -68,7 +69,9 @@ export function UnitsScreen({ state }: { state: OnboardingStateDto }) { const written = await answer.mutateAsync(input); router.push(screenHref(nextScreen(written, "units") ?? "confirm")); } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); } } diff --git a/src/components/onboarding/welcome-screen.tsx b/src/components/onboarding/welcome-screen.tsx index 736f4b69d..1e62c48ee 100644 --- a/src/components/onboarding/welcome-screen.tsx +++ b/src/components/onboarding/welcome-screen.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -70,7 +71,9 @@ export function WelcomeScreen({ variant }: { variant: "fresh" | "again" }) { acknowledge.isPending || complete.isPending || restart.isPending; const fail = (err: unknown) => - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); async function setUp() { if (pending || !acknowledged) return; diff --git a/src/components/outcome/outcome-toast.ts b/src/components/outcome/outcome-toast.ts index 84ca8d157..20a9f71c1 100644 --- a/src/components/outcome/outcome-toast.ts +++ b/src/components/outcome/outcome-toast.ts @@ -1,6 +1,7 @@ "use client"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import type { WrittenOutcome } from "@/lib/outcome/written-outcome"; @@ -25,7 +26,7 @@ export function toastWrittenOutcome( toast.warning(message); return; case "failed": - toast.error(message); + showErrorToast(message); return; case "empty": toast.info(message); diff --git a/src/components/practitioners/practitioner-list.tsx b/src/components/practitioners/practitioner-list.tsx index 7aa6524b0..b3232114f 100644 --- a/src/components/practitioners/practitioner-list.tsx +++ b/src/components/practitioners/practitioner-list.tsx @@ -14,6 +14,7 @@ import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -256,7 +257,10 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) { const id = deleting.id; setDeleting(null); remove.mutate(id, { - onError: () => toast.error(t("practitioners.deleteFailed")), + onError: (failure) => + showErrorToast(t("practitioners.deleteFailed"), { + cause: failure, + }), }); }} > diff --git a/src/components/practitioners/practitioner-sheet.tsx b/src/components/practitioners/practitioner-sheet.tsx index 0c2639a43..53e5394d5 100644 --- a/src/components/practitioners/practitioner-sheet.tsx +++ b/src/components/practitioners/practitioner-sheet.tsx @@ -14,6 +14,7 @@ */ import { useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { FieldGroup } from "@/components/ui/field-group"; @@ -107,7 +108,7 @@ export function PractitionerSheet({ const message = err instanceof Error ? err.message : t("practitioners.saveFailed"); setError(message); - toast.error(message); + showErrorToast(message, { cause: err }); } }; diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 07d77b6dc..5b10ecbde 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -18,6 +18,7 @@ import { import { I18nProvider, useTranslations } from "@/lib/i18n/context"; import type { Locale } from "@/lib/i18n/config"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Toaster } from "@/components/ui/sonner"; import { applyThemeColor } from "@/lib/pwa/theme-color"; import { KeyboardInsetBridge } from "@/components/keyboard-inset-bridge"; @@ -370,7 +371,7 @@ function OfflineMutationToaster() { typeof navigator !== "undefined" && navigator.onLine === false ) { - toast.error(t("offlineBanner.saveFailed"), { + showErrorToast(t("offlineBanner.saveFailed"), { id: "offline-mutation-failed", }); } diff --git a/src/components/records/about-me-note-manager.tsx b/src/components/records/about-me-note-manager.tsx index 2fa1059bf..a5f334d93 100644 --- a/src/components/records/about-me-note-manager.tsx +++ b/src/components/records/about-me-note-manager.tsx @@ -30,6 +30,7 @@ import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, MessageCircleQuestion, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -116,7 +117,7 @@ export function AboutMeNoteManager() { toast.message(t("common.conflictReloaded")); return; } - toast.error(t("settings.ai.aboutMe.saveError")); + showErrorToast(t("settings.ai.aboutMe.saveError"), { cause: err }); }, }); diff --git a/src/components/records/ai-profile-inclusion-manager.tsx b/src/components/records/ai-profile-inclusion-manager.tsx index 8f0c3e88b..9ad51e11d 100644 --- a/src/components/records/ai-profile-inclusion-manager.tsx +++ b/src/components/records/ai-profile-inclusion-manager.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -88,7 +89,7 @@ export function AiProfileInclusionManager() { if (isConflict(error)) { queryClient.invalidateQueries({ queryKey: queryKeys.coachAboutMe() }); } - toast.error(t("records.aiInclusion.saveError")); + showErrorToast(t("records.aiInclusion.saveError"), { cause: error }); }, }); diff --git a/src/components/records/allergy-free-text-note.tsx b/src/components/records/allergy-free-text-note.tsx index f9efc719b..741d00396 100644 --- a/src/components/records/allergy-free-text-note.tsx +++ b/src/components/records/allergy-free-text-note.tsx @@ -25,6 +25,7 @@ import { useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -98,7 +99,7 @@ export function AllergyFreeTextNote({ toast.message(t("common.conflictReloaded")); return; } - toast.error(t("records.allergies.freeText.saveError")); + showErrorToast(t("records.allergies.freeText.saveError"), { cause: err }); }, }); diff --git a/src/components/records/allergy-manager.tsx b/src/components/records/allergy-manager.tsx index e89ce9a56..015931429 100644 --- a/src/components/records/allergy-manager.tsx +++ b/src/components/records/allergy-manager.tsx @@ -4,6 +4,7 @@ import { useState, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Pencil, Plus, ShieldAlert } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DeleteButton } from "@/components/data-list"; import { Badge } from "@/components/ui/badge"; @@ -53,7 +54,8 @@ export function AllergyManager({ toast.success(t("records.allergies.deletedToast")); queryClient.invalidateQueries({ queryKey: queryKeys.allergies() }); }, - onError: () => toast.error(t("records.allergies.deleteError")), + onError: (failure) => + showErrorToast(t("records.allergies.deleteError"), { cause: failure }), }); function openNew() { diff --git a/src/components/records/conditions-manager.tsx b/src/components/records/conditions-manager.tsx index 681ca000b..9045d4c90 100644 --- a/src/components/records/conditions-manager.tsx +++ b/src/components/records/conditions-manager.tsx @@ -24,6 +24,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -89,8 +90,8 @@ export function ConditionsManager() { queryKey: queryKeys.coachAboutMeQuestions(), }); }, - onError: () => { - toast.error(t("records.conditions.saveError")); + onError: (failure) => { + showErrorToast(t("records.conditions.saveError"), { cause: failure }); }, }); diff --git a/src/components/records/emergency-profile-manager.tsx b/src/components/records/emergency-profile-manager.tsx index 7bf147763..c8223954d 100644 --- a/src/components/records/emergency-profile-manager.tsx +++ b/src/components/records/emergency-profile-manager.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -84,7 +85,8 @@ export function EmergencyProfileManager() { queryClient.setQueryData(queryKeys.emergencyProfile(), data); toastWrittenOutcome("success", t("records.emergency.savedToast")); }, - onError: () => toast.error(t("records.emergency.saveError")), + onError: (failure) => + showErrorToast(t("records.emergency.saveError"), { cause: failure }), }); if (query.isError) { diff --git a/src/components/records/family-history-manager.tsx b/src/components/records/family-history-manager.tsx index 2f84fff10..e9266e439 100644 --- a/src/components/records/family-history-manager.tsx +++ b/src/components/records/family-history-manager.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Pencil, Plus, Users } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; @@ -43,7 +44,8 @@ export function FamilyHistoryManager() { toast.success(t("records.family.deletedToast")); queryClient.invalidateQueries({ queryKey: queryKeys.familyHistory() }); }, - onError: () => toast.error(t("records.family.deleteError")), + onError: (failure) => + showErrorToast(t("records.family.deleteError"), { cause: failure }), }); function openNew() { diff --git a/src/components/records/health-profile-facts-manager.tsx b/src/components/records/health-profile-facts-manager.tsx index 61676506f..4e05393a3 100644 --- a/src/components/records/health-profile-facts-manager.tsx +++ b/src/components/records/health-profile-facts-manager.tsx @@ -4,6 +4,7 @@ import { useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -177,7 +178,7 @@ export function HealthProfileFactsManager() { if (failed.length === 0) { toastWrittenOutcome("success", t("records.profileFacts.savedToast")); } else { - toast.error( + showErrorToast( t("records.profileFacts.savePartialError", { kinds: failed.map((r) => t(KIND_LABEL[r.kind])).join(", "), }), @@ -228,10 +229,11 @@ export function HealthProfileFactsManager() { queryKey: queryKeys.healthProfileFacts(), }); } - toast.error( + showErrorToast( t("records.profileFacts.removeError", { kind: t(KIND_LABEL[current.kind]), }), + { cause: error }, ); }, }); diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx index 5904130ed..2e913d33e 100644 --- a/src/components/settings/about-section.tsx +++ b/src/components/settings/about-section.tsx @@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SupportInfoCard } from "@/components/settings/support-info-card"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; @@ -260,6 +261,9 @@ export function AboutSection() { )} + {/* v1.43 — the text a bug report needs, put together in the browser. */} + + {/* Sources & docs — every link card was untitled in the v1.4.2 About surface, so the buttons floated alone with no heading telling the user what the section was for. */} diff --git a/src/components/settings/access/managed-profile-handover.tsx b/src/components/settings/access/managed-profile-handover.tsx index 934ed319e..d6fc0efc3 100644 --- a/src/components/settings/access/managed-profile-handover.tsx +++ b/src/components/settings/access/managed-profile-handover.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Check, Copy, Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { stepUpErrorKey } from "@/components/settings/access/grant-action-error"; @@ -406,8 +407,10 @@ function HandoverLink({ t("recordSharing.managed.handover.copied"), ); window.setTimeout(() => setCopied(false), 2_000); - } catch { - toast.error(t("recordSharing.managed.handover.copyError")); + } catch (failure) { + showErrorToast(t("recordSharing.managed.handover.copyError"), { + cause: failure, + }); } } diff --git a/src/components/settings/account-section/setup-card.tsx b/src/components/settings/account-section/setup-card.tsx index 47d19d28d..50e4f6f41 100644 --- a/src/components/settings/account-section/setup-card.tsx +++ b/src/components/settings/account-section/setup-card.tsx @@ -2,6 +2,7 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { ListChecks, Loader2 } from "lucide-react"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; @@ -37,7 +38,9 @@ export function SetupCard() { await restart.mutateAsync(); router.push("/onboarding/who"); } catch (err) { - toast.error(localizedApiError(err, t, "onboarding.errorGeneric")); + showErrorToast(localizedApiError(err, t, "onboarding.errorGeneric"), { + cause: err, + }); } } diff --git a/src/components/settings/ai/runtime-actions-row.tsx b/src/components/settings/ai/runtime-actions-row.tsx index 236b9fde2..c12d86e85 100644 --- a/src/components/settings/ai/runtime-actions-row.tsx +++ b/src/components/settings/ai/runtime-actions-row.tsx @@ -7,6 +7,7 @@ import { useState, type ReactNode } from "react"; import { Activity, Loader2, RefreshCw, Sparkles } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; @@ -201,8 +202,8 @@ export function RuntimeActionsRow({ try { await apiPut("/api/insights/settings", { privacyMode: next }); onPrivacyChanged(); - } catch { - toast.error(t("settings.savingError")); + } catch (failure) { + showErrorToast(t("settings.savingError"), { cause: failure }); } } diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index 9639edb24..2357a16a5 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -15,6 +15,7 @@ import { type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -229,8 +230,8 @@ function IngestTokenCard({ setTokenCopied(true); toast.success(t("settings.tokenCopied")); setTimeout(() => setTokenCopied(false), 2_000); - } catch { - toast.error(t("settings.tokenCopyFailed")); + } catch (failure) { + showErrorToast(t("settings.tokenCopyFailed"), { cause: failure }); } } @@ -455,12 +456,12 @@ function ApiTokensCard() { method: "DELETE", }); if (!res.ok) { - toast.error(t("settings.tokenRevokeFailed")); + showErrorToast(t("settings.tokenRevokeFailed"), { cause: res }); return; } queryClient.invalidateQueries({ queryKey: queryKeys.tokens() }); - } catch { - toast.error(t("settings.tokenRevokeFailed")); + } catch (failure) { + showErrorToast(t("settings.tokenRevokeFailed"), { cause: failure }); } } diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index 3264a1289..20403a5ca 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -56,6 +56,7 @@ import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { apiDelete } from "@/lib/api/api-fetch"; import { Textarea } from "@/components/ui/textarea"; import { @@ -123,7 +124,7 @@ function FactRow({ const edit = useEditCoachFact({ // The edited words in place are the confirmation. onSuccess: () => setEditing(false), - onError: () => toast.error(t("admin.settingsSaveError")), + onError: () => showErrorToast(t("admin.settingsSaveError")), }); const sourceKey = factSourceKey(fact); const trimmed = draft.trim(); @@ -262,7 +263,7 @@ export function CoachMemorySection({ const forgetOne = useForgetCoachFact({ onSuccess: () => toast.success(t("settings.ai.coachMemory.forgotToast")), - onError: () => toast.error(t("settings.ai.coachMemory.forgotError")), + onError: () => showErrorToast(t("settings.ai.coachMemory.forgotError")), }); const forgetAll = useMutation({ @@ -279,8 +280,10 @@ export function CoachMemorySection({ ); queryClient.invalidateQueries({ queryKey: queryKeys.coachFacts() }); }, - onError: () => { - toast.error(t("settings.ai.coachMemory.clearError")); + onError: (failure) => { + showErrorToast(t("settings.ai.coachMemory.clearError"), { + cause: failure, + }); }, }); diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index 1c3afecfa..f13ba961e 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useId, useMemo, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { LayoutDashboard, RotateCcw, @@ -335,7 +336,7 @@ export function DashboardLayoutSection({ id }: { id: string }) { toast.message(t("dashboard.layoutConflictReloaded")); return; } - toast.error(t("dashboard.layoutSaveError")); + showErrorToast(t("dashboard.layoutSaveError"), { cause: err }); }, }); diff --git a/src/components/settings/environment-section.tsx b/src/components/settings/environment-section.tsx index 2c3ef08c1..0bc0850e3 100644 --- a/src/components/settings/environment-section.tsx +++ b/src/components/settings/environment-section.tsx @@ -18,6 +18,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { CloudSun, MapPin, Plane, Trash2, Wind } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -180,7 +181,10 @@ export function EnvironmentSection() { setHomeResults(data.results); setHomeSearched(true); }, - onError: () => toast.error(t("settings.sections.environment.searchError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.searchError"), { + cause: failure, + }), }); const searchTravel = useMutation({ @@ -190,7 +194,10 @@ export function EnvironmentSection() { setTravelResults(data.results); setTravelSearched(true); }, - onError: () => toast.error(t("settings.sections.environment.searchError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.searchError"), { + cause: failure, + }), }); const setHome = useMutation({ @@ -209,7 +216,10 @@ export function EnvironmentSection() { invalidate(); toast.success(t("settings.sections.environment.homeSaved")); }, - onError: () => toast.error(t("settings.sections.environment.saveError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.saveError"), { + cause: failure, + }), }); const addTravel = useMutation({ @@ -230,7 +240,10 @@ export function EnvironmentSection() { invalidate(); toast.success(t("settings.sections.environment.travelAdded")); }, - onError: () => toast.error(t("settings.sections.environment.saveError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.saveError"), { + cause: failure, + }), }); const removeTravel = useMutation({ @@ -240,7 +253,10 @@ export function EnvironmentSection() { invalidate(); toast.success(t("settings.sections.environment.travelRemoved")); }, - onError: () => toast.error(t("settings.sections.environment.saveError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.saveError"), { + cause: failure, + }), }); const setAirQuality = useMutation({ @@ -249,8 +265,10 @@ export function EnvironmentSection() { apiPatch("/api/environment/preferences", { airQualityEnabled }), // The switch itself shows the new state; no toast on success. onSuccess: () => invalidate(), - onError: () => - toast.error(t("settings.sections.environment.airQuality.saveError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.airQuality.saveError"), { + cause: failure, + }), }); const backfill = useMutation({ @@ -264,8 +282,10 @@ export function EnvironmentSection() { invalidate(); toast.success(t("settings.sections.environment.backfillQueued")); }, - onError: () => - toast.error(t("settings.sections.environment.backfillError")), + onError: (failure) => + showErrorToast(t("settings.sections.environment.backfillError"), { + cause: failure, + }), }); if (!enabled) { diff --git a/src/components/settings/insights-pill-order-section.tsx b/src/components/settings/insights-pill-order-section.tsx index 7222916a8..aeeb5c209 100644 --- a/src/components/settings/insights-pill-order-section.tsx +++ b/src/components/settings/insights-pill-order-section.tsx @@ -3,6 +3,7 @@ import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Eye, EyeOff, GripVertical, ListOrdered, Loader2 } from "lucide-react"; import { DndContext, @@ -187,7 +188,7 @@ export function InsightsPillOrderSection({ id }: { id?: string }) { toast.message(t("common.conflictReloaded")); return; } - toast.error(t("insights.pillOrder.saveError")); + showErrorToast(t("insights.pillOrder.saveError"), { cause: err }); }, }); diff --git a/src/components/settings/integrations/connections-panel.tsx b/src/components/settings/integrations/connections-panel.tsx index bc89bb947..e0773c183 100644 --- a/src/components/settings/integrations/connections-panel.tsx +++ b/src/components/settings/integrations/connections-panel.tsx @@ -15,6 +15,7 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card"; import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note"; @@ -246,7 +247,7 @@ export function ConnectionsPanel({ const reasonKey = WITHINGS_OAUTH_ERROR_KEYS[withingsOauthOutcome.reason] ?? "settings.withingsOauthError.generic"; - toast.error(t("settings.withingsOauthFailed"), { + showErrorToast(t("settings.withingsOauthFailed"), { description: t(reasonKey), duration: 10_000, }); @@ -285,7 +286,7 @@ export function ConnectionsPanel({ } else { // Known reason tags resolve to a specific message; anything else falls // back to the generic copy (matching the Withings handler). - toast.error(t(`settings.${provider}OauthFailed`), { + showErrorToast(t(`settings.${provider}OauthFailed`), { description: t(oauthReasonKey(provider, oauthOutcome.reason)), duration: 10_000, }); diff --git a/src/components/settings/mcp-section.tsx b/src/components/settings/mcp-section.tsx index a9dfe96a1..fdffc8ad1 100644 --- a/src/components/settings/mcp-section.tsx +++ b/src/components/settings/mcp-section.tsx @@ -20,6 +20,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Copy, Key, Link2, Loader2, Plug, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -106,12 +107,16 @@ function McpConnectionsCard() { method: "DELETE", }); if (!res.ok) { - toast.error(t("settings.mcp.connectionRevokeFailed")); + showErrorToast(t("settings.mcp.connectionRevokeFailed"), { + cause: res, + }); return; } queryClient.invalidateQueries({ queryKey: queryKeys.mcpConnections() }); - } catch { - toast.error(t("settings.mcp.connectionRevokeFailed")); + } catch (failure) { + showErrorToast(t("settings.mcp.connectionRevokeFailed"), { + cause: failure, + }); } } @@ -224,7 +229,8 @@ function McpEnableCard() { void invalidateKeys(queryClient, aiInputDependentKeys); toast.success(t("settings.sections.modules.saved")); }, - onError: () => toast.error(t("settings.sections.modules.error")), + onError: (failure) => + showErrorToast(t("settings.sections.modules.error"), { cause: failure }), }); return ( @@ -334,8 +340,8 @@ function McpTokensCard() { setTokenCopied(true); toast.success(t("settings.tokenCopied")); setTimeout(() => setTokenCopied(false), 2_000); - } catch { - toast.error(t("settings.tokenCopyFailed")); + } catch (failure) { + showErrorToast(t("settings.tokenCopyFailed"), { cause: failure }); } } @@ -345,12 +351,12 @@ function McpTokensCard() { method: "DELETE", }); if (!res.ok) { - toast.error(t("settings.tokenRevokeFailed")); + showErrorToast(t("settings.tokenRevokeFailed"), { cause: res }); return; } queryClient.invalidateQueries({ queryKey: queryKeys.mcpTokens() }); - } catch { - toast.error(t("settings.tokenRevokeFailed")); + } catch (failure) { + showErrorToast(t("settings.tokenRevokeFailed"), { cause: failure }); } } diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx index 88c2542f1..5987c6574 100644 --- a/src/components/settings/score-section.tsx +++ b/src/components/settings/score-section.tsx @@ -48,6 +48,7 @@ import { useMemo, useState } from "react"; import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { SettingsCardActions } from "@/components/settings/_card-actions"; @@ -248,7 +249,7 @@ export function ScoreSection() { return; } } - toast.error(t("settings.sections.score.error")); + showErrorToast(t("settings.sections.score.error"), { cause: err }); }, }); @@ -267,8 +268,10 @@ export function ScoreSection() { ); void queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); }, - onError: () => { - toast.error(t("settings.sections.score.notice.dismissError")); + onError: (failure) => { + showErrorToast(t("settings.sections.score.notice.dismissError"), { + cause: failure, + }); }, }); @@ -287,8 +290,10 @@ export function ScoreSection() { ); void queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); }, - onError: () => { - toast.error(t("settings.sections.score.notice.dismissError")); + onError: (failure) => { + showErrorToast(t("settings.sections.score.notice.dismissError"), { + cause: failure, + }); }, }); diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 30dbba232..49f1e845c 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -31,6 +31,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { useMemo, useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { ArrowDown, ArrowUp, @@ -193,7 +194,10 @@ export function SourcesSection() { setDraft(null); toast.success(t("settings.sections.sources.saveSuccess")); }, - onError: () => toast.error(t("settings.sections.sources.saveError")), + onError: (failure) => + showErrorToast(t("settings.sections.sources.saveError"), { + cause: failure, + }), }); const resetMutation = useMutation({ diff --git a/src/components/settings/support-info-card.tsx b/src/components/settings/support-info-card.tsx new file mode 100644 index 000000000..7daa22a25 --- /dev/null +++ b/src/components/settings/support-info-card.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Copy, LifeBuoy } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import type { IntegrationStatusEnvelope } from "@/components/settings/integrations/shared"; +import { useAuth } from "@/hooks/use-auth"; +import { apiGet } from "@/lib/api/api-fetch"; +import { recentErrorIds } from "@/lib/errors/error-id"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { buildSupportInfo } from "@/lib/support/support-info"; + +interface VersionPayload { + version: string; + buildSha: string | null; +} + +/** + * Settings, About: "Copy support info". Puts together what a bug report needs + * from reads the app already makes (build, language, time zone, modules, AI + * state, each connection's health, the last error ids of this session) and + * copies it as plain text. No new route, nothing leaves the browser until the + * person pastes it somewhere. See `src/lib/support/support-info.ts`. + */ +export function SupportInfoCard() { + const { t, locale } = useTranslations(); + const { user } = useAuth(); + const queryClient = useQueryClient(); + const [busy, setBusy] = useState(false); + const [copied, setCopied] = useState(false); + + async function copy() { + setBusy(true); + try { + const [version, integrations] = await Promise.all([ + queryClient + .fetchQuery({ + queryKey: queryKeys.apiVersion(), + queryFn: async () => apiGet("/api/version"), + staleTime: Infinity, + }) + .catch(() => null), + queryClient + .fetchQuery({ + queryKey: queryKeys.integrationsStatus(), + queryFn: async () => + apiGet("/api/integrations/status"), + }) + .catch(() => null), + ]); + const text = buildSupportInfo({ + version, + me: user + ? { + id: user.id, + username: user.username, + email: user.email, + fullName: user.fullName, + timezone: user.timezone, + modules: user.modules, + ai: user.ai ?? null, + } + : null, + locale, + connections: integrations?.integrations ?? null, + recentErrorIds: recentErrorIds(), + client: { + userAgent: + typeof navigator !== "undefined" ? navigator.userAgent : null, + standalone: + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(display-mode: standalone)").matches, + }, + generatedAt: new Date().toISOString(), + }); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + window.prompt(t("settings.about.support.copy"), text); + } + } catch (failure) { + showErrorToast(t("settings.about.support.copyFailed"), { + cause: failure, + }); + } finally { + setBusy(false); + } + } + + return ( + + + + + + + ); +} diff --git a/src/components/settings/thresholds-editor-section.tsx b/src/components/settings/thresholds-editor-section.tsx index 6e857bc5a..ee3869cb9 100644 --- a/src/components/settings/thresholds-editor-section.tsx +++ b/src/components/settings/thresholds-editor-section.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { SlidersHorizontal, RotateCcw, AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ConfirmButton, ConfirmDialog } from "@/components/ui/confirm-button"; @@ -101,8 +102,9 @@ export function ThresholdsEditorSection({ id }: { id: string }) { toast.success(t("thresholds.saveSuccess")); }, onError: (err) => - toast.error( + showErrorToast( err instanceof Error ? err.message : t("thresholds.saveError"), + { cause: err }, ), }); @@ -131,7 +133,8 @@ export function ThresholdsEditorSection({ id }: { id: string }) { void refetchInactiveDailyReads(queryClient); toast.success(t("thresholds.resetSuccess")); }, - onError: () => toast.error(t("thresholds.saveError")), + onError: (failure) => + showErrorToast(t("thresholds.saveError"), { cause: failure }), }); return ( diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index bddd162e0..c0f018f18 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -3,6 +3,7 @@ import { useEffect, useId, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { RotateCcw } from "lucide-react"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -257,8 +258,9 @@ function TargetEditSheetBody({ onOpenChange(false); }, onError: (err) => - toast.error( + showErrorToast( err instanceof Error ? err.message : t("targets.edit.saveError"), + { cause: err }, ), }); @@ -284,7 +286,8 @@ function TargetEditSheetBody({ toast.success(t("targets.edit.resetSuccess")); onOpenChange(false); }, - onError: () => toast.error(t("targets.edit.saveError")), + onError: (failure) => + showErrorToast(t("targets.edit.saveError"), { cause: failure }), }); const busy = updateMutation.isPending || resetMutation.isPending; diff --git a/src/components/vaccinations/booster-mint-prompt.tsx b/src/components/vaccinations/booster-mint-prompt.tsx index cb80e2982..ff7488a06 100644 --- a/src/components/vaccinations/booster-mint-prompt.tsx +++ b/src/components/vaccinations/booster-mint-prompt.tsx @@ -18,6 +18,7 @@ */ import { useState } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { @@ -105,8 +106,9 @@ export function BoosterMintPrompt({ }); toastWrittenOutcome("success", t("vaccinations.booster.planned")); } catch (err) { - toast.error( + showErrorToast( err instanceof Error ? err.message : t("vaccinations.booster.failed"), + { cause: err }, ); } finally { onClose(); diff --git a/src/components/vaccinations/custom-vaccine-sheet.tsx b/src/components/vaccinations/custom-vaccine-sheet.tsx index 929ac6819..7431a0add 100644 --- a/src/components/vaccinations/custom-vaccine-sheet.tsx +++ b/src/components/vaccinations/custom-vaccine-sheet.tsx @@ -19,6 +19,7 @@ import { useState } from "react"; import { Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -152,7 +153,7 @@ export function CustomVaccineSheet({ ? t("vaccinations.custom.nameTaken") : t("vaccinations.custom.saveFailed"); setError(message); - toast.error(message); + showErrorToast(message, { cause: err }); } }; @@ -315,8 +316,10 @@ export function CustomVaccineSheet({ ); onOpenChange(false); }, - onError: () => - toast.error(t("vaccinations.custom.saveFailed")), + onError: (failure) => + showErrorToast(t("vaccinations.custom.saveFailed"), { + cause: failure, + }), }); }} > diff --git a/src/components/vaccinations/vaccination-document-suggestion.tsx b/src/components/vaccinations/vaccination-document-suggestion.tsx index 942ba59bc..319d19b0a 100644 --- a/src/components/vaccinations/vaccination-document-suggestion.tsx +++ b/src/components/vaccinations/vaccination-document-suggestion.tsx @@ -19,6 +19,7 @@ import { useState } from "react"; import { Check, Syringe } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; @@ -74,7 +75,10 @@ export function VaccinationDocumentSuggestion({ setLinkedId(candidate.id); toastWrittenOutcome("success", t("vaccinations.suggestion.linked")); }, - onError: () => toast.error(t("vaccinations.suggestion.failed")), + onError: (failure) => + showErrorToast(t("vaccinations.suggestion.failed"), { + cause: failure, + }), }, ); }; diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index 3cb0e8130..40dbbd401 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -21,6 +21,7 @@ import { useState } from "react"; import { Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { AlertDialog, @@ -100,7 +101,7 @@ export function VaccinationSheet({ const message = err instanceof Error ? err.message : t("vaccinations.saveFailed"); setError(message); - toast.error(message); + showErrorToast(message, { cause: err }); } }; @@ -188,8 +189,10 @@ export function VaccinationSheet({ onClick={() => { remove.mutate(vaccination.id, { onSuccess: () => onOpenChange(false), - onError: () => - toast.error(t("vaccinations.deleteFailed")), + onError: (failure) => + showErrorToast(t("vaccinations.deleteFailed"), { + cause: failure, + }), }); }} > diff --git a/src/hooks/use-account-switch.ts b/src/hooks/use-account-switch.ts index e23ddc89a..924073df2 100644 --- a/src/hooks/use-account-switch.ts +++ b/src/hooks/use-account-switch.ts @@ -35,6 +35,7 @@ import { } from "@tanstack/react-query"; import { useCallback } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; import { @@ -223,10 +224,11 @@ export function useAccountSwitch() { return landOnRecord(destination); }, onError: (error: unknown) => { - toast.error( + showErrorToast( isSharingAccessDenied(error) ? t("recordSharing.switch.noLongerAvailable") : t("recordSharing.switch.failed"), + { cause: error }, ); }, }); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 038625e60..fb17c7f50 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -8,6 +8,7 @@ import { } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiFetchRaw, ApiError } from "@/lib/api/api-fetch"; import { adoptRecordFenceState } from "@/lib/api/record-fence"; @@ -646,6 +647,7 @@ export function useLogout() { }, // v1.16.4 — a network-failed logout used to do nothing at all (the // menu closed, the session stayed); a toast names the failure. - onError: () => toast.error(t("common.networkError")), + onError: (failure) => + showErrorToast(t("common.networkError"), { cause: failure }), }); } diff --git a/src/hooks/use-module-toggle.ts b/src/hooks/use-module-toggle.ts index db2b9224b..c54fbce6b 100644 --- a/src/hooks/use-module-toggle.ts +++ b/src/hooks/use-module-toggle.ts @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { useAuth } from "@/hooks/use-auth"; import { apiPatch } from "@/lib/api/api-fetch"; @@ -106,7 +107,7 @@ export function useModuleToggle(options?: { toast.message(t("common.conflictReloaded")); return; } - toast.error(t("settings.sections.modules.error")); + showErrorToast(t("settings.sections.modules.error"), { cause: err }); }, }); } diff --git a/src/lib/api/__tests__/api-fetch.test.ts b/src/lib/api/__tests__/api-fetch.test.ts index 148635b07..e526e6f12 100644 --- a/src/lib/api/__tests__/api-fetch.test.ts +++ b/src/lib/api/__tests__/api-fetch.test.ts @@ -405,3 +405,51 @@ describe("verb helpers", () => { ); }); }); + +describe("ApiError.requestId", () => { + it("takes meta.requestId, else the x-request-id header", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse( + { data: null, error: "boom", meta: { requestId: "meta-id-0001" } }, + 500, + ), + ); + const fromMeta = await apiGet("/api/x").catch((e: unknown) => e); + expect(fromMeta).toBeInstanceOf(ApiError); + expect((fromMeta as ApiError).requestId).toBe("meta-id-0001"); + + fetchMock.mockResolvedValueOnce( + new Response("Bad gateway", { + status: 502, + headers: { "x-request-id": "header-id-0001" }, + }), + ); + const fromHeader = await apiGet("/api/x").catch((e: unknown) => e); + expect((fromHeader as ApiError).requestId).toBe("header-id-0001"); + + fetchMock.mockResolvedValueOnce(new Response("nope", { status: 500 })); + const none = await apiGet("/api/x").catch((e: unknown) => e); + expect((none as ApiError).requestId).toBeNull(); + }); + + it("records a server failure's id for the session's support info", async () => { + const { recentErrorIds, _resetRecentErrorIdsForTests } = + await import("@/lib/errors/error-id"); + _resetRecentErrorIdsForTests(); + fetchMock.mockResolvedValueOnce( + jsonResponse( + { data: null, error: "boom", meta: { requestId: "srv-fail-0001" } }, + 503, + ), + ); + fetchMock.mockResolvedValueOnce( + jsonResponse( + { data: null, error: "no", meta: { requestId: "client-0001" } }, + 404, + ), + ); + await apiGet("/api/x").catch(() => {}); + await apiGet("/api/x").catch(() => {}); + expect(recentErrorIds()).toEqual(["srv-fail-0001"]); + }); +}); diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts index 5d6dfcc31..95c13f927 100644 --- a/src/lib/api/api-fetch.ts +++ b/src/lib/api/api-fetch.ts @@ -50,6 +50,7 @@ */ import { randomId } from "@/lib/random-id"; +import { recordErrorId } from "@/lib/errors/error-id"; import { readError } from "./read-error"; import { validateResponseContext, @@ -62,12 +63,28 @@ export class ApiError extends Error { readonly status: number; /** `meta` object of the error envelope, when the body carried one. */ readonly meta: Record | undefined; + /** + * The server's request id for this failure: `meta.requestId`, else the + * `x-request-id` response header, else null (a proxy answered, or an older + * server). Its first characters are the error id a person reads out. + */ + readonly requestId: string | null; - constructor(message: string, status: number, meta?: Record) { + constructor( + message: string, + status: number, + meta?: Record, + requestId?: string | null, + ) { super(message); this.name = "ApiError"; this.status = status; this.meta = meta; + const fromMeta = + typeof meta?.requestId === "string" && meta.requestId.length > 0 + ? meta.requestId + : null; + this.requestId = fromMeta ?? requestId ?? null; } } @@ -87,7 +104,16 @@ async function throwApiError(res: Response): Promise { } catch { /* non-JSON body — readError supplies the fallback message */ } - throw new ApiError(await readError(res), res.status, meta); + const error = new ApiError( + await readError(res), + res.status, + meta, + res.headers.get("x-request-id"), + ); + // A server-side failure is worth an id in the session's support info, + // whether or not the call site ends up showing it. + if (error.status >= 500 && error.requestId) recordErrorId(error.requestId); + throw error; } async function parseEnvelope( diff --git a/src/lib/errors/__tests__/error-id.test.ts b/src/lib/errors/__tests__/error-id.test.ts new file mode 100644 index 000000000..aef107c63 --- /dev/null +++ b/src/lib/errors/__tests__/error-id.test.ts @@ -0,0 +1,98 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; + +// The node test environment has no `window`; a session storage stand-in is +// all this module touches. +const store = new Map(); +const sessionStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), +}; +vi.stubGlobal("window", { sessionStorage }); +afterAll(() => { + vi.unstubAllGlobals(); +}); + +import { ApiError } from "@/lib/api/api-fetch"; +import { + _resetRecentErrorIdsForTests, + errorIdOf, + recentErrorIds, + recordErrorId, + RECENT_ERROR_IDS_MAX, + shortErrorId, +} from "../error-id"; + +const ID = "3f9a2c1b-0000-4000-8000-000000000001"; + +beforeEach(() => { + _resetRecentErrorIdsForTests(); +}); + +describe("errorIdOf", () => { + it("shows a server failure's request id and keeps a 4xx quiet", () => { + expect(errorIdOf(new ApiError("boom", 500, { requestId: ID }))).toBe(ID); + expect(errorIdOf(new ApiError("no", 404, { requestId: ID }))).toBeNull(); + expect(errorIdOf(new ApiError("boom", 503, undefined, ID))).toBe(ID); + expect(errorIdOf(new ApiError("boom", 500))).toBeNull(); + }); + + it("shows a 4xx too once it reached an error boundary", () => { + expect( + errorIdOf(new ApiError("no", 404, { requestId: ID }), { + unexpected: true, + }), + ).toBe(ID); + }); + + it("prefers meta.requestId over the header", () => { + const error = new ApiError("boom", 500, { requestId: ID }, "header-0001"); + expect(error.requestId).toBe(ID); + }); + + it("reads the header of a raw Response", () => { + const res = new Response("x", { + status: 502, + headers: { "x-request-id": ID }, + }); + expect(errorIdOf(res)).toBe(ID); + expect( + errorIdOf( + new Response("x", { status: 409, headers: { "x-request-id": ID } }), + ), + ).toBeNull(); + }); + + it("falls back to a render digest and to nothing", () => { + const rendered = Object.assign(new Error("render"), { + digest: "123456789", + }); + expect(errorIdOf(rendered)).toBe("123456789"); + expect(errorIdOf(new TypeError("Failed to fetch"))).toBeNull(); + expect(errorIdOf(undefined)).toBeNull(); + expect(errorIdOf("text")).toBeNull(); + }); + + it("shows the first 8 characters", () => { + expect(shortErrorId(ID)).toBe("3f9a2c1b"); + }); +}); + +describe("recent error ids", () => { + it("keeps the newest five, newest first, without repeats", () => { + for (let i = 0; i < 7; i++) recordErrorId(`id-0000-${i}`); + recordErrorId("id-0000-5"); + const ids = recentErrorIds(); + expect(ids).toHaveLength(RECENT_ERROR_IDS_MAX); + expect(ids[0]).toBe("id-0000-5"); + expect(new Set(ids).size).toBe(ids.length); + expect(ids).not.toContain("id-0000-0"); + }); + + it("survives a reload through session storage", () => { + recordErrorId(ID); + expect( + JSON.parse(sessionStorage.getItem("healthlog-recent-error-ids")!), + ).toEqual([ID]); + }); +}); diff --git a/src/lib/errors/__tests__/error-toast.test.tsx b/src/lib/errors/__tests__/error-toast.test.tsx new file mode 100644 index 000000000..0abc6b7cd --- /dev/null +++ b/src/lib/errors/__tests__/error-toast.test.tsx @@ -0,0 +1,48 @@ +import { isValidElement } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("sonner", () => ({ toast: { error: vi.fn(() => "toast-id") } })); + +import { toast } from "sonner"; +import { ApiError } from "@/lib/api/api-fetch"; +import { showErrorToast } from "../error-toast"; + +const ID = "3f9a2c1b-0000-4000-8000-000000000001"; + +beforeEach(() => { + vi.mocked(toast.error).mockClear(); +}); + +describe("showErrorToast", () => { + it("is exactly toast.error without a cause", () => { + showErrorToast("Could not save"); + expect(toast.error).toHaveBeenCalledWith("Could not save"); + showErrorToast("Could not save", { id: "x" }); + expect(toast.error).toHaveBeenLastCalledWith("Could not save", { id: "x" }); + }); + + it("is exactly toast.error for a cause with no id worth showing", () => { + showErrorToast("Not allowed", { + cause: new ApiError("no", 403, { requestId: ID }), + }); + expect(toast.error).toHaveBeenCalledWith("Not allowed"); + }); + + it("adds the error id to a server failure and keeps the toast up longer", () => { + showErrorToast("Could not save", { + cause: new ApiError("boom", 500, { requestId: ID }), + description: "Try again.", + }); + const [message, options] = vi.mocked(toast.error).mock.calls[0] as [ + string, + { description: unknown; duration: number }, + ]; + expect(message).toBe("Could not save"); + expect(options.duration).toBe(10_000); + expect(isValidElement(options.description)).toBe(true); + const props = (options.description as { props: Record }) + .props; + expect(props.id).toBe(ID); + expect(props.description).toBe("Try again."); + }); +}); diff --git a/src/lib/errors/error-id.ts b/src/lib/errors/error-id.ts new file mode 100644 index 000000000..9acc827f3 --- /dev/null +++ b/src/lib/errors/error-id.ts @@ -0,0 +1,129 @@ +/** + * Error ids: the short handle a person reads out when something failed. + * + * Every API error envelope carries the request id (`meta.requestId`, and the + * `x-request-id` header). The web shows its first 8 characters with a copy + * button on a server failure (5xx) or an unexpected one, and the operator + * finds the event with it under Admin, App logs. A render failure that never + * reached the API has no request id; for one the server rendered, Next's + * `digest` is the handle the server log prints beside the stack. + * + * The last few ids of this browser session are kept for the "Copy support + * info" text in Settings, About, so a person filing an issue later still has + * them. Session storage, not local: they belong to this visit. + * + * Every error display goes through this module (toasts through + * `showErrorToast`, boundaries through `ErrorIdLine`); the guard is + * `src/__tests__/error-display-helper-guard.test.ts`. + */ + +/** How many characters of the id are shown. */ +export const ERROR_ID_LENGTH = 8; + +/** How many ids the session keeps for the support info. */ +export const RECENT_ERROR_IDS_MAX = 5; + +const STORAGE_KEY = "healthlog-recent-error-ids"; + +let memory: string[] = []; + +/** The displayed form of an id. */ +export function shortErrorId(id: string): string { + return id.slice(0, ERROR_ID_LENGTH); +} + +function readStored(): string[] { + if (typeof window === "undefined") return memory; + try { + const raw = window.sessionStorage.getItem(STORAGE_KEY); + const parsed: unknown = raw ? JSON.parse(raw) : []; + if ( + Array.isArray(parsed) && + parsed.every((v) => typeof v === "string" && v.length <= 64) + ) { + return parsed.slice(0, RECENT_ERROR_IDS_MAX); + } + } catch { + /* storage blocked or malformed: memory is the fallback */ + } + return memory; +} + +/** Remember an id for this session's support info, newest first. */ +export function recordErrorId(id: string): void { + if (!id) return; + const next = [id, ...readStored().filter((v) => v !== id)].slice( + 0, + RECENT_ERROR_IDS_MAX, + ); + memory = next; + if (typeof window === "undefined") return; + try { + window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + /* the in-memory copy still serves this page */ + } +} + +/** The ids this session saw, newest first, at most `RECENT_ERROR_IDS_MAX`. */ +export function recentErrorIds(): string[] { + return [...readStored()]; +} + +/** Test helper. */ +export function _resetRecentErrorIdsForTests(): void { + memory = []; + if (typeof window === "undefined") return; + try { + window.sessionStorage.removeItem(STORAGE_KEY); + } catch { + /* nothing to clear */ + } +} + +interface ApiLikeError { + status: number; + requestId: string | null; +} + +function isApiLike(error: unknown): error is ApiLikeError { + return ( + typeof error === "object" && + error !== null && + typeof (error as { status?: unknown }).status === "number" && + "requestId" in error + ); +} + +/** + * The id to show for a failure, or null when there is none worth showing. + * + * - An API failure shows its request id when the server failed (5xx). A + * 4xx is an answer the person can act on and its message says how, + * unless it reached an error boundary (`unexpected`), where nothing is + * an expected answer any more. + * - Any other error is unexpected; it shows Next's `digest` when the + * server rendered it, and nothing when there is no id to find it by. + */ +export function errorIdOf( + error: unknown, + options: { unexpected?: boolean } = {}, +): string | null { + // A raw `Response` from `apiFetchRaw`, for the call sites that branch on + // `res.ok` themselves: the id rides the header. + if (typeof Response !== "undefined" && error instanceof Response) { + const id = error.headers.get("x-request-id"); + const shown = options.unexpected === true || error.status >= 500; + return shown && id ? id : null; + } + if (isApiLike(error)) { + // Inside an error boundary every failure is unexpected, a 4xx included. + const shown = options.unexpected === true || error.status >= 500; + return shown && error.requestId ? error.requestId : null; + } + if (typeof error === "object" && error !== null) { + const digest = (error as { digest?: unknown }).digest; + if (typeof digest === "string" && digest.length > 0) return digest; + } + return null; +} diff --git a/src/lib/errors/error-toast.tsx b/src/lib/errors/error-toast.tsx new file mode 100644 index 000000000..41ab12c22 --- /dev/null +++ b/src/lib/errors/error-toast.tsx @@ -0,0 +1,64 @@ +"use client"; + +import type { ReactNode } from "react"; +import { toast, type ExternalToast } from "sonner"; + +import { ErrorIdLine } from "@/components/errors/error-id-line"; +import { errorIdOf } from "./error-id"; + +export interface ErrorToastOptions extends ExternalToast { + /** + * The failure behind the toast, usually the caught error. When it is a + * server failure (an `ApiError` with a 5xx status) the toast carries its + * error id with a copy button; otherwise the toast is the plain one. + */ + cause?: unknown; +} + +/** How long a toast that carries an error id stays, so it can be copied. */ +const ERROR_ID_TOAST_DURATION_MS = 10_000; + +/** + * The one error toast of the web app. + * + * Every error toast goes through here so a server failure always shows the id + * a person needs to report it (`src/__tests__/error-display-helper-guard.test.ts` + * holds call sites to it). Without a `cause`, or with one that has no id worth + * showing, the call is exactly `toast.error(message, options)`. + */ +export function showErrorToast( + message: Parameters[0], + options: ErrorToastOptions = {}, +): string | number { + const { cause, ...rest } = options; + const id = errorIdOf(cause); + if (!id) { + return Object.keys(rest).length > 0 + ? toast.error(message, rest) + : toast.error(message); + } + const description = + typeof rest.description === "function" + ? rest.description() + : rest.description; + return toast.error(message, { + ...rest, + duration: rest.duration ?? ERROR_ID_TOAST_DURATION_MS, + description: , + }); +} + +function ErrorToastDescription({ + description, + id, +}: { + description: ReactNode; + id: string; +}) { + return ( + + {description ? {description} : null} + + + ); +} diff --git a/src/lib/queries/use-documents-layout.ts b/src/lib/queries/use-documents-layout.ts index 5b1251e0e..579bf5ec3 100644 --- a/src/lib/queries/use-documents-layout.ts +++ b/src/lib/queries/use-documents-layout.ts @@ -6,6 +6,7 @@ import { type QueryClient, } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; import { @@ -58,9 +59,9 @@ export async function runSetDocumentsLayout(deps: { { version: 1, ...patch }, ); if (saved) queryClient.setQueryData(key, saved); - } catch { + } catch (failure) { queryClient.setQueryData(key, previous); - toast.error(t("documents.layout.saveFailed")); + showErrorToast(t("documents.layout.saveFailed"), { cause: failure }); } } diff --git a/src/lib/queries/use-medication-list-layout.ts b/src/lib/queries/use-medication-list-layout.ts index 6a2ef6815..8b36d2ca6 100644 --- a/src/lib/queries/use-medication-list-layout.ts +++ b/src/lib/queries/use-medication-list-layout.ts @@ -6,6 +6,7 @@ import { type QueryClient, } from "@tanstack/react-query"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/errors/error-toast"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; import { @@ -74,7 +75,7 @@ export async function runSetMedicationListView(deps: { toast.message(t("common.conflictReloaded")); return; } - toast.error(t("medications.viewSaveFailed")); + showErrorToast(t("medications.viewSaveFailed"), { cause: err }); } } @@ -114,7 +115,7 @@ export async function runSaveMedicationListOrder(deps: { toast.message(t("common.conflictReloaded")); return false; } - toast.error(t("medications.reorderSaveFailed")); + showErrorToast(t("medications.reorderSaveFailed"), { cause: err }); return false; } } diff --git a/src/lib/support/__tests__/support-info.test.ts b/src/lib/support/__tests__/support-info.test.ts new file mode 100644 index 000000000..7c05005f6 --- /dev/null +++ b/src/lib/support/__tests__/support-info.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import { buildSupportInfo, type SupportInfoInput } from "../support-info"; + +const ERROR_ID = "3f9a2c1b-0000-4000-8000-000000000001"; + +function input(over: Partial = {}): SupportInfoInput { + return { + version: { version: "1.43.0", buildSha: "abc1234" }, + me: { + id: "cmuserabcdefghijklmnopqr", + username: "distinctuser", + email: "distinct.user@example.org", + fullName: "Dora Distinct", + timezone: "Europe/Berlin", + modules: { mood: true, sleep: true, cycle: false }, + ai: { + capabilities: { + coach: { available: false, reason: "not_granted" }, + briefing: { available: true, reason: null }, + }, + provider: { configured: true, managedBy: "server" }, + usage: { level: "STANDARD" }, + }, + }, + locale: "de", + connections: [ + { integration: "polar", connected: false, health: null }, + { + integration: "withings", + connected: true, + health: { + state: "degraded", + reason: "provider_errors", + since: "2026-10-14T08:00:00.000Z", + action: null, + unavailable: [{ item: "spo2", cause: "plan" }], + lastSuccessAt: "2026-10-14T07:00:00.000Z", + lastDataAt: "2026-10-14T06:00:00.000Z", + diagnostics: { + ledgerState: "failing", + failingLegs: ["sleep"], + failingSince: "2026-10-14T08:00:00.000Z", + lastAttemptAt: "2026-10-15T08:00:00.000Z", + verdict: "stale", + recentCalls: [ + { + endpoint: "sleep", + status: "transient", + at: "2026-10-15T08:00:00.000Z", + errorCode: "503", + }, + ], + }, + }, + }, + ], + recentErrorIds: [ERROR_ID], + client: { + userAgent: + "Mozilla/5.0 (Macintosh) AppleWebKit/537.36 Chrome/128.0.0.0 Safari/537.36", + standalone: true, + }, + generatedAt: "2026-10-15T10:00:00.000Z", + ...over, + } as SupportInfoInput; +} + +describe("buildSupportInfo", () => { + it("carries build, account settings, AI state, health and error ids", () => { + const text = buildSupportInfo(input()); + expect(text).toContain("- Server version: 1.43.0"); + expect(text).toContain("- Build SHA: abc1234"); + expect(text).toContain("- Client: web (installed app)"); + expect(text).toContain("Chrome/128.0.0.0"); + expect(text).toContain("- Language: de"); + expect(text).toContain("- Time zone: Europe/Berlin"); + expect(text).toContain("- Enabled modules: mood, sleep"); + expect(text).toContain("- Usage level: STANDARD"); + expect(text).toContain("- coach: unavailable (not_granted)"); + expect(text).toContain( + "- withings: degraded / provider_errors since 2026-10-14T08:00:00.000Z", + ); + expect(text).toContain("Unavailable: spo2 (plan)"); + expect(text).toContain("2026-10-15T08:00:00.000Z sleep: transient (503)"); + expect(text).not.toContain("polar"); + // The person's own error ids survive the id-shaped scrub. + expect(text).toContain(`- ${ERROR_ID}`); + }); + + it("never carries the username, email, name or account id", () => { + const text = buildSupportInfo(input()); + for (const value of [ + "cmuserabcdefghijklmnopqr", + "distinctuser", + "distinct.user@example.org", + "Dora Distinct", + ]) { + expect(text.toLowerCase()).not.toContain(value.toLowerCase()); + } + }); + + it("redacts an account value that turns up in a field it should not", () => { + const text = buildSupportInfo( + input({ + me: { + ...input().me!, + timezone: "distinct.user@example.org", + }, + }), + ); + expect(text).not.toContain("distinct.user@example.org"); + }); + + it("says what it could not load rather than guessing", () => { + const text = buildSupportInfo( + input({ version: null, connections: null, recentErrorIds: [] }), + ); + expect(text).toContain("- Server version: unknown"); + expect(text).toContain("## Connections\n- Not loaded"); + expect(text).toContain("## Recent error ids (this session)\n- None"); + }); +}); diff --git a/src/lib/support/redact.ts b/src/lib/support/redact.ts index 78b7b7c63..7ab5359ed 100644 --- a/src/lib/support/redact.ts +++ b/src/lib/support/redact.ts @@ -121,7 +121,8 @@ export const SUPPORT_REDACTION_STEPS: readonly SupportRedactionStep[] = [ name: "ip-addresses", apply: (text) => text - .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[ip]") + // Not after a slash or a dot: `Chrome/128.0.0.0` is a version. + .replace(/(?>; + ai?: { + capabilities?: Record< + string, + { available: boolean; reason: string | null } + >; + provider?: { configured: boolean; managedBy: string | null }; + usage?: { level?: string | null } | null; + } | null; + } | null; + locale: string; + connections: Array<{ + integration: string; + connected?: boolean; + health?: ConnectionHealth | null; + }> | null; + recentErrorIds: readonly string[]; + client: { userAgent: string | null; standalone: boolean }; + generatedAt: string; +} + +/** The support info as plain text, redacted. */ +export function buildSupportInfo(input: SupportInfoInput): string { + const lines: string[] = ["# HealthLog support info"]; + lines.push(`Generated: ${input.generatedAt}`); + + lines.push("", "## App"); + lines.push(`- Server version: ${input.version?.version ?? "unknown"}`); + lines.push(`- Build SHA: ${input.version?.buildSha ?? "unknown"}`); + lines.push( + `- Client: web${input.client.standalone ? " (installed app)" : ""}`, + ); + if (input.client.userAgent) { + lines.push(`- Browser: ${input.client.userAgent}`); + } + + lines.push("", "## Account"); + lines.push(`- Language: ${input.locale}`); + lines.push(`- Time zone: ${input.me?.timezone ?? "unknown"}`); + const modules = Object.entries(input.me?.modules ?? {}) + .filter(([, on]) => on === true) + .map(([key]) => key) + .sort(); + lines.push(`- Enabled modules: ${modules.join(", ") || "none"}`); + + const ai = input.me?.ai ?? null; + lines.push("", "## AI"); + if (!ai) { + lines.push("- State: not reported"); + } else { + if (ai.provider) { + lines.push( + `- Provider configured: ${ai.provider.configured ? "yes" : "no"}${ai.provider.managedBy ? ` (${ai.provider.managedBy})` : ""}`, + ); + } + if (ai.usage?.level) lines.push(`- Usage level: ${ai.usage.level}`); + for (const [key, state] of Object.entries(ai.capabilities ?? {}).sort( + ([a], [b]) => a.localeCompare(b), + )) { + lines.push( + `- ${key}: ${state.available ? "available" : `unavailable (${state.reason ?? "unknown"})`}`, + ); + } + } + + lines.push("", "## Connections"); + const connected = (input.connections ?? []).filter((c) => c.health); + if (input.connections === null) { + lines.push("- Not loaded"); + } else if (connected.length === 0) { + lines.push("- None connected"); + } + for (const c of connected) { + const h = c.health!; + lines.push( + `- ${c.integration}: ${h.state} / ${h.reason}${h.since ? ` since ${h.since}` : ""}${h.action ? `, action ${h.action}` : ""}`, + ); + if (h.lastSuccessAt) lines.push(` - Last success: ${h.lastSuccessAt}`); + if (h.lastDataAt) lines.push(` - Newest data: ${h.lastDataAt}`); + if (h.unavailable.length > 0) { + lines.push( + ` - Unavailable: ${h.unavailable.map((u) => `${u.item} (${u.cause})`).join(", ")}`, + ); + } + const d = h.diagnostics; + lines.push( + ` - Ledger: ${d.ledgerState ?? "none"}, verdict ${d.verdict}${d.failingLegs.length > 0 ? `, failing ${d.failingLegs.join(", ")}` : ""}${d.failingSince ? ` since ${d.failingSince}` : ""}`, + ); + for (const call of d.recentCalls.slice(0, 5)) { + lines.push( + ` - ${call.at} ${call.endpoint}: ${call.status}${call.errorCode ? ` (${call.errorCode})` : ""}`, + ); + } + } + + lines.push("", "## Recent error ids (this session)"); + if (input.recentErrorIds.length === 0) lines.push("- None"); + for (const id of input.recentErrorIds) lines.push(`- ${id}`); + + const knownValues = input.me + ? [ + input.me.id, + input.me.username, + input.me.email, + input.me.fullName, + ].filter((v): v is string => typeof v === "string" && v.length > 0) + : []; + // The error ids are this session's own request ids, which the person is + // meant to hand over; they are put back after the id-shaped scrub. + const placeholders = input.recentErrorIds.map((_, i) => `@@ERRORID${i}@@`); + let text = `${lines.join("\n")}\n`; + input.recentErrorIds.forEach((id, i) => { + text = text.split(`- ${id}\n`).join(`- ${placeholders[i]}\n`); + }); + text = redactSupportText(text, { knownValues }); + input.recentErrorIds.forEach((id, i) => { + text = text.split(placeholders[i]).join(id); + }); + return text; +} diff --git a/tests/integration/admin-support-report.test.ts b/tests/integration/admin-support-report.test.ts index 8a05ac7a5..94b0e431e 100644 --- a/tests/integration/admin-support-report.test.ts +++ b/tests/integration/admin-support-report.test.ts @@ -57,7 +57,8 @@ async function signIn(userId: string) { } function call() { - return GET(new NextRequest("http://localhost/api/admin/support-report")); + const route = GET as unknown as (request: NextRequest) => Promise; + return route(new NextRequest("http://localhost/api/admin/support-report")); } describe("GET /api/admin/support-report (integration)", () => { From 6979928d73ca2f30ddcfc85ed407f1bac415fcef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:36:34 +0200 Subject: [PATCH 06/10] chore(web): drop toast imports the error toast helper made unused --- src/components/cycle/use-cycle.ts | 1 - src/components/documents/document-link-picker.tsx | 1 - src/components/encounters/encounter-sheet.tsx | 1 - src/components/insights/coach-panel/coach-conversation.tsx | 1 - src/components/insights/coach-panel/copy-table-button.tsx | 1 - src/components/insights/coach-panel/memory-note.tsx | 1 - src/components/insights/coach-panel/reminder-suggestion-card.tsx | 1 - src/components/insights/coach-panel/sources-rail.tsx | 1 - src/components/insights/coach-panel/suggested-action-card.tsx | 1 - src/components/insights/coach-panel/use-coach.ts | 1 - src/components/insights/pattern-dismiss-action.tsx | 1 - src/components/labs/ocr-review-dialog.tsx | 1 - src/components/medications/detail/efficacy/efficacy-tab.tsx | 1 - src/components/medications/glp1-medication-card.tsx | 1 - src/components/medications/log-intake-dialog.tsx | 1 - src/components/medications/medication-card.tsx | 1 - src/components/medications/sections/courses-section.tsx | 1 - src/components/medications/side-effects-section.tsx | 1 - src/components/onboarding/confirm-screen.tsx | 1 - src/components/onboarding/done-screen.tsx | 1 - src/components/onboarding/first-result-screen.tsx | 1 - src/components/onboarding/question-screen.tsx | 1 - src/components/onboarding/units-screen.tsx | 1 - src/components/onboarding/welcome-screen.tsx | 1 - src/components/practitioners/practitioner-list.tsx | 1 - src/components/practitioners/practitioner-sheet.tsx | 1 - src/components/providers.tsx | 1 - src/components/records/ai-profile-inclusion-manager.tsx | 1 - src/components/records/emergency-profile-manager.tsx | 1 - src/components/records/health-profile-facts-manager.tsx | 1 - src/components/settings/access/managed-profile-handover.tsx | 1 - src/components/settings/account-section/setup-card.tsx | 1 - src/components/settings/ai/runtime-actions-row.tsx | 1 - src/components/vaccinations/booster-mint-prompt.tsx | 1 - src/components/vaccinations/custom-vaccine-sheet.tsx | 1 - src/components/vaccinations/vaccination-document-suggestion.tsx | 1 - src/components/vaccinations/vaccination-sheet.tsx | 1 - src/hooks/use-account-switch.ts | 1 - src/hooks/use-auth.ts | 1 - src/lib/queries/use-documents-layout.ts | 1 - 40 files changed, 40 deletions(-) diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index 213a2b193..7b561dc7e 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -11,7 +11,6 @@ */ import { useMemo } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { diff --git a/src/components/documents/document-link-picker.tsx b/src/components/documents/document-link-picker.tsx index 59076672f..aadd54e5b 100644 --- a/src/components/documents/document-link-picker.tsx +++ b/src/components/documents/document-link-picker.tsx @@ -16,7 +16,6 @@ import { useDocumentDate } from "@/components/documents/use-document-date"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Search } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Input } from "@/components/ui/input"; diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx index 463442272..654c6ac8f 100644 --- a/src/components/encounters/encounter-sheet.tsx +++ b/src/components/encounters/encounter-sheet.tsx @@ -33,7 +33,6 @@ */ import { useState } from "react"; import { Trash2 } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 0ac00a043..a0092b46b 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -8,7 +8,6 @@ import { useQueryClient, } from "@tanstack/react-query"; import { Paperclip, Sparkles } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { cn } from "@/lib/utils"; diff --git a/src/components/insights/coach-panel/copy-table-button.tsx b/src/components/insights/coach-panel/copy-table-button.tsx index da1995278..4320cfa5e 100644 --- a/src/components/insights/coach-panel/copy-table-button.tsx +++ b/src/components/insights/coach-panel/copy-table-button.tsx @@ -11,7 +11,6 @@ */ import { useCallback, useEffect, useRef, useState } from "react"; import { Check, Copy } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { diff --git a/src/components/insights/coach-panel/memory-note.tsx b/src/components/insights/coach-panel/memory-note.tsx index 24b04f4b0..e07cc3a78 100644 --- a/src/components/insights/coach-panel/memory-note.tsx +++ b/src/components/insights/coach-panel/memory-note.tsx @@ -16,7 +16,6 @@ * no line behind. */ import { useState } from "react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/insights/coach-panel/reminder-suggestion-card.tsx b/src/components/insights/coach-panel/reminder-suggestion-card.tsx index 476c6277a..9344c5868 100644 --- a/src/components/insights/coach-panel/reminder-suggestion-card.tsx +++ b/src/components/insights/coach-panel/reminder-suggestion-card.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { BellPlus, Check, Loader2 } from "lucide-react"; diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx index a55fbbd20..f53812d38 100644 --- a/src/components/insights/coach-panel/sources-rail.tsx +++ b/src/components/insights/coach-panel/sources-rail.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { Eye, Loader2, Target } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Label } from "@/components/ui/label"; diff --git a/src/components/insights/coach-panel/suggested-action-card.tsx b/src/components/insights/coach-panel/suggested-action-card.tsx index 5ba5cf205..9e528a079 100644 --- a/src/components/insights/coach-panel/suggested-action-card.tsx +++ b/src/components/insights/coach-panel/suggested-action-card.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Check, Loader2, Sparkles } from "lucide-react"; diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index c0c77d30a..cedd31787 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -15,7 +15,6 @@ import { useQueryClient, } from "@tanstack/react-query"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import type { diff --git a/src/components/insights/pattern-dismiss-action.tsx b/src/components/insights/pattern-dismiss-action.tsx index 9b00478ab..88075d434 100644 --- a/src/components/insights/pattern-dismiss-action.tsx +++ b/src/components/insights/pattern-dismiss-action.tsx @@ -3,7 +3,6 @@ import { QueryClientContext, type QueryClient } from "@tanstack/react-query"; import { EyeOff, RotateCcw, X } from "lucide-react"; import { useContext, useReducer, useState } from "react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/labs/ocr-review-dialog.tsx b/src/components/labs/ocr-review-dialog.tsx index df911daac..b88947659 100644 --- a/src/components/labs/ocr-review-dialog.tsx +++ b/src/components/labs/ocr-review-dialog.tsx @@ -13,7 +13,6 @@ import { DocumentReadingConsentPrompt } from "@/components/ai/document-reading-c import { useEffect, useId, useMemo, useRef, useState } from "react"; import { AlertCircle, Loader2, ScanLine, Upload } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; diff --git a/src/components/medications/detail/efficacy/efficacy-tab.tsx b/src/components/medications/detail/efficacy/efficacy-tab.tsx index cde93df12..a6a7fbcdb 100644 --- a/src/components/medications/detail/efficacy/efficacy-tab.tsx +++ b/src/components/medications/detail/efficacy/efficacy-tab.tsx @@ -13,7 +13,6 @@ import { useMemo, useState } from "react"; import dynamic from "next/dynamic"; import { Activity, TrendingUp } from "lucide-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; diff --git a/src/components/medications/glp1-medication-card.tsx b/src/components/medications/glp1-medication-card.tsx index 06e430cb8..cf4068cdf 100644 --- a/src/components/medications/glp1-medication-card.tsx +++ b/src/components/medications/glp1-medication-card.tsx @@ -6,7 +6,6 @@ import { } from "@/components/medications/course-fields"; import { useEffect, useReducer, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { MedicationCardMenu } from "@/components/medications/medication-card-menu"; diff --git a/src/components/medications/log-intake-dialog.tsx b/src/components/medications/log-intake-dialog.tsx index bcc7d306e..e985ab0b2 100644 --- a/src/components/medications/log-intake-dialog.tsx +++ b/src/components/medications/log-intake-dialog.tsx @@ -23,7 +23,6 @@ import { useActiveRecordName } from "@/hooks/use-record-capabilities"; import { useId, useMemo, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/medications/medication-card.tsx b/src/components/medications/medication-card.tsx index 07e73bbf8..b7c5a2d55 100644 --- a/src/components/medications/medication-card.tsx +++ b/src/components/medications/medication-card.tsx @@ -6,7 +6,6 @@ import { } from "@/components/medications/course-fields"; import { useState, useEffect, useReducer } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { useMedicationIntake } from "@/components/medications/use-medication-intake"; import { MedicationCardMenu } from "@/components/medications/medication-card-menu"; diff --git a/src/components/medications/sections/courses-section.tsx b/src/components/medications/sections/courses-section.tsx index 98886eff9..b2edada7f 100644 --- a/src/components/medications/sections/courses-section.tsx +++ b/src/components/medications/sections/courses-section.tsx @@ -14,7 +14,6 @@ import { useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index a2554c2fe..ad04465a3 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -8,7 +8,6 @@ import { useMutation, useQueryClient, } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2, Plus, Trash2 } from "lucide-react"; diff --git a/src/components/onboarding/confirm-screen.tsx b/src/components/onboarding/confirm-screen.tsx index fc1012f26..8f0ba95ef 100644 --- a/src/components/onboarding/confirm-screen.tsx +++ b/src/components/onboarding/confirm-screen.tsx @@ -3,7 +3,6 @@ import { useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { BaselineForm } from "@/components/onboarding/baseline-form"; diff --git a/src/components/onboarding/done-screen.tsx b/src/components/onboarding/done-screen.tsx index 58aab06f1..3cb5432ba 100644 --- a/src/components/onboarding/done-screen.tsx +++ b/src/components/onboarding/done-screen.tsx @@ -5,7 +5,6 @@ import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { CheckCircle2, diff --git a/src/components/onboarding/first-result-screen.tsx b/src/components/onboarding/first-result-screen.tsx index 86b3f2300..f5d828042 100644 --- a/src/components/onboarding/first-result-screen.tsx +++ b/src/components/onboarding/first-result-screen.tsx @@ -4,7 +4,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Loader2 } from "lucide-react"; diff --git a/src/components/onboarding/question-screen.tsx b/src/components/onboarding/question-screen.tsx index 37577e5ae..244b2bc4d 100644 --- a/src/components/onboarding/question-screen.tsx +++ b/src/components/onboarding/question-screen.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Activity, diff --git a/src/components/onboarding/units-screen.tsx b/src/components/onboarding/units-screen.tsx index 1eb517c62..a1f10d5d0 100644 --- a/src/components/onboarding/units-screen.tsx +++ b/src/components/onboarding/units-screen.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { ChoiceChip } from "@/components/onboarding/choice-chip"; diff --git a/src/components/onboarding/welcome-screen.tsx b/src/components/onboarding/welcome-screen.tsx index 1e62c48ee..1a95aaf4e 100644 --- a/src/components/onboarding/welcome-screen.tsx +++ b/src/components/onboarding/welcome-screen.tsx @@ -4,7 +4,6 @@ import { useId, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useMutation } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/practitioners/practitioner-list.tsx b/src/components/practitioners/practitioner-list.tsx index b3232114f..9dfd0f535 100644 --- a/src/components/practitioners/practitioner-list.tsx +++ b/src/components/practitioners/practitioner-list.tsx @@ -13,7 +13,6 @@ */ import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react"; import { useMemo, useState } from "react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { diff --git a/src/components/practitioners/practitioner-sheet.tsx b/src/components/practitioners/practitioner-sheet.tsx index 53e5394d5..f9654f691 100644 --- a/src/components/practitioners/practitioner-sheet.tsx +++ b/src/components/practitioners/practitioner-sheet.tsx @@ -13,7 +13,6 @@ * created back to whoever opened it. */ import { useState } from "react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/providers.tsx b/src/components/providers.tsx index 5b10ecbde..947ac9cbf 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -17,7 +17,6 @@ import { } from "react"; import { I18nProvider, useTranslations } from "@/lib/i18n/context"; import type { Locale } from "@/lib/i18n/config"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Toaster } from "@/components/ui/sonner"; import { applyThemeColor } from "@/lib/pwa/theme-color"; diff --git a/src/components/records/ai-profile-inclusion-manager.tsx b/src/components/records/ai-profile-inclusion-manager.tsx index 9ad51e11d..176e264bf 100644 --- a/src/components/records/ai-profile-inclusion-manager.tsx +++ b/src/components/records/ai-profile-inclusion-manager.tsx @@ -3,7 +3,6 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/records/emergency-profile-manager.tsx b/src/components/records/emergency-profile-manager.tsx index c8223954d..8d7716467 100644 --- a/src/components/records/emergency-profile-manager.tsx +++ b/src/components/records/emergency-profile-manager.tsx @@ -3,7 +3,6 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2 } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/records/health-profile-facts-manager.tsx b/src/components/records/health-profile-facts-manager.tsx index 4e05393a3..0d405ad34 100644 --- a/src/components/records/health-profile-facts-manager.tsx +++ b/src/components/records/health-profile-facts-manager.tsx @@ -3,7 +3,6 @@ import { useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, Trash2 } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/settings/access/managed-profile-handover.tsx b/src/components/settings/access/managed-profile-handover.tsx index d6fc0efc3..c5a79fcce 100644 --- a/src/components/settings/access/managed-profile-handover.tsx +++ b/src/components/settings/access/managed-profile-handover.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { Check, Copy, Loader2 } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; diff --git a/src/components/settings/account-section/setup-card.tsx b/src/components/settings/account-section/setup-card.tsx index 50e4f6f41..768fff45f 100644 --- a/src/components/settings/account-section/setup-card.tsx +++ b/src/components/settings/account-section/setup-card.tsx @@ -1,7 +1,6 @@ "use client"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { ListChecks, Loader2 } from "lucide-react"; diff --git a/src/components/settings/ai/runtime-actions-row.tsx b/src/components/settings/ai/runtime-actions-row.tsx index c12d86e85..79a2b24f4 100644 --- a/src/components/settings/ai/runtime-actions-row.tsx +++ b/src/components/settings/ai/runtime-actions-row.tsx @@ -6,7 +6,6 @@ import { useState, type ReactNode } from "react"; import { Activity, Loader2, RefreshCw, Sparkles } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/vaccinations/booster-mint-prompt.tsx b/src/components/vaccinations/booster-mint-prompt.tsx index ff7488a06..49707c3ef 100644 --- a/src/components/vaccinations/booster-mint-prompt.tsx +++ b/src/components/vaccinations/booster-mint-prompt.tsx @@ -17,7 +17,6 @@ * already closed by the time this appears. */ import { useState } from "react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; diff --git a/src/components/vaccinations/custom-vaccine-sheet.tsx b/src/components/vaccinations/custom-vaccine-sheet.tsx index 7431a0add..2dc6d4e38 100644 --- a/src/components/vaccinations/custom-vaccine-sheet.tsx +++ b/src/components/vaccinations/custom-vaccine-sheet.tsx @@ -18,7 +18,6 @@ */ import { useState } from "react"; import { Trash2 } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { diff --git a/src/components/vaccinations/vaccination-document-suggestion.tsx b/src/components/vaccinations/vaccination-document-suggestion.tsx index 319d19b0a..53a8d4590 100644 --- a/src/components/vaccinations/vaccination-document-suggestion.tsx +++ b/src/components/vaccinations/vaccination-document-suggestion.tsx @@ -18,7 +18,6 @@ */ import { useState } from "react"; import { Check, Syringe } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { Button } from "@/components/ui/button"; diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index 40dbbd401..8597b1b84 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -20,7 +20,6 @@ */ import { useState } from "react"; import { Trash2 } from "lucide-react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { diff --git a/src/hooks/use-account-switch.ts b/src/hooks/use-account-switch.ts index 924073df2..c9e1d80ff 100644 --- a/src/hooks/use-account-switch.ts +++ b/src/hooks/use-account-switch.ts @@ -34,7 +34,6 @@ import { type QueryClient, } from "@tanstack/react-query"; import { useCallback } from "react"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index fb17c7f50..22a0ce25b 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -7,7 +7,6 @@ import { type QueryClient, } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiFetchRaw, ApiError } from "@/lib/api/api-fetch"; diff --git a/src/lib/queries/use-documents-layout.ts b/src/lib/queries/use-documents-layout.ts index 579bf5ec3..638ba87a9 100644 --- a/src/lib/queries/use-documents-layout.ts +++ b/src/lib/queries/use-documents-layout.ts @@ -5,7 +5,6 @@ import { useQueryClient, type QueryClient, } from "@tanstack/react-query"; -import { toast } from "sonner"; import { showErrorToast } from "@/lib/errors/error-toast"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; From 703689069f53e30e1a1f1af82070ee7ead91b1d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:37:18 +0200 Subject: [PATCH 07/10] docs(support): document error ids, the support info and the support report, and ask for them in bug reports --- .github/ISSUE_TEMPLATE/bug_report.yml | 22 +++++- docs/README.md | 5 +- docs/self-hosting/getting-started.md | 5 ++ docs/self-hosting/support.md | 105 ++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 docs/self-hosting/support.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 919b5a557..d3202921c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -15,7 +15,7 @@ body: id: version attributes: label: HealthLog version - description: Output of `/api/version` or the tag of the GHCR image you're running. + description: Settings, About shows it, as does `/api/version`, or the tag of the GHCR image you're running. placeholder: "1.4.19" validations: required: true @@ -55,11 +55,27 @@ body: 3. See error validations: required: true + - type: input + id: error-id + attributes: + label: Error ID + description: If the failure showed an error ID (eight characters, e.g. `3f9a2c1b`), paste it here. The operator finds the event with it under Admin, App logs. + placeholder: "3f9a2c1b" + - type: textarea + id: support-info + attributes: + label: Support info / support report + description: | + Everyone: Settings, About, "Copy support info". + Operators: Admin, System status, "Support report", then Copy. + Both are redacted (no names, emails, values or tokens); read them before you paste. + See https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/support.md + render: text - type: textarea id: logs attributes: - label: Relevant logs / screenshots - description: Paste any relevant log output (redact secrets) or attach screenshots. Wide-event logs from `/admin/app-logs` are useful. + label: Screenshots / other logs + description: Attach screenshots, or paste other log output if you have it. Redact secrets and personal data first. render: shell - type: input id: browser diff --git a/docs/README.md b/docs/README.md index dfe00df1a..34465adf4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,7 +33,10 @@ notes, and machine-readable specs that ship alongside the source: three access levels, the eight sections a grant can be narrowed to, managed profiles for somebody with no login and the guardians who look after them, what revocation does and does not undo, and which trust - properties of the instance are unchanged by any of it. + properties of the instance are unchanged by any of it. And + [`self-hosting/support.md`](./self-hosting/support.md) — reporting a + problem: error ids, the support info every account can copy, the + operator's redacted support report, and finding an error in the admin log. - [`using/`](./using/) — notes on using the app that sit close to the code. [`using/search.md`](./using/search.md) covers the command palette's record search: opening it, what it finds, how matching works, and its diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index fac39c9b6..aa6ed6ba4 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -218,6 +218,11 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build ## Troubleshooting +- **Where to look.** For a bug report, copy the support report (Admin, + System status) and the support info (Settings, About), and note the error + id the failure showed. Admin, App logs finds the event behind an error id. + What each contains, and what it never contains, is in + [`support.md`](./support.md). - **`app` container exits with a Prisma migration error.** The Postgres healthcheck races the migration on slow disks. Run `docker compose restart app` once the database has stabilised — diff --git a/docs/self-hosting/support.md b/docs/self-hosting/support.md new file mode 100644 index 000000000..d9e8af841 --- /dev/null +++ b/docs/self-hosting/support.md @@ -0,0 +1,105 @@ +# Reporting a problem + +HealthLog does not send anything home, and nobody but the operator can read +an instance's logs. So a bug report has to carry what is needed to find the +fault. Two texts do that, and both are redacted before you see them. + +| Who | Where | What it is | +| -------- | ---------------------------------------- | ------------------------------------------ | +| Everyone | Settings, About, **Copy support info** | Your app version, settings and connections | +| Operator | Admin, System status, **Support report** | The whole instance, accounts as numbers | + +Add the **error id** of the failure if you have one, and paste both texts into +the [bug report](https://github.com/MBombeck/HealthLog/issues/new?template=bug_report.yml). +Read them before you post. They are built to contain nothing personal, but +you are the one publishing them. + +## Error ids + +When the server fails (a 5xx answer), the message shows **Error ID** and +eight characters, for example `3f9a2c1b`, with a copy button that copies the +full id. A page that fails to render shows one too when the server has one +to give: the request id of the failed call, or the digest the server printed +beside the stack in its log. + +The id is the request id of the failing call. Every error answer of the API +carries it in `meta.requestId` and in the `x-request-id` header. An incoming +`x-request-id` is only adopted when it consists of letters, digits and hyphens +(8 to 64 characters); anything else is replaced with a fresh id. + +The operator finds the event under **Admin, App logs** by typing the id (or +its first 8 characters) into **Error ID**. A search by id reads the second log +buffer, which keeps the last 1,000 warnings and errors of the process. Ordinary +traffic cannot push them out, so an error stays findable for much longer than +in the main buffer of 500 events. Both buffers live in memory: a restart +empties them, and a split `web` / `worker` deployment has one per process. + +The same search works on the API: + +``` +GET /api/admin/app-logs?requestId=3f9a2c1b&ring=problems +``` + +A 4xx answer (a refused input, a missing permission) shows no id. Its message +says what to change. + +## Support info (everyone) + +**Settings, About, Copy support info** puts together, in your browser: + +- server version and build, and your browser; +- language, time zone and the modules you have switched on; +- the AI state: whether a provider is set up, your usage level, and which AI + features are available and why not; +- for each connected source (Withings, Oura, and so on), its health state, + reason, last success, newest data and the last recorded sync attempts; +- the last five error ids of this browser session. + +It never contains your username, email address, name, server address, +tokens, recorded values or record ids. Nothing is sent anywhere; the text +goes to your clipboard. + +## Support report (operator) + +**Admin, System status, Support report** creates a text with **Copy** and +**Download**. It is created when you press a button, not on page load, and +shown above the buttons so you can read it first. It contains: + +- **Build:** version, build SHA, build time, Node, process type, heap limit, + uptime. +- **Database:** PostgreSQL version, server time zone, size, last applied + migration, unfinished migrations. +- **Configuration**, as yes or no only: secure cookie flag, OIDC, OIDC only, + Loki, GlitchTip, off-host backup, APNs, Web Push, SMTP, GeoLite2, the number + of `NOTIFICATION_PRIVATE_ORIGINS` entries, operator AI key, central Codex, + the AI provider types accounts chose, demo mode. +- **Jobs:** whether the worker runs in this process and when it last + reported, the newest finished job, and every queue with failures in the + last 72 hours with an error class (`timeout`, `TypeError`, `P2002`, + `ECONNREFUSED`, an HTTP status), never the message. +- **Connections:** per source, how many accounts are in each health state and + reason, which accounts are not `ok`, and open watchdog incidents. +- **AI:** the usage level default and ceiling, accounts per usage level, and + the provider ledger per provider type (working, failing, credential + refused, paused). +- **Notifications:** per channel, delivered, failed and skipped in the last 7 + days, with the most frequent failure reasons. +- **Backups:** newest in-database copy, the last weekly run, off-host + freshness, and the last restore drill. +- **Recent warnings and errors:** the 20 most frequent action names in the + warning and error buffer of this process, with count, last seen and error + class. + +Accounts appear as `Account 1`, `Account 2` and so on, numbered by creation. +The report never contains an email address, a username, an account id, a +recorded value, a token, or the host name of another service. As a second +layer, the finished text is scrubbed for the instance's own usernames, +emails, names and ids, for tokens, URLs, email addresses, ids, IP addresses +and host names before it is shown. + +## Logs beyond the report + +`docker compose logs app` prints every wide event as one JSON line; +`LOG_LEVEL=debug` adds debug events. With `LOKI_ENDPOINT` set the same events +go to Loki, and GlitchTip (Admin, General) receives unexpected server errors. +Each line carries `request_id`, so an error id finds its line there too. From 0ec7b80a9e6a516daa0a3f63f9c9f6288a93957f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:42:05 +0200 Subject: [PATCH 08/10] style(api): format the request id envelope test --- src/lib/__tests__/api-handler-error-request-id.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/__tests__/api-handler-error-request-id.test.ts b/src/lib/__tests__/api-handler-error-request-id.test.ts index ca32c21b0..57688c550 100644 --- a/src/lib/__tests__/api-handler-error-request-id.test.ts +++ b/src/lib/__tests__/api-handler-error-request-id.test.ts @@ -30,9 +30,7 @@ function call( headers: Record = {}, ) { const route = apiHandler(async (_request: NextRequest) => handler()); - return route( - new NextRequest("http://localhost/api/probe", { headers }), - ); + return route(new NextRequest("http://localhost/api/probe", { headers })); } describe("meta.requestId on every error envelope", () => { From 24c74bd3ebd4d9c07d37258c673a87a7cd78c596 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:54:17 +0200 Subject: [PATCH 09/10] test(integration): keep the requestId out of the mcp refusal it does not carry --- tests/integration/encryption-key-check.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/encryption-key-check.test.ts b/tests/integration/encryption-key-check.test.ts index 51daa67cb..eae7d1f87 100644 --- a/tests/integration/encryption-key-check.test.ts +++ b/tests/integration/encryption-key-check.test.ts @@ -628,7 +628,6 @@ describe("routes outside apiHandler refuse while the key does not match", () => ); expect(res.status).toBe(503); expect((await res.json()).meta).toEqual({ - requestId: expect.any(String), errorCode: "encryption.key_mismatch", }); }); From 9c01e179ca4bfe89c732f33bd2a135f5c2034da3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 22:55:03 +0200 Subject: [PATCH 10/10] test(integration): compare the oidc refusals without their request ids --- tests/integration/oidc-pending-link.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/integration/oidc-pending-link.test.ts b/tests/integration/oidc-pending-link.test.ts index 19b97c781..4bc34c225 100644 --- a/tests/integration/oidc-pending-link.test.ts +++ b/tests/integration/oidc-pending-link.test.ts @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; +import { bodyWithoutRequestId } from "@/__tests__/helpers/error-envelope"; process.env.API_TOKEN_HMAC_KEY ??= "test-hmac-key-oidc-pending-link-32-bytes-minimum-1234567890"; @@ -186,7 +187,9 @@ describe("under an SSO-only policy", () => { const wrong = await passwordLogin("acct-b", "not the password"); expect(right.status).toBe(403); expect(wrong.status).toBe(403); - expect(await wrong.json()).toEqual(await right.json()); + expect(await bodyWithoutRequestId(wrong)).toBe( + await bodyWithoutRequestId(right), + ); }); it("keeps it closed for any other account", async () => {