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_