diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8e9102cb7..543f02194 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -306,6 +306,14 @@ components: - data - error type: object + DisableCoachRequest: + description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates. + properties: + disableCoach: + type: boolean + required: + - disableCoach + type: object ErrorEnvelope: additionalProperties: false description: "Standard error response: data is null, error is human prose." @@ -394,6 +402,29 @@ components: - data - error type: object + GetDisableCoachResponse: + additionalProperties: false + properties: + data: + additionalProperties: false + properties: + disableCoach: + type: boolean + required: + - disableCoach + type: object + error: + type: "null" + meta: + additionalProperties: false + properties: + requestId: + type: string + type: object + required: + - data + - error + type: object GetSourcePriorityResponse: additionalProperties: false properties: @@ -1051,6 +1082,29 @@ components: - data - error type: object + PatchDisableCoachResponse: + additionalProperties: false + properties: + data: + additionalProperties: false + properties: + disableCoach: + type: boolean + required: + - disableCoach + type: object + error: + type: "null" + meta: + additionalProperties: false + properties: + requestId: + type: string + type: object + required: + - data + - error + type: object PutCoachPrefsResponse: additionalProperties: false properties: @@ -1921,6 +1975,45 @@ paths: summary: Replace per-user Coach prompt-tuning preferences tags: - Auth + /api/auth/me/disable-coach: + get: + description: Returns the user's per-account Coach opt-out flag. Default `false` (Coach visible). Powers the Settings → + Insights "Hide Coach" Switch and the layout FAB short-circuit. + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/GetDisableCoachResponse" + description: Resolved flag. + "401": *a1 + "422": *a2 + "429": *a3 + summary: Read per-user Coach opt-out flag + tags: + - Auth + patch: + description: Flips the per-account Coach opt-out flag. Idempotent — the DB write fires even when the value matches so + the audit-log row mirrors the API call. Rate-limit 60/min per user. + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/DisableCoachRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PatchDisableCoachResponse" + description: Resolved next-state echoed back for optimistic-update consumers. + "401": *a1 + "422": *a2 + "429": *a3 + summary: Toggle per-user Coach opt-out flag + tags: + - Auth /api/auth/me/source-priority: get: description: Returns the per-metric-class source priority used by the analytics aggregator. Missing keys fall back to diff --git a/docs/ops/attempt-number-semantics.md b/docs/ops/attempt-number-semantics.md new file mode 100644 index 000000000..ab6b3c062 --- /dev/null +++ b/docs/ops/attempt-number-semantics.md @@ -0,0 +1,52 @@ +# `attemptNumber` audit-row vs alert-signal semantics + +Two numbers describe a failing integration sync, and they can diverge. +On-call operators reading an audit-log row alongside an admin alert +should expect the alert number to be smaller than (or equal to) the +audit number, never the other way around. + +## The two numbers + +`integrations.sync.failed` audit rows write +`attemptNumber = transient + reauth_required + persistent` — the **sum** +of all three failure buckets. This is the historical "consecutive +failures" running total carried by the legacy +`IntegrationStatus.consecutiveFailures` column (dropped in migration +`0076`, v1.4.47 W1). Auditors looking at a single row see one integer +that captures how many failures the bucket ledger has accumulated since +the last success. + +The persistent-failure alert dispatcher in +`src/lib/integrations/status.ts:453` uses `Math.max(...buckets)` — the +**max** across the three buckets. A 3-deep persistent streak pages even +when the transient bucket is empty, and a flapping integration that +ping-pongs between transient and reauth_required never trips a +single-bucket threshold even if its sum looks alarming. + +## When the two diverge + +A typed-failure-streak edge case: integration fails 2× transient, then +2× reauth_required, then 1× persistent. Audit row shows +`attemptNumber: 5`. Alert signal is `max(2, 2, 1) = 2`, which is below +the default threshold of 3, so no page fires. From the audit log this +looks like a sustained 5-failure outage; from the alert dispatcher it +looks like three small bumps that never crossed a single-bucket +ceiling. + +Both readings are correct for their purpose. The audit log records what +happened end-to-end; the alert dispatcher only fires when a single +failure mode persists, because mixed-bucket streaks usually indicate a +transient network blip layered on top of an unrelated reauth prompt — +two problems, neither of which is the "this integration is broken" page +we want to wake someone up for. + +## Runbook implication + +If you see an audit row with `attemptNumber >= threshold` but no alert +was dispatched, check the per-bucket counts on +`IntegrationStatus.failureBuckets` for that user/integration pair. The +sum may be high while every individual bucket sits below the threshold. +This is working as designed; manually investigate only if a single +bucket is at or above the threshold and `alertedAt` is unset (broken +dispatcher) or set within the last 24 hours (rate-limited, see the +`ALERT_REPEAT_WINDOW_MS` constant). diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md new file mode 100644 index 000000000..0ab71cadc --- /dev/null +++ b/docs/ops/deploy.md @@ -0,0 +1,62 @@ +# Deploy ordering + +Coolify handles the standard deploy ordering automatically: the +container image is pulled, `docker-entrypoint.sh` runs +`prisma migrate deploy` against the live database, and only then does +the Next.js process bind to the listener. This page documents the +ordering explicitly for manual-rollback and partial-deploy scenarios +where the operator drives the steps by hand. + +## Migration ordering + +Migrations land **before** the new app container takes traffic. The +canonical command sequence on a single-instance host: + +```sh +# 1. Pull the new image. +docker pull ghcr.io/marc-bombeck/healthlog:vX.Y.Z + +# 2. Run migrations against the live DB using the new image. +docker run --rm --env-file .env.production \ + ghcr.io/marc-bombeck/healthlog:vX.Y.Z \ + pnpm prisma migrate deploy + +# 3. Recreate the app container so it picks up the new image. +docker compose up -d app +``` + +The entrypoint also runs `prisma migrate deploy` on container boot, so +step 2 is redundant in normal Coolify flow. It's listed explicitly for +the manual case so an operator can verify the migration result before +flipping traffic. + +### What goes wrong if you reverse it + +If the new app container starts before migrations run, the legacy +schema is missing the columns / indexes / enum values the new code +expects. Symptoms: Prisma client throws `P2022 column does not exist` +on the first query that touches the new shape, the request path 500s, +and the dispatcher logs fill with the same error until the migration +catches up. Health checks usually keep the container marked "healthy" +because `/api/version` doesn't exercise the affected tables — so a +naïve load balancer will happily route real traffic into the broken +shell. + +### Rollback + +Rolling back **migrations** is generally one-way. Prisma's migration +history table tracks applied migrations by hash; the `down` step is +not auto-generated and most HealthLog migrations are forward-only +(column drops, enum additions, index rebuilds). If a release has to +roll back: + +1. Roll back the **app container** to the previous image tag. +2. Leave the **database schema** at the newer migration. The previous + app version reads forward-compatible-ish shapes for nearly every + release pair we've shipped on the v1.4.x line; tested in the + release-marathon QA pass. +3. If a column-drop migration ships in the rollback target, hand-roll + the column back via a fresh forward migration (`pnpm prisma migrate + dev --create-only --name vX_Y_Z_rollback_`), edit the SQL, + commit, and re-deploy. Never `migrate resolve --rolled-back` against + production unless you've taken a fresh `pg_dump` first. diff --git a/messages/de.json b/messages/de.json index 054c8f549..ee3468149 100644 --- a/messages/de.json +++ b/messages/de.json @@ -276,7 +276,7 @@ "layoutTileColumn": "Kachel", "layoutChartColumn": "Chart", "dragHandle": "Zum Verschieben ziehen", - "dragHandleHint": "Ziehe den Griff, um dieses Widget neu zu sortieren. Du kannst auch die Pfeiltasten Nach oben und Nach unten verwenden, oder den Griff fokussieren und Leertaste plus Pfeiltasten drücken.", + "dragHandleHint": "Pfeiltasten Hoch/Runter, oder Leertaste plus Pfeiltasten am fokussierten Griff.", "emptyTitle": "Dein Dashboard ist leer", "emptyDescription": "Erfasse den ersten Messwert oder Stimmungseintrag. Kacheln und Charts erscheinen automatisch, sobald Daten vorhanden sind.", "emptyAddMeasurement": "Ersten Messwert erfassen", diff --git a/messages/es.json b/messages/es.json index 7c394bf40..0d2efa471 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2005,8 +2005,8 @@ "docs": "Documentation", "linksHeading": "Sources & docs", "newerAvailable": "Newer version: {tag}", - "tourReplay": "Replay the tour", - "tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit." + "tourReplay": "Volver a ver el recorrido", + "tourReplayHint": "Mira el recorrido del panel — útil si lo saltaste en la primera visita." }, "testConnection": { "test": "Test connection", diff --git a/messages/fr.json b/messages/fr.json index 50c5da96f..b9f5b04e6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2005,8 +2005,8 @@ "docs": "Documentation", "linksHeading": "Sources & docs", "newerAvailable": "Newer version: {tag}", - "tourReplay": "Replay the tour", - "tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit." + "tourReplay": "Revoir la visite", + "tourReplayHint": "Voir la visite guidée du tableau de bord — pratique si tu l'as sautée à la première visite." }, "testConnection": { "test": "Test connection", diff --git a/messages/it.json b/messages/it.json index ef5bee847..aa1af0b8b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2005,8 +2005,8 @@ "docs": "Documentation", "linksHeading": "Sources & docs", "newerAvailable": "Newer version: {tag}", - "tourReplay": "Replay the tour", - "tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit." + "tourReplay": "Rivedi il tour", + "tourReplayHint": "Guarda il tour della dashboard — utile se l'hai saltato alla prima visita." }, "testConnection": { "test": "Test connection", diff --git a/messages/pl.json b/messages/pl.json index 6f8113525..382ce0150 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -276,7 +276,7 @@ "layoutTileColumn": "Kafelek", "layoutChartColumn": "Wykres", "dragHandle": "Przeciągnij, aby zmienić kolejność", - "dragHandleHint": "Przeciągnij uchwyt, aby zmienić kolejność tego widżetu. Możesz też użyć przycisków strzałek W górę i W dół albo skupić uchwyt i wcisnąć Spację oraz strzałki.", + "dragHandleHint": "Strzałki Góra/Dół, lub Spacja plus strzałki na zaznaczonym uchwycie.", "emptyTitle": "Twój panel jest pusty", "emptyDescription": "Zarejestruj pierwszy pomiar lub nastrój. Kafelki i wykresy pojawią się automatycznie, gdy będą dane.", "emptyAddMeasurement": "Zarejestruj pierwszy pomiar", @@ -2005,8 +2005,8 @@ "docs": "Documentation", "linksHeading": "Sources & docs", "newerAvailable": "Newer version: {tag}", - "tourReplay": "Replay the tour", - "tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit." + "tourReplay": "Obejrzyj samouczek", + "tourReplayHint": "Zobacz przewodnik po panelu — przydatne, jeśli pominąłeś go przy pierwszej wizycie." }, "testConnection": { "test": "Test connection", diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx index 9ba01f00c..88942d86b 100644 --- a/src/components/insights/layout-coach-fab.tsx +++ b/src/components/insights/layout-coach-fab.tsx @@ -45,7 +45,7 @@ export function LayoutCoachFab() { // FAB returns null when the master OR the coach sub-flag is off. if (!flags.coach) return null; // v1.4.47 W3 — per-user opt-out beats the operator's "on" default. - // A privacy-leaning user who toggles "Hide Coach" in Settings → AI + // A privacy-leaning user who toggles "Hide Coach" in Settings → Insights // gets the FAB short-circuited here, identical to the global-off // branch above. The hook defaults to `false` in the absence of a // QueryClient (legacy SSR-only snapshot tests). diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 0c571282c..6b906a471 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -123,6 +123,20 @@ const coachMessageFeedbackBody = z "Per-message helpful/unhelpful feedback (v1.4.23 H7). Optional `reason` is free-form prose, capped at 200 chars.", }); +const disableCoachBody = z + .object({ + disableCoach: z.boolean(), + }) + .meta({ + id: "DisableCoachRequest", + description: + "Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.", + }); + +const disableCoachData = z.object({ + disableCoach: z.boolean(), +}); + // ── Sub-schemas owned here (route-specific shapes) ─────────────────── const passkeyLoginVerifyRequest = z @@ -780,6 +794,50 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/auth/me/disable-coach": { + get: { + tags: ["Auth"], + summary: "Read per-user Coach opt-out flag", + description: + "Returns the user's per-account Coach opt-out flag. Default `false` (Coach visible). Powers the Settings → Insights \"Hide Coach\" Switch and the layout FAB short-circuit.", + responses: { + "200": { + description: "Resolved flag.", + content: { + "application/json": { + schema: dataEnvelope(disableCoachData, "GetDisableCoachResponse"), + }, + }, + }, + ...stdResponses, + }, + }, + patch: { + tags: ["Auth"], + summary: "Toggle per-user Coach opt-out flag", + description: + "Flips the per-account Coach opt-out flag. Idempotent — the DB write fires even when the value matches so the audit-log row mirrors the API call. Rate-limit 60/min per user.", + requestBody: { + required: true, + content: { "application/json": { schema: disableCoachBody } }, + }, + responses: { + "200": { + description: + "Resolved next-state echoed back for optimistic-update consumers.", + content: { + "application/json": { + schema: dataEnvelope( + disableCoachData, + "PatchDisableCoachResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/auth/me/source-priority": { get: { tags: ["Auth"],