docs+i18n: sub-locale tour copy, OpenAPI disable-coach, runbook notes

- tourReplay + tourReplayHint native translations for es / fr / it / pl
- dragHandleHint trimmed in de + pl (sr-only, was ~30 words)
- layout-coach-fab docstring: Settings → Insights (matches URL + title)
- OpenAPI: GET + PATCH /api/auth/me/disable-coach
- Operations runbook: attemptNumber audit vs alert-signal semantics
- docs/ops/deploy.md: explicit migration-before-container ordering

Closes L1 + L3 + L4 + L6 + L9 + H4 in v1.4.48 backlog.
This commit is contained in:
Marc-André Bombeck committed 2026-05-23 10:24:13 +02:00
1 parent d156d227a3
commit ff6439fd1a
10 files changed
+276 -11

No files matched your search

+93
View File
@@ -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
+52
View File
@@ -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).
+62
View File
@@ -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_<col>`), edit the SQL,
commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first.
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+3 -3
View File
@@ -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",
+1 -1
View File
@@ -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).
+58
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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"],