Merge branch 'v143/support' into release/v1.43.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:01:52 +02:00
commit 5dab12472f
177 files changed
+4435 -408

No files matched your search

+19 -3
View File
@@ -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
+4 -1
View File
@@ -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
+5
View File
@@ -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 —
+105
View File
@@ -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.
+24 -4
View File
@@ -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",
+24 -4
View File
@@ -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",
+24 -4
View File
@@ -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",
+24 -4
View File
@@ -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",
+24 -4
View File
@@ -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",
+24 -4
View File
@@ -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": "업적",
+24 -4
View File
@@ -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",
@@ -28,7 +28,6 @@ const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
"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<Record<string, readonly string[]>> = {
};
/** 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"] })
@@ -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<Record<string, string>> = {
// 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 !(
/<ErrorDetails\b/.test(source) ||
/<ErrorIdLine\b/.test(source) ||
/\berrorIdOf\s*\(/.test(source)
);
});
expect(missing).toEqual([]);
for (const rel of Object.keys(BOUNDARY_EXEMPT)) {
expect(boundaries, `${rel} is exempt but no longer exists`).toContain(
rel,
);
}
});
it("shows the id in the shared boundary panel", () => {
const source = read("components/error-details.tsx");
expect(source).toMatch(/<ErrorIdLine\b/);
expect(source).toMatch(/\berrorIdOf\s*\(/);
});
});
+35
View File
@@ -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<string, unknown>) };
const meta = record.meta;
if (typeof meta === "object" && meta !== null && !Array.isArray(meta)) {
const rest = { ...(meta as Record<string, unknown>) };
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<string> {
return withoutRequestId(await res.text());
}
+3
View File
@@ -36,6 +36,7 @@ import { ModuleAvailabilitySection } from "@/components/admin/module-availabilit
import { RemindersSection } from "@/components/admin/reminders-section";
import { ServicesSection } from "@/components/admin/services-section";
import { SystemStatusSection } from "@/components/admin/system-status-section";
import { SupportReportCard } from "@/components/admin/support-report-card";
import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
import { Skeleton } from "@/components/ui/skeleton";
import { MeasurementMaintenanceCard } from "@/components/admin/measurement-maintenance-card";
@@ -69,6 +70,8 @@ export function AdminSectionRenderer({
>
<SectionFrame>
<SystemStatusSection />
{/* v1.43 — the redacted report for a bug report. */}
<SupportReportCard />
{/* v1.42 — the table maintenance after the tombstone purge. */}
<MeasurementMaintenanceCard />
</SectionFrame>
@@ -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);
});
});
+26 -2
View File
@@ -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,
},
});
});
@@ -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<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
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());
}
});
});
+21 -2
View File
@@ -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 });
});
@@ -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);
@@ -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",
@@ -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",
@@ -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",
@@ -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();
@@ -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",
});
@@ -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 () => {
@@ -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" }),
);
+51
View File
@@ -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"}
</pre>
{errorId && (
<p
data-slot="error-id"
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontSize: 13,
color: "#d0d0d0",
marginBottom: 16,
}}
>
<span>Fehler-ID / Error ID</span>
<code style={{ color: "#f8f8f2" }}>{shortErrorId(errorId)}</code>
<button
type="button"
onClick={handleCopyId}
aria-label="Copy error ID"
style={{
background: "transparent",
color: "#f8f8f2",
border: "1px solid #44475a",
borderRadius: 6,
minHeight: 44,
padding: "6px 12px",
cursor: "pointer",
fontSize: 13,
}}
>
Copy
</button>
</p>
)}
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<button
onClick={reset}
+3 -1
View File
@@ -4,6 +4,7 @@ import { useMemo } from "react";
import { StatTile } from "@/components/admin/_stat-tile";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
@@ -297,10 +298,11 @@ export function useUpdateSettings() {
toast.success(t("common.saved"));
},
onError: (err) => {
toast.error(
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
},
});
@@ -4,6 +4,7 @@ import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, KeyRound, Loader2 } 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";
@@ -89,8 +90,9 @@ export function AiServerKeySection() {
toast.success(t("admin.aiServerKey.saved"));
},
onError: (error: unknown) => {
toast.error(
showErrorToast(
error instanceof Error ? error.message : t("admin.aiServerKey.error"),
{ cause: error },
);
},
});
@@ -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<LogLevel | "__all__">("__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<AppLogsResponse>(
`/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). */}
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-[repeat(4,minmax(0,1fr))_auto]">
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-[repeat(5,minmax(0,1fr))_auto]">
<Input
type="search"
placeholder={t("admin.section.app-logs.filterErrorId")}
value={requestId}
maxLength={64}
onChange={(e) => {
setRequestId(e.target.value);
setPage(1);
}}
aria-label={t("admin.section.app-logs.filterErrorId")}
data-slot="app-logs-error-id-filter"
/>
<Input
type="search"
placeholder={t("admin.section.app-logs.filterTraceId")}
+3 -1
View File
@@ -3,6 +3,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -104,10 +105,11 @@ function useUpdateAssistantFlags() {
toast.success(t("common.saved"));
},
onError: (err) => {
toast.error(
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
client.invalidateQueries({
queryKey: queryKeys.adminAssistantFlags(),
+13 -6
View File
@@ -24,6 +24,7 @@ import {
} from "lucide-react";
import { useRef, useState, type ChangeEvent } from "react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
AlertDialog,
AlertDialogAction,
@@ -864,10 +865,11 @@ export function BackupsSection() {
}, 2000);
},
onError: (err) => {
toast.error(
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.runFailed"),
{ cause: err },
);
},
});
@@ -923,10 +925,11 @@ export function BackupsSection() {
},
onError: (err) => {
if (err instanceof ReproofCancelledError) return;
toast.error(
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.uploadFailed"),
{ cause: err },
);
},
onSettled: () => {
@@ -983,7 +986,7 @@ export function BackupsSection() {
void queryClient.invalidateQueries();
} else if (job.status === "failed") {
setRefusedSections(missingSectionsOf(job.failure));
toast.error(t("admin.section.backups.restoreFailed"));
showErrorToast(t("admin.section.backups.restoreFailed"));
}
}
}
@@ -1061,17 +1064,20 @@ export function BackupsSection() {
err instanceof ApiError &&
err.meta?.errorCode === "backup.restore.active"
) {
toast.error(t("admin.section.backups.restoreActive"));
showErrorToast(t("admin.section.backups.restoreActive"), {
cause: err,
});
void queryClient.invalidateQueries({
queryKey: queryKeys.adminBackupRestores(),
});
return;
}
setRefusedSections([]);
toast.error(
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.restoreFailed"),
{ cause: err },
);
},
});
@@ -1109,10 +1115,11 @@ export function BackupsSection() {
toast.success(t("admin.section.backups.downloadStarted"));
} catch (err) {
if (err instanceof ReproofCancelledError) return;
toast.error(
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.section.backups.downloadFailed"),
{ cause: err },
);
} finally {
setDownloadingId(null);
@@ -19,6 +19,7 @@ import { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, ShieldAlert, Sparkles, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Badge } from "@/components/ui/badge";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
@@ -75,8 +76,9 @@ export function CentralCodexSection() {
intervalSeconds: json.data.intervalSeconds,
});
} catch (err) {
toast.error(
showErrorToast(
err instanceof Error ? err.message : t("admin.centralCodex.error"),
{ cause: err },
);
setDevicePolling(false);
}
@@ -110,8 +112,9 @@ export function CentralCodexSection() {
if (!cancelled) setTimeout(tick, intervalMs);
} catch (err) {
if (cancelled) return;
toast.error(
showErrorToast(
err instanceof Error ? err.message : t("admin.centralCodex.error"),
{ cause: err },
);
setDeviceCode(null);
setDevicePolling(false);
@@ -142,8 +145,9 @@ export function CentralCodexSection() {
});
void invalidateKeys(queryClient, aiInputDependentKeys);
} catch (err) {
toast.error(
showErrorToast(
err instanceof Error ? err.message : t("admin.centralCodex.error"),
{ cause: err },
);
} finally {
setDisconnecting(false);
+5 -1
View File
@@ -23,6 +23,7 @@ import {
ShieldCheck,
} from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
AlertDialog,
AlertDialogAction,
@@ -125,7 +126,10 @@ export function EncryptionSection() {
});
},
onError: (err: Error) => {
toast.error(err.message || t("admin.section.encryption.rotateFailed"));
showErrorToast(
err.message || t("admin.section.encryption.rotateFailed"),
{ cause: err },
);
},
});
+3 -1
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, 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";
@@ -111,10 +112,11 @@ export function GlitchtipSection() {
});
},
onError: (error) => {
toast.error(
showErrorToast(
error instanceof Error
? error.message
: t("admin.monitoringTestFailed"),
{ cause: error },
);
void queryClient.invalidateQueries({
queryKey: queryKeys.adminSettings(),
@@ -30,6 +30,7 @@ import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Ban, Check, Copy, Loader2, Plus, Ticket, Users } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
AlertDialog,
@@ -221,8 +222,8 @@ export function InviteTokensSection() {
toast.success(t("admin.invites.createdToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.adminInvites() });
},
onError: () => {
toast.error(t("admin.invites.createError"));
onError: (failure) => {
showErrorToast(t("admin.invites.createError"), { cause: failure });
},
});
@@ -234,8 +235,8 @@ export function InviteTokensSection() {
toast.success(t("admin.invites.revokedToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.adminInvites() });
},
onError: () => {
toast.error(t("admin.invites.revokeError"));
onError: (failure) => {
showErrorToast(t("admin.invites.revokeError"), { cause: failure });
},
});
@@ -245,8 +246,8 @@ export function InviteTokensSection() {
setCopied(true);
toast.success(t("admin.invites.copiedToast"));
window.setTimeout(() => setCopied(false), 2_000);
} catch {
toast.error(t("admin.invites.copyError"));
} catch (failure) {
showErrorToast(t("admin.invites.copyError"), { cause: failure });
}
}
+8 -3
View File
@@ -15,6 +15,7 @@ import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { KeyRound, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -106,11 +107,13 @@ export function KeyBackupCard() {
},
onError: async (err: Error) => {
if (err instanceof ApiError && err.status === 409) {
toast.error(t("admin.keyBackup.stale"));
showErrorToast(t("admin.keyBackup.stale"), { cause: err });
await invalidate();
return;
}
toast.error(err.message || t("admin.keyBackup.confirmFailed"));
showErrorToast(err.message || t("admin.keyBackup.confirmFailed"), {
cause: err,
});
},
});
@@ -133,7 +136,9 @@ export function KeyBackupCard() {
},
onError: (err: Error) => {
setCheckResult(null);
toast.error(err.message || t("admin.keyBackup.checkFailed"));
showErrorToast(err.message || t("admin.keyBackup.checkFailed"), {
cause: err,
});
},
});
@@ -3,6 +3,7 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
Check,
CheckCircle2,
@@ -112,8 +113,8 @@ export function LoginOverviewSection() {
window.setTimeout(() => {
setCopiedIp((current) => (current === id ? null : current));
}, 2_000);
} catch {
toast.error(t("admin.copyFailed"));
} catch (failure) {
showErrorToast(t("admin.copyFailed"), { cause: failure });
}
}
@@ -14,6 +14,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Database, Loader2, Wrench } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import { StatTile } from "@/components/admin/_stat-tile";
import { SettingsCardActions } from "@/components/settings/_card-actions";
@@ -92,7 +93,10 @@ export function MeasurementMaintenanceCard() {
});
},
onError: (err: Error) => {
toast.error(err.message || t("admin.section.maintenance.startFailed"));
showErrorToast(
err.message || t("admin.section.maintenance.startFailed"),
{ cause: err },
);
},
});
@@ -4,6 +4,7 @@ import Link from "next/link";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { ToggleLeft } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -74,10 +75,11 @@ function useUpdateModuleAvailability() {
toast.success(t("common.saved"));
},
onError: (err) => {
toast.error(
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.modules.saveError"),
{ cause: err },
);
client.invalidateQueries({
queryKey: queryKeys.adminModuleAvailability(),
+6 -3
View File
@@ -11,6 +11,7 @@ import {
XCircle,
} 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";
@@ -144,7 +145,7 @@ export function RemindersSection() {
onSuccess: (data) => {
const failures = describeChannelTestFailures(data?.results ?? [], t);
if (failures.length > 0) {
toast.error(data?.message ?? t("admin.notificationTestFailed"), {
showErrorToast(data?.message ?? t("admin.notificationTestFailed"), {
description: (
<ul className="space-y-0.5">
{failures.map((line) => (
@@ -158,10 +159,11 @@ export function RemindersSection() {
}
},
onError: (error) => {
toast.error(
showErrorToast(
error instanceof Error
? error.message
: t("admin.notificationTestFailed"),
{ cause: error },
);
},
});
@@ -196,8 +198,9 @@ export function RemindersSection() {
toast.success(data?.message ?? t("admin.reminderCheckSuccess"));
},
onError: (error) => {
toast.error(
showErrorToast(
error instanceof Error ? error.message : t("admin.reminderCheckFailed"),
{ cause: error },
);
},
});
@@ -0,0 +1,128 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Copy, Download, LifeBuoy, Loader2 } 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 { apiGet } from "@/lib/api/api-fetch";
import { showErrorToast } from "@/lib/errors/error-toast";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
interface SupportReport {
generatedAt: string;
text: string;
}
/**
* Admin, System status: the redacted support report, with copy and download.
*
* The report is made on request, not on page load: it reads every account's
* connection health, which is work an operator only wants when filing a bug.
* Once made it is shown in full above the actions, so the operator reads what
* they are about to share.
*/
export function SupportReportCard() {
const { t } = useTranslations();
const [copied, setCopied] = useState(false);
const report = useQuery({
queryKey: queryKeys.adminSupportReport(),
queryFn: async () => apiGet<SupportReport>("/api/admin/support-report"),
enabled: false,
staleTime: 0,
gcTime: 5 * 60_000,
});
async function load(): Promise<SupportReport | null> {
const result = await report.refetch();
if (result.error || !result.data) {
showErrorToast(t("admin.supportReport.loadError"), {
cause: result.error,
});
return null;
}
return result.data;
}
async function copy() {
const data = await load();
if (!data) return;
try {
await navigator.clipboard.writeText(data.text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
window.prompt(t("admin.supportReport.copy"), data.text);
}
}
async function download() {
const data = await load();
if (!data) return;
const blob = new Blob([data.text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `healthlog-support-report-${data.generatedAt.slice(0, 10)}.txt`;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
const busy = report.isFetching;
return (
<SettingsCard>
<SettingsCardHeader
icon={LifeBuoy}
title={t("admin.supportReport.title")}
description={t("admin.supportReport.description")}
/>
<p className="text-sm">{t("admin.supportReport.body")}</p>
{report.data && (
<pre
data-slot="support-report-text"
className="bg-muted/50 max-h-80 overflow-auto rounded-lg border p-3 font-mono text-xs whitespace-pre-wrap"
>
{report.data.text}
</pre>
)}
<SettingsCardActions>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => void copy()}
disabled={busy}
aria-live="polite"
>
{busy ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Copy className="size-4" aria-hidden="true" />
)}
{copied
? t("admin.supportReport.copied")
: t("admin.supportReport.copy")}
</Button>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => void download()}
disabled={busy}
>
<Download className="size-4" aria-hidden="true" />
{t("admin.supportReport.download")}
</Button>
</SettingsCardActions>
</SettingsCard>
);
}
+3 -1
View File
@@ -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 },
);
},
});
@@ -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 },
);
},
});
@@ -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);
}
@@ -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()
@@ -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({
+9 -5
View File
@@ -11,7 +11,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 +233,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 +356,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 +377,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 +394,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 }),
});
}
@@ -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 }),
},
);
};
@@ -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 }),
},
)
}
@@ -16,7 +16,7 @@ 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";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
@@ -92,10 +92,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()]);
},
+16 -7
View File
@@ -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;
@@ -33,7 +33,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 +130,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 +205,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,
}),
});
}}
>
+7 -1
View File
@@ -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({
<div className="bg-muted/50 text-muted-foreground rounded-md border p-3 font-mono text-xs break-all">
{error.message || t("common.unknownError")}
</div>
{errorId && <ErrorIdLine id={errorId} />}
<div className="flex flex-wrap items-center gap-2">
{reset && (
<Button onClick={reset} variant="outline">
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useEffect, useState } from "react";
import { Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { recordErrorId, shortErrorId } from "@/lib/errors/error-id";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* "Error ID 3f9a2c1b" with a copy button: the one way the web shows the id of
* a failure, in toasts (`showErrorToast`) and in error boundaries. The first 8
* characters are shown; the button copies the whole id, which the operator's
* log search matches as well as the short one.
*
* Showing an id also records it for the session's support info.
*/
export function ErrorIdLine({
id,
className,
}: {
id: string;
className?: string;
}) {
const { t } = useTranslations();
const [copied, setCopied] = useState(false);
useEffect(() => {
recordErrorId(id);
}, [id]);
async function copy() {
try {
await navigator.clipboard.writeText(id);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
window.prompt(t("common.errorIdCopy"), id);
}
}
return (
<span
data-slot="error-id"
className={cn("inline-flex items-center gap-1.5 text-xs", className)}
>
<span className="text-muted-foreground">{t("common.errorIdLabel")}</span>
<code className="text-foreground font-mono">{shortErrorId(id)}</code>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-8"
onClick={(event) => {
// Inside a toast, a click must not count as a dismiss swipe.
event.stopPropagation();
void copy();
}}
aria-label={
copied ? t("common.errorIdCopied") : t("common.errorIdCopy")
}
title={copied ? t("common.errorIdCopied") : t("common.errorIdCopy")}
>
{copied ? (
<Check className="size-3.5" aria-hidden="true" />
) : (
<Copy className="size-3.5" aria-hidden="true" />
)}
</Button>
</span>
);
}
+3 -1
View File
@@ -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 }),
});
},
},
@@ -8,7 +8,7 @@ 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";
import { useTranslations } from "@/lib/i18n/context";
@@ -335,8 +335,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 +453,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 +561,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 +572,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;
@@ -11,7 +11,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 +61,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],
@@ -16,7 +16,7 @@
* no line behind.
*/
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 +83,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 =
@@ -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,
});
},
});
@@ -2,7 +2,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 +60,8 @@ export function ReminderSuggestionCard({
: "dismissed",
);
},
onError: () => {
toast.error(t("coach.reminderSuggestion.failed"));
onError: (failure) => {
showErrorToast(t("coach.reminderSuggestion.failed"), { cause: failure });
},
});
@@ -2,7 +2,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 +98,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 }),
});
}
@@ -2,7 +2,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 +53,8 @@ export function SuggestedActionCard({
}
setSettled("applied");
},
onError: () => toast.error(t("coach.suggestedAction.failed")),
onError: (failure) =>
showErrorToast(t("coach.suggestedAction.failed"), { cause: failure }),
});
if (settled) {
@@ -15,7 +15,7 @@ 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 {
CoachActivity,
@@ -521,7 +521,7 @@ export function useDeleteCoachConversationWithUndo() {
const failedMessage = t("insights.coach.historyDeleteFailed");
commitRef.current = (id) =>
commitCoachConversationDelete(queryClient, id, owner, () =>
toast.error(failedMessage),
showErrorToast(failedMessage),
);
});
@@ -596,7 +596,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);
@@ -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;
@@ -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));
}
@@ -3,7 +3,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 +138,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 +184,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);
}
+5 -2
View File
@@ -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() {
+3 -1
View File
@@ -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
+5 -3
View File
@@ -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({
+7 -5
View File
@@ -13,7 +13,7 @@ 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";
import { Button } from "@/components/ui/button";
@@ -233,21 +233,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 +297,8 @@ export function OcrReviewDialog({
onCommitted();
handleClose(false);
},
onError: () => toast.error(t("labs.ocr.saveFailed")),
onError: (failure) =>
showErrorToast(t("labs.ocr.saveFailed"), { cause: failure }),
},
);
}
@@ -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 });
},
});
@@ -13,7 +13,7 @@ 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";
import { SegmentedToggle } from "@/components/medications/detail/segmented-toggle";
@@ -434,12 +434,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);
}
@@ -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);
}
@@ -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,
});
}
}
@@ -6,7 +6,7 @@ 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";
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
@@ -321,7 +321,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);
@@ -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);
}
@@ -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);
}
@@ -23,7 +23,7 @@ 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";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
@@ -195,7 +195,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;
@@ -6,7 +6,7 @@ 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";
import { MedicationStateBadges } from "@/components/medications/card-parts/medication-state-badges";
@@ -243,7 +243,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);
@@ -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,
});
}
}
@@ -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 },
);
},
});
@@ -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);
}
@@ -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);
@@ -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);
}
@@ -14,7 +14,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 +222,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);
}
@@ -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);
}
}
@@ -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);
}
@@ -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);
}
@@ -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,
});
}
}
@@ -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);
}
@@ -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);
@@ -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);
}
@@ -8,7 +8,7 @@ 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";
import { Button } from "@/components/ui/button";
@@ -215,8 +215,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,
});
},
});
+3 -2
View File
@@ -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 };
@@ -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 });
}
}
@@ -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 \}\)/,
);
});
});
@@ -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);
}
@@ -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);
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff. Show more