diff --git a/CHANGELOG.md b/CHANGELOG.md index 724e42f02..f2ff3ca84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,42 @@ ## [Unreleased] +## [1.16.6] — 2026-06-11 — corrected eras, an honest weekly status and a faster first paint + +### Added + +- **Schedule eras can be corrected.** A recorded era can be edited through a superseding revision that keeps the original as an audit trail; manual eras edit in place, all era writes serialise per medication, and duplicate dose times collapse in validation. +- **The server-wide assistant key has an admin home** — status, provider, model and base URL with the BYOK-first and consent rules stated where the operator works. + +### Changed + +- **The first paint got faster again.** The dashboard snapshot fires the moment login succeeds instead of waiting for the page chunk — its request starts about half a second earlier under throttled conditions — handed over so the server and client render never disagree. +- **Guided coach follow-ups speak to your data.** Questions are personalised from your own snapshot when a provider is available, and an answer receives the coach's streamed reaction before the next question. +- **Settings and admin finally share one face.** Twenty-seven hand-rolled card headers move onto the shared pattern, sixty containers normalise to one chrome, descriptions get a consistent one-sentence voice, and the one duplicated coach preference collapses onto a single owner. + +### Fixed + +- A rolling medication whose next dose is tomorrow no longer reads as heavily overdue — the card status derives strictly from the served next-due. +- The adherence bars keep a fixed geometry in every state instead of growing with their status text. +- The remaining hydration warnings on the dashboard, insights and AI settings are gone. + +## [1.16.5] — 2026-06-11 — a guided coach dialog and a schedule that shows its past + +### Added + +- **Open coach follow-ups run as a guided dialog.** A quiet card offers to start, the coach asks one question at a time with visible progress, answers can be adopted into the matching self-context field, skipping keeps a question pending, and a closing summary says what was taken over. +- **The schedule shows its history.** Past eras appear as a collapsible timeline on the schedule tab, and an earlier era can be added manually for old history — validated against overlaps and bounded so it can never swallow tracked live history. +- **Smarter proactive nudges.** Three new deterministic signals — weight drifting from its target band, a run of short nights, an active user falling silent — plus per-group toggles, a frequency preference and wording that addresses the focus you asked the coach to watch. +- **Invites mint up to fifty uses** with a scrolling redemption list. + +### Changed + +- **A documented path away from the moving latest tag.** The compose image reference accepts a pinned digest via env var, and a promote script resolves the published digest, verifies its cosign signature and hands the operator the exact pinning call. + +### Fixed + +- The self-context adopt runs in one transaction, the admin area renders nothing before the role is confirmed, and the json-body helper keeps caller headers intact. + ## [1.16.4] — 2026-06-11 — half the bundle, a typed client, and stock you can record ### Added diff --git a/docker-compose.yml b/docker-compose.yml index b173c7de4..67bc2ddbb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,15 +4,22 @@ services: # For horizontal scaling switch to the web/worker split below # (HEALTHLOG_PROCESS_TYPE=web on this service + uncomment app-worker). app: - # Tracks the GHCR `:latest` multi-arch image. `pull_policy: always` - # is the load-bearing line — without it, Docker keeps a stale - # `:latest` digest cached on the host and skips the registry round- - # trip on `compose up`, which is what kept Coolify re-deploying the - # previous image after every tag push. With pull_policy=always the - # compose-up always re-checks the registry digest and pulls when - # GHCR has a fresher manifest. The small bandwidth cost on every - # `compose up` is the price of digest-correctness. - image: ghcr.io/mbombeck/healthlog:latest + # Tracks the GHCR `:latest` multi-arch image by default. + # `pull_policy: always` is the load-bearing line — without it, + # Docker keeps a stale `:latest` digest cached on the host and + # skips the registry round-trip on `compose up`, which is what kept + # Coolify re-deploying the previous image after every tag push. + # With pull_policy=always the compose-up always re-checks the + # registry digest and pulls when GHCR has a fresher manifest. + # + # HEALTHLOG_IMAGE_REF pins the image deterministically without + # editing this file: set it in the compose `.env` (or the Coolify + # env vars) to `:vX.Y.Z` for a tag or `@sha256:` for an + # immutable digest reference (see docs/ops/deploy.md, "Digest + # pinning"; scripts/promote-digest.ts resolves + verifies the + # digest after a release). Unset, everything behaves exactly as + # before — `:latest` plus pull_policy. + image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest} pull_policy: always container_name: healthlog-app # The app runs as the unprivileged `nextjs` user and never needs a @@ -108,7 +115,7 @@ services: # See docs/self-hosting/scaling.md for the full guide. # # app-worker: - # image: ghcr.io/mbombeck/healthlog:latest + # image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest} # build: # context: . # dockerfile: Dockerfile diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 84cfcc443..067bbe972 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.16.4 + version: 1.16.6 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -2398,6 +2398,162 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/medications/{id}/schedule-revisions: + get: + tags: + - Medications + summary: List a medication's archived schedule eras + description: Returns every archived schedule era (newest first) plus `currentSince`, the instant the live plan took + over. The dose-history ledger and compliance tallies already mint past days against these eras; this read powers + the Zeitplan-tab history timeline. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Era list. + content: + application/json: + schema: + $ref: "#/components/schemas/ListScheduleRevisionsResponse" + "401": *a1 + "404": + description: Medication not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + post: + tags: + - Medications + summary: Append a manual schedule era (pre-tracking history) + description: Records that the medication dosed at the given daily times during `[validFrom, validUntil)` — history from + before the schedule was edited in the app. The era must end at or before the start of the live plan and must not + overlap an existing era; violations return 422. The snapshot is shaped exactly like a write-path archive + (`FREQ=DAILY`, window pulled to the min/max of the times), so every historical surface reads it transparently. + Audits as `medication.schedule_revision.created`. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateScheduleRevisionRequest" + responses: + "201": + description: Manual era created. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateScheduleRevisionResponse" + "401": *a1 + "404": + description: Medication not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/medications/{id}/schedule-revisions/{revisionId}: + patch: + tags: + - Medications + summary: Correct a recorded schedule era + description: "Replaces an era's bounds and daily times. A `MANUAL` era updates in place; an `ARCHIVED` era stays as the + immutable audit record and the correction is minted as a superseding `MANUAL` revision that takes its place in + every historical surface (the response carries the correction's id). Validation mirrors the sibling POST: the + era must end at or before the start of the live plan and must not overlap another active era; violations return + 422. An era that has already been corrected refuses with 409. Audits as `medication.schedule_revision.updated`." + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: revisionId + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateScheduleRevisionRequest" + responses: + "200": + description: Era corrected. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateScheduleRevisionResponse" + "401": *a1 + "404": + description: Medication or revision not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: The revision has already been superseded by a correction. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + delete: + tags: + - Medications + summary: Delete a manually added schedule era + description: "Removes a `MANUAL` era — one appended through the sibling POST, or a correction minted by PATCH (deleting + a correction restores the archived original it superseded). Write-path archives (`source: ARCHIVED`) are + immutable history and refuse with 409. Audits as `medication.schedule_revision.deleted`." + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: revisionId + schema: + type: string + required: true + responses: + "200": + description: Deletion succeeded. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteScheduleRevisionResponse" + "401": *a1 + "404": + description: Medication or revision not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: The revision is a write-path archive (`ARCHIVED`) and cannot be deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/auth/me/coach-prefs: get: tags: @@ -4791,6 +4947,35 @@ components: description: Free-text medication description payload. The route runs the text through the Coach provider chain and returns a partial structured payload the wizard merges. Rate-limited 10 requests / 5 minutes / user; budget-gated against the daily Coach token ceiling. + CreateScheduleRevisionRequest: + type: object + properties: + validFrom: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: Inclusive start instant of the manual era (ISO 8601). + validUntil: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: Exclusive end instant of the manual era (ISO 8601). Must precede the current plan. + timesOfDay: + description: Daily dose times (HH:mm, user local) the era ran at. 1–8 entries; duplicates are collapsed. + minItems: 1 + maxItems: 8 + type: array + items: + type: string + pattern: ^([01]\d|2[0-3]):([0-5]\d)$ + required: + - validFrom + - validUntil + - timesOfDay + description: "Manual schedule-era payload: era bounds plus the daily dose times that were live during the era." + UpdateScheduleRevisionRequest: + description: "Corrected schedule-era payload: replacement era bounds plus the daily dose times that were live during the era." + $ref: "#/components/schemas/CreateScheduleRevisionRequest" DisableCoachFlag: type: object properties: @@ -9021,6 +9206,158 @@ components: additionalProperties: false description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that. + ListScheduleRevisionsResponse: + type: object + properties: + data: + type: object + properties: + currentSince: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + description: "Instant the LIVE plan took over: the newest revision's `validUntil`, or the medication's `createdAt` when + no era has been archived." + revisions: + type: array + items: + $ref: "#/components/schemas/MedicationScheduleRevision" + description: Archived eras, newest first. + required: + - currentSince + - revisions + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationScheduleRevision: + type: object + properties: + id: + type: string + validFrom: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + description: Inclusive start instant of the era. + validUntil: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + description: Exclusive end instant of the era — the moment the next plan took over. + source: + type: string + enum: + - ARCHIVED + - MANUAL + description: Provenance. ARCHIVED = minted by the schedule-replace write path (immutable). MANUAL = user-entered + pre-tracking era (deletable). + entries: + type: array + items: + $ref: "#/components/schemas/ScheduleRevisionEntry" + required: + - id + - validFrom + - validUntil + - source + - entries + additionalProperties: false + description: One archived schedule era covering `[validFrom, validUntil)`. The dose-history ledger, compliance tallies, + and cadence chips mint past days against the era that was live then. + ScheduleRevisionEntry: + type: object + properties: + timesOfDay: + type: array + items: + type: string + description: Daily dose times (HH:mm, user local) the era ran at. + label: + anyOf: + - type: string + - type: "null" + dose: + anyOf: + - type: string + - type: "null" + scheduleType: + type: string + description: Schedule-type discriminator of the archived row (SCHEDULED / PRN / CYCLIC). + required: + - timesOfDay + - label + - dose + - scheduleType + additionalProperties: false + description: Display summary of one archived schedule row inside an era. The full snapshot (windows, rrule, doseWindows, + …) stays server-side; this projection carries what the timeline renders. + CreateScheduleRevisionResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationScheduleRevision" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateScheduleRevisionResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationScheduleRevision" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteScheduleRevisionResponse: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetCoachPrefsResponse: type: object properties: diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index ad890d7d9..7e2065897 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -121,19 +121,72 @@ image's only immutable identity, and it is what the signature actually covers. For any deploy where supply-chain integrity matters more than convenience: -1. Resolve and verify the digest once: +The compose file carries the hook for this: the `app` service image is +`ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}`. Unset, the +day-to-day `:latest` + `pull_policy: always` flow stays the documented +default for self-hosters. Set `HEALTHLOG_IMAGE_REF` in the compose +`.env` (or the deployment platform's env vars) to switch the reference +without touching the file: - ```sh - cosign verify ... ghcr.io/mbombeck/healthlog:1.16.4 \ - | jq -r '.[0].critical.image."docker-manifest-digest"' - ``` +- `HEALTHLOG_IMAGE_REF=:v1.16.5` — pin to a release tag (still mutable). +- `HEALTHLOG_IMAGE_REF=@sha256:` — pin to the immutable digest. -2. Pin the compose `image:` (or the Coolify image reference) to - `ghcr.io/mbombeck/healthlog@sha256:` instead of the tag. +### Promote a release to a pinned digest -The day-to-day `:latest` + `pull_policy: always` flow stays the -documented default for self-hosters; digest pinning is the recommended -hardening for operators with a threat model that includes the registry. +`scripts/promote-digest.ts` automates the whole promotion after +`docker-publish.yml` has pushed a tag: + +```sh +pnpm dlx tsx scripts/promote-digest.ts v1.16.5 +``` + +It resolves the multi-arch index digest from the GHCR registry API +(anonymous pull token, no docker daemon), verifies the keyless cosign +signature against the workflow identity (fails closed — no cosign, no +promotion), and prints the resulting `HEALTHLOG_IMAGE_REF=@sha256:…` +value. + +For a Coolify-managed deployment it can also apply the pin. Two facts +about Coolify shape the mechanism (verified against the Coolify +OpenAPI spec and a live instance): + +- For a git-based "Docker Compose" application, Coolify re-reads the + compose file from the repository checkout on every deploy. + `docker_compose_raw` is its stored copy of that file, and the + application-update API (`PATCH /api/v1/applications/{uuid}`) does not + accept the field — editing the stored compose is neither possible + via API nor durable. +- Application **env vars** are durable, exposed via the documented + `/api/v1/applications/{uuid}/envs` endpoints, and interpolated into + the compose at deploy time. + +So the pin travels as the `HEALTHLOG_IMAGE_REF` env var. With +`COOLIFY_API_TOKEN`, `COOLIFY_API_URL` and `COOLIFY_APP_UUID` in the +environment the script creates/updates the var itself (add +`PROMOTE_DEPLOY=1` to also trigger the redeploy); without a token it +prints the equivalent curl commands: + +```sh +curl -sS -X PATCH "https:///api/v1/applications//envs" \ + -H "Authorization: Bearer " -H "Content-Type: application/json" \ + -d '{"key":"HEALTHLOG_IMAGE_REF","value":"@sha256:"}' + +curl -sS "https:///api/v1/deploy?uuid=" \ + -H "Authorization: Bearer " +``` + +After the deploy settles, confirm `/api/version` reports the released +`version` + `buildSha` on every target (see above). + +**Rollback:** re-run the script with the previous tag (or PATCH the env +var back to the previous digest / to empty for `:latest`) and redeploy. +Digest pinning trades the automatic `:latest` pickup for determinism — +a pinned instance only moves when the var moves, so the promotion step +becomes part of the release routine, not an optional extra. + +Digest pinning is the recommended hardening for operators with a +threat model that includes the registry; `:latest` remains the +low-friction default. ## Deploy-status webhook: replay protection diff --git a/e2e/coach-guided-questions.spec.ts b/e2e/coach-guided-questions.spec.ts new file mode 100644 index 000000000..3d708b2ca --- /dev/null +++ b/e2e/coach-guided-questions.spec.ts @@ -0,0 +1,252 @@ +import { expect, test } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * Guided clarifying-questions flow on the full-page Coach route + * (v1.16.5 — V2 of the v1.16.0 composer chips). + * + * Contracts under test: + * + * 1. With pending self-context questions, the entry card + * (`data-slot="coach-guided-offer"`) renders above the composer + * and offers start / later / don't-ask-again. + * 2. Starting the flow mounts the first deterministic question + * bubble in the thread (`data-slot="coach-guided-question"`, + * `data-state="current"`) with the progress line and the + * skip / later / dismiss actions. + * 3. Skip advances to the next question without any server write. + * 4. "Later" mid-sequence ends the flow without a summary when + * nothing was answered; the entry card stays hidden for the + * session. + * 5. The entry-card "later" hides the offer without touching the + * thread. + * + * 6. v1.16.6 — answering a question streams a contextual Coach + * reaction (mocked SSE) BEFORE the next question appears; the + * sequence advances only once the adopt offer settles. + * + * The questions API is mocked; the answer test also mocks the chat SSE + * POST (the reaction content itself is the model's business, only the + * ordering contract is pinned here). + */ +test.describe("Coach guided clarifying questions", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + const QUESTIONS = [ + "Guided e2e question one?", + "Guided e2e question two?", + "Guided e2e question three?", + ]; + const REACTION = "Thanks for sharing — that helps me read your numbers."; + + test.beforeEach(async ({ page }) => { + // Empty conversation list so the page surface paints the empty + // thread + history rail without hitting the database. + // Wide pattern on purpose: the list GET may fire with or without a + // cursor query. Neither the detail GET nor the SSE POST fires in + // this spec (no conversation is opened, no message is sent). + await page.route("**/api/insights/chat*", (route) => { + // v1.16.6 — the answer test POSTs the guided answer through the + // normal chat pipeline; serve a minimal SSE reaction. GETs keep + // the empty conversation list. + if (route.request().method() === "POST") { + return route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: + `data: ${JSON.stringify({ type: "token", token: REACTION })}\n\n` + + `data: ${JSON.stringify({ + type: "done", + conversationId: "conv-guided-e2e", + messageId: "msg-guided-e2e", + })}\n\n`, + }); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { conversations: [], nextCursor: null }, + error: null, + }), + }); + }); + // Detail refetch after the SSE `done` frame. + await page.route("**/api/insights/chat/conv-guided-e2e", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + id: "conv-guided-e2e", + title: "Guided e2e", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + messageCount: 2, + summary: null, + messages: [ + { + id: "msg-user-e2e", + role: "user", + content: "Yes, hypertension since 2019.", + createdAt: new Date().toISOString(), + metricSource: null, + providerType: null, + promptVersion: null, + }, + { + id: "msg-guided-e2e", + role: "assistant", + content: REACTION, + createdAt: new Date().toISOString(), + metricSource: null, + providerType: "mock", + promptVersion: null, + }, + ], + }, + error: null, + }), + }), + ); + await page.route("**/api/insights/coach/nudge-status*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { nudgedAt: null, unread: false }, + error: null, + }), + }), + ); + // Pending questions: GET serves the snapshot, DELETE (dismiss one + // or all) empties it. + await page.route("**/api/coach/about-me/questions*", (route) => { + const isDelete = route.request().method() === "DELETE"; + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { questions: isDelete ? [] : QUESTIONS }, + error: null, + }), + }); + }); + }); + + test("offer card starts the guided sequence; skip and later walk it", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "flow logic is viewport-independent; desktop run suffices", + ); + + await page.goto("/insights/coach", { waitUntil: "domcontentloaded" }); + + // 1. Entry card with the three choices. + const offer = page.locator('[data-slot="coach-guided-offer"]'); + await expect(offer).toBeVisible({ timeout: 10_000 }); + await expect( + offer.locator('[data-slot="coach-guided-offer-start"]'), + ).toBeVisible(); + await expect( + offer.locator('[data-slot="coach-guided-offer-later"]'), + ).toBeVisible(); + await expect( + offer.locator('[data-slot="coach-guided-offer-dismiss"]'), + ).toBeVisible(); + + // 2. Start → first question bubble (typing reveal lasts <1s). + await offer.locator('[data-slot="coach-guided-offer-start"]').click(); + await expect(offer).toHaveCount(0); + const question = page.locator( + '[data-slot="coach-guided-question"][data-state="current"]', + ); + await expect(question).toBeVisible({ timeout: 10_000 }); + await expect(question).toContainText(QUESTIONS[0], { timeout: 10_000 }); + await expect( + question.locator('[data-slot="coach-guided-progress"]'), + ).toContainText("1"); + + // 3. Skip → second question, original numbering. + await question.locator('[data-slot="coach-guided-skip"]').click(); + await expect(question).toContainText(QUESTIONS[1], { timeout: 10_000 }); + await expect( + question.locator('[data-slot="coach-guided-progress"]'), + ).toContainText("2"); + + // 4. Later mid-sequence → flow ends, nothing answered → no summary, + // no question bubble, and the offer stays hidden for the session. + await question.locator('[data-slot="coach-guided-later"]').click(); + await expect( + page.locator('[data-slot="coach-guided-question"]'), + ).toHaveCount(0); + await expect( + page.locator('[data-slot="coach-guided-summary"]'), + ).toHaveCount(0); + await expect(offer).toHaveCount(0); + }); + + test("entry-card later hides the offer without starting the flow", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "flow logic is viewport-independent; desktop run suffices", + ); + + await page.goto("/insights/coach", { waitUntil: "domcontentloaded" }); + + const offer = page.locator('[data-slot="coach-guided-offer"]'); + await expect(offer).toBeVisible({ timeout: 10_000 }); + await offer.locator('[data-slot="coach-guided-offer-later"]').click(); + await expect(offer).toHaveCount(0); + await expect( + page.locator('[data-slot="coach-guided-question"]'), + ).toHaveCount(0); + }); + + test("an answer streams a Coach reaction before the next question", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "flow logic is viewport-independent; desktop run suffices", + ); + + await page.goto("/insights/coach", { waitUntil: "domcontentloaded" }); + + const offer = page.locator('[data-slot="coach-guided-offer"]'); + await expect(offer).toBeVisible({ timeout: 10_000 }); + await offer.locator('[data-slot="coach-guided-offer-start"]').click(); + + const current = page.locator( + '[data-slot="coach-guided-question"][data-state="current"]', + ); + await expect(current).toContainText(QUESTIONS[0], { timeout: 10_000 }); + + // Answer through the composer — the message rides the normal chat + // pipeline (mocked SSE above). + const textarea = page.locator('[data-slot="coach-input-textarea"]'); + await textarea.fill("Yes, hypertension since 2019."); + await page.locator('[data-slot="coach-input-send"]').click(); + + // Order contract: the streamed Coach reaction appears, the adopt + // offer follows, and NO next question is current yet. + await expect(page.getByText(REACTION).first()).toBeVisible({ + timeout: 10_000, + }); + const adopt = page.locator('[data-slot="coach-self-context-adopt"]'); + await expect(adopt).toBeVisible({ timeout: 10_000 }); + await expect(current).toHaveCount(0); + + // Settling the offer (decline via ✕) releases the next question. + await adopt.locator('[data-slot="coach-self-context-adopt-dismiss"]').click(); + await expect(current).toContainText(QUESTIONS[1], { timeout: 10_000 }); + await expect( + current.locator('[data-slot="coach-guided-progress"]'), + ).toContainText("2"); + }); +}); diff --git a/knip.json b/knip.json index 4b0f6accf..367c93737 100644 --- a/knip.json +++ b/knip.json @@ -68,11 +68,17 @@ "docker", "docker-compose", "pg_isready", - "tsx" + "tsx", + "cosign" ], "ignoreExportsUsedInFile": true, "ignoreIssues": { - "src/components/ui/**": ["exports", "types"], - "src/lib/validations/**": ["types"] + "src/components/ui/**": [ + "exports", + "types" + ], + "src/lib/validations/**": [ + "types" + ] } -} +} \ No newline at end of file diff --git a/messages/de.json b/messages/de.json index 5131c9de3..36652cfe3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1599,6 +1599,42 @@ "graceEcho": "Erinnerungsfenster: {minutes} Minuten", "reminderTitle": "Erinnerung", "manageChannels": "Benachrichtigungskanäle verwalten", + "history": { + "title": "Planhistorie", + "currentSince": "Aktueller Plan seit {date}", + "noTimes": "Keine festen Uhrzeiten", + "manualChip": "Manuell ergänzt", + "showErasOne": "1 frühere Ära anzeigen", + "showErasOther": "{count} frühere Ären anzeigen", + "hideEras": "Frühere Ären ausblenden", + "addEra": "Frühere Ära ergänzen", + "addEraTitle": "Frühere Ära ergänzen", + "addEraDescription": "Halte fest, zu welchen Uhrzeiten dieses Medikament früher eingenommen wurde — etwa bevor du es hier angepasst hast.", + "editAria": "Ära bearbeiten", + "editEraTitle": "Ära bearbeiten", + "editEraDescription": "Passe die Einnahmezeiten und den Zeitraum dieser Ära an.", + "archivedEditHint": "Diese Ära wurde automatisch aufgezeichnet — deine Änderung korrigiert die aufgezeichnete Historie; das Original bleibt intern als Beleg erhalten.", + "updatedToast": "Ära aktualisiert", + "updateError": "Die Ära konnte nicht aktualisiert werden. Sie darf sich nicht mit anderen Ären überschneiden und muss vor dem aktuellen Plan enden.", + "timesLabel": "Einnahmezeiten", + "fromLabel": "Von", + "untilLabel": "Bis", + "untilHint": "„Bis\" ist der Tag, ab dem der nachfolgende Plan galt (ausschließlich).", + "validationTimes": "Mindestens eine Uhrzeit angeben.", + "validationRangeMissing": "Von- und Bis-Datum angeben.", + "validationRangeOrder": "Das Von-Datum muss vor dem Bis-Datum liegen.", + "cancel": "Abbrechen", + "submit": "Ära speichern", + "createdToast": "Frühere Ära gespeichert", + "createError": "Die Ära konnte nicht gespeichert werden. Sie darf sich nicht mit bestehenden Ären überschneiden und muss vor dem aktuellen Plan enden.", + "deletedToast": "Ära gelöscht", + "deleteError": "Die Ära konnte nicht gelöscht werden", + "deleteAria": "Ära löschen", + "deleteConfirmTitle": "Ära löschen?", + "deleteConfirmBody": "Verlauf und Therapietreue werden für diesen Zeitraum wieder anhand des aktuellen Plans berechnet.", + "deleteConfirmAction": "Löschen", + "loadError": "Planhistorie konnte nicht geladen werden" + }, "save": "Zeitplan speichern", "saved": "Zeitplan gespeichert", "failed": "Zeitplan konnte nicht gespeichert werden", @@ -1995,7 +2031,6 @@ "thinking": "Denke nach…", "composerPlaceholder": "Frag mich etwas zu deinen Daten…", "nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen", - "selfContextChipsLabel": "Der Coach würde gern wissen:", "selfContextAdopt": { "offer": "Antwort in deine Selbstauskunft übernehmen?", "confirm": "Übernehmen", @@ -2004,8 +2039,25 @@ "duplicate": "Bereits in der Selbstauskunft enthalten", "failed": "Übernahme fehlgeschlagen. Bitte erneut versuchen." }, - "selfContextChipDismiss": "Frage verwerfen", - "selfContextChipDismissError": "Frage konnte nicht ausgeblendet werden", + "guided": { + "offerTitle": "Der Coach hat {count} Rückfragen an dich", + "offerTitleOne": "Der Coach hat eine Rückfrage an dich", + "offerBody": "Kurze Antworten schärfen deine Selbstauskunft — eine Frage nach der anderen, direkt hier im Chat.", + "offerStart": "Jetzt beantworten", + "offerLater": "Später", + "offerDismiss": "Nicht mehr fragen", + "dismissError": "Die Fragen konnten nicht verworfen werden. Bitte erneut versuchen.", + "progress": "Frage {current} von {total}", + "skip": "Überspringen", + "later": "Später", + "dismissRemaining": "Nicht mehr fragen", + "answerPlaceholder": "Deine Antwort…", + "summaryTitle": "Geschafft — danke für deine Antworten!", + "summaryAnswered": "{answered} von {total} beantwortet", + "summaryAdopted": "{adopted} in die Selbstauskunft übernommen", + "summaryAdoptedNone": "nichts Neues in die Selbstauskunft übernommen", + "summaryLink": "Selbstauskunft ansehen" + }, "voiceComingSoon": "Spracheingabe kommt mit der iOS-App in v1.5.", "threadEmpty": "Frag mich etwas zu deinen Trends, Medikamenten oder Messwerten, um zu starten.", "historyTitle": "Unterhaltungen", @@ -2082,6 +2134,8 @@ "settingsVerbosityBrief": "Kurz", "settingsVerbosityDefault": "Standard", "settingsVerbosityDetailed": "Ausführlich", + "settingsSourcesPointer": "Welche Datenquellen der Coach nutzen darf und das Standard-Analysefenster verwaltest du direkt im Coach-Chat (Leiste „Worauf ich zugreife\").", + "settingsSourcesPointerLink": "Coach öffnen", "settingsExcludeLabel": "Metriken ausschließen", "settingsExcludeHint": "Der Coach sieht keine Daten der ausgewählten Metriken.", "settingsContextLabel": "Kontext mitschicken", @@ -3461,7 +3515,7 @@ "integrationsHint": "Neue Datenquellen verbindest oder trennst du auf der Integrationen-Seite.", "integrationsHintLink": "Integrationen öffnen", "cardTitle": "Quellen-Priorität pro Metrik", - "help": "Wenn mehrere Quellen denselben Wert am selben Tag liefern (z. B. Withings + Apple Health beide 1.200 Schritte), wird die oberste Quelle als kanonisch verwendet. Andere Quellen bleiben gespeichert für die Audit-Spur.", + "help": "Liefern mehrere Quellen denselben Wert am selben Tag, zählt die oberste Quelle als kanonisch. Die übrigen bleiben für die Audit-Spur gespeichert.", "resetDefaults": "Auf Standard zurücksetzen", "moveUp": "Nach oben", "moveDown": "Nach unten", @@ -3821,7 +3875,7 @@ }, "adminOpenai": { "title": "Admin-OpenAI", - "body": "Vom Betreiber bereitgestellter OpenAI-Key. Wird als letzter Fallback genutzt, wenn kein persönlicher Provider konfiguriert ist. Hier ist nichts einzustellen — die Anzeige bedeutet, dass der Betreiber ihn eingerichtet hat.", + "body": "Vom Betreiber bereitgestellter OpenAI-Key, genutzt als letzter Fallback ohne persönlichen Provider. Hier ist nichts einzustellen.", "notConfigured": "Der Betreiber hat keinen geteilten OpenAI-Key auf dieser Instanz konfiguriert." }, "testProvider": "Aktiven Provider testen", @@ -4231,6 +4285,7 @@ "subtitle": "Systemstatus, Benutzer und Einstellungen", "settingsSaveError": "Einstellungen konnten nicht gespeichert werden", "systemStatus": "Systemstatus", + "systemStatusDescription": "Laufzeitdaten der Instanz: Datenbank, Worker und Bestandszähler.", "database": "Datenbank", "databaseConnected": "Verbunden", "databaseError": "Fehler", @@ -4252,6 +4307,23 @@ "empty": "Erfassung läuft — Daten erscheinen in 1–2 Minuten.", "loadError": "Host-Metriken konnten nicht geladen werden. Bitte die Seite neu laden." }, + "aiServerKey": { + "title": "KI-Server-Schlüssel", + "description": "Ein betreiberweiter Provider-Schlüssel, auf den jedes Konto zurückfallen kann, wenn kein eigener Schlüssel hinterlegt ist.", + "byokHint": "Der eigene Schlüssel (BYOK, ChatGPT-Login oder lokaler Provider) hat immer Vorrang — der Server-Schlüssel ist die letzte Stufe der Provider-Kette.", + "consentHint": "Anfragen über den Server-Schlüssel erfordern die aktive KI-Einwilligung der Person; ohne Einwilligungsbeleg wird nichts gesendet.", + "statusConfigured": "Eingerichtet", + "statusNotConfigured": "Nicht eingerichtet", + "keyLabel": "API-Schlüssel", + "keyPlaceholder": "sk-...", + "keyPlaceholderSet": "Hinterlegt ({preview}) — neuen Schlüssel eingeben, um ihn zu ersetzen", + "modelLabel": "Modell", + "baseUrlLabel": "Basis-URL", + "save": "Speichern", + "remove": "Schlüssel entfernen", + "saved": "KI-Server-Schlüssel gespeichert", + "error": "Speichern des KI-Server-Schlüssels fehlgeschlagen" + }, "aiQuality": { "title": "Auswertungs-Qualität", "loading": "Feedback-Zusammenfassung wird geladen...", @@ -4287,6 +4359,7 @@ "workerStopped": "Gestoppt", "lastReminderCheck": "Letzter Reminder-Check", "appSettings": "Anwendungseinstellungen", + "appSettingsDescription": "Globale Standardwerte für Registrierung, Sprache und Zeitzone neuer Konten.", "registrationEnabled": "Registrierung erlauben", "registrationEnabledDescription": "Wenn deaktiviert, können sich keine neuen Benutzer registrieren.", "defaultLanguage": "Standard-Sprache", @@ -4417,6 +4490,7 @@ "revokeError": "Einladung konnte nicht widerrufen werden." }, "userManagement": "Benutzerverwaltung", + "userManagementDescription": "Konten verwalten: Rollen, Passwort-Resets und erzwungene Abmeldungen.", "userName": "Name", "userEmail": "E-Mail", "userRole": "Rolle", @@ -4438,6 +4512,7 @@ "reset": "Zurücksetzen", "loadingUsers": "Lade Benutzer...", "apiTokens": "API-Tokens", + "apiTokensDescription": "Alle aktiven und widerrufenen API-Tokens über alle Konten hinweg.", "tokenUser": "Benutzer", "tokenName": "Name", "tokenPermissions": "Berechtigungen", @@ -4450,6 +4525,7 @@ "tokensEmptyTitle": "Noch keine API-Tokens", "tokensEmptyDescription": "Tokens werden bei der Anmeldung nativer Clients oder über Einstellungen → Account ausgestellt.", "loginOverview": "Anmeldeübersicht", + "loginOverviewDescription": "Anmelde- und Auth-Ereignisse aller Konten, filterbar und als CSV exportierbar.", "allAuthEvents": "Alle Auth-Ereignisse", "failedOnly": "Nur fehlgeschlagene", "loginEmptyDescription": "Anmeldungen, Abmeldungen und Passkey-Änderungen erscheinen hier.", @@ -5109,10 +5185,21 @@ }, "coachNudge": { "title": "Proaktiver Coach", - "description": "Meldet sich höchstens einmal pro Woche, wenn deine Daten einen Blick in den Coach nahelegen — etwa bei niedriger Therapietreue oder erhöhtem Blutdruck-Wochenmittel.", + "description": "Meldet sich, wenn deine Daten einen Blick in den Coach nahelegen — etwa bei niedriger Therapietreue oder erhöhtem Blutdruck-Wochenmittel. Themen und Häufigkeit sind einstellbar.", "toggleAria": "Proaktive Coach-Hinweise umschalten", - "statusOn": "Aktiv — max. 1× pro Woche", + "statusOn": "Aktiv", "statusOff": "Aus", + "groupsLabel": "Themen", + "groupMedication": "Medikation", + "groupMedicationDesc": "Lückenhafte Einnahmen der letzten Woche.", + "groupVitals": "Vitalwerte", + "groupVitalsDesc": "Blutdruck, Erholung, Gewichtstrend, Schlaf.", + "groupRoutine": "Routine", + "groupRoutineDesc": "Abrupte Mess-Pausen und Selbstauskunft-Check-ups.", + "frequencyLabel": "Häufigkeit", + "frequencyWeekly": "Höchstens einmal pro Woche", + "frequencyBiweekly": "Höchstens alle zwei Wochen", + "frequencyAria": "Häufigkeit der Coach-Hinweise wählen", "enabledToast": "Proaktive Coach-Hinweise aktiviert.", "disabledToast": "Proaktive Coach-Hinweise deaktiviert.", "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen." @@ -5312,6 +5399,13 @@ "scoreBody": "Dein Erholungswert ist diese Woche deutlich gesunken. Der Coach hilft dir, mögliche Ursachen einzugrenzen.", "selfContextTitle": "Hilf deinem Coach, dich besser zu kennen", "selfContextBody": "Deine Selbstauskunft ist unvollständig oder länger nicht aktualisiert. Zwei Minuten in den Einstellungen halten den Kontext des Coachs frisch.", + "weightTitle": "Dein Gewicht entfernt sich vom Zielbereich", + "weightBody": "Dein Wochenmittel hat sich weiter von deinem Zielbereich entfernt. Der Coach kann mit dir in Ruhe Bilanz ziehen.", + "sleepDebtTitle": "Mehrere kurze Nächte diese Woche", + "sleepDebtBody": "Die meisten deiner letzten Nächte lagen unter deinem Schlafziel. Der Coach hilft dir, nach Mustern zu suchen.", + "measurementGapTitle": "Dein Protokoll ist still geworden", + "measurementGapBody": "Seit einer Woche kommen keine neuen Werte an. Ein kurzer Austausch mit dem Coach hilft beim Wiedereinstieg — oder deckt eine hängende Synchronisierung auf.", + "focusSuffix": "Du wolltest auf Folgendes achten: {focus}.", "questionConditions": "Hast du chronische Erkrankungen, die der Coach kennen sollte?", "questionFocus": "Gibt es etwas Bestimmtes, worauf der Coach achten soll?", "questionAllergies": "Hast du Allergien oder Unverträglichkeiten?", diff --git a/messages/en.json b/messages/en.json index 75aa016ef..83da91264 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1599,6 +1599,42 @@ "graceEcho": "Reminder window: {minutes} minutes", "reminderTitle": "Reminder", "manageChannels": "Manage notification channels", + "history": { + "title": "Plan history", + "currentSince": "Current plan since {date}", + "noTimes": "No fixed times", + "manualChip": "Added manually", + "showErasOne": "Show 1 earlier era", + "showErasOther": "Show {count} earlier eras", + "hideEras": "Hide earlier eras", + "addEra": "Add earlier era", + "addEraTitle": "Add earlier era", + "addEraDescription": "Record the times this medication used to be taken at — for example before you adjusted it here.", + "editAria": "Edit era", + "editEraTitle": "Edit era", + "editEraDescription": "Adjust the dose times and the date range of this era.", + "archivedEditHint": "This era was recorded automatically — your change corrects the recorded history; the original is kept internally as a record.", + "updatedToast": "Era updated", + "updateError": "Could not update the era. It must not overlap other eras and must end before the current plan.", + "timesLabel": "Dose times", + "fromLabel": "From", + "untilLabel": "Until", + "untilHint": "\"Until\" is the day the following plan took over (exclusive).", + "validationTimes": "Add at least one time.", + "validationRangeMissing": "Set both the from and until dates.", + "validationRangeOrder": "The from date must lie before the until date.", + "cancel": "Cancel", + "submit": "Save era", + "createdToast": "Earlier era saved", + "createError": "Could not save the era. It must not overlap existing eras and must end before the current plan.", + "deletedToast": "Era deleted", + "deleteError": "Could not delete the era", + "deleteAria": "Delete era", + "deleteConfirmTitle": "Delete era?", + "deleteConfirmBody": "History and adherence for this period will be computed against the current plan again.", + "deleteConfirmAction": "Delete", + "loadError": "Could not load the plan history" + }, "save": "Save schedule", "saved": "Schedule saved", "failed": "Could not save the schedule", @@ -1995,7 +2031,6 @@ "thinking": "Thinking…", "composerPlaceholder": "Ask anything about your data…", "nudgeBubbleLabel": "New message from the Coach — open chat", - "selfContextChipsLabel": "The Coach would like to know:", "selfContextAdopt": { "offer": "Add this answer to your self-context?", "confirm": "Add", @@ -2004,8 +2039,25 @@ "duplicate": "Already part of your self-context", "failed": "Could not add. Please try again." }, - "selfContextChipDismiss": "Dismiss question", - "selfContextChipDismissError": "Could not dismiss the question", + "guided": { + "offerTitle": "The Coach has {count} follow-up questions for you", + "offerTitleOne": "The Coach has a follow-up question for you", + "offerBody": "Short answers sharpen your self-context — one question at a time, right here in the chat.", + "offerStart": "Answer now", + "offerLater": "Later", + "offerDismiss": "Don't ask again", + "dismissError": "Could not dismiss the questions. Please try again.", + "progress": "Question {current} of {total}", + "skip": "Skip", + "later": "Later", + "dismissRemaining": "Don't ask again", + "answerPlaceholder": "Type your answer…", + "summaryTitle": "All set — thanks for your answers!", + "summaryAnswered": "{answered} of {total} answered", + "summaryAdopted": "{adopted} added to your self-context", + "summaryAdoptedNone": "nothing new added to your self-context", + "summaryLink": "Review your self-context" + }, "voiceComingSoon": "Voice input arrives with the iOS app in v1.5.", "threadEmpty": "Ask anything about your trends, medications, or readings to start a conversation.", "historyTitle": "Conversations", @@ -2082,6 +2134,8 @@ "settingsVerbosityBrief": "Brief", "settingsVerbosityDefault": "Default", "settingsVerbosityDetailed": "Detailed", + "settingsSourcesPointer": "Which data clusters the coach may draw on and the default analysis window are managed directly in the coach chat (\"What I draw on\" rail).", + "settingsSourcesPointerLink": "Open the coach", "settingsExcludeLabel": "Exclude metrics", "settingsExcludeHint": "The Coach will not see data for selected metrics.", "settingsContextLabel": "Include context", @@ -3461,7 +3515,7 @@ "integrationsHint": "Connect or disconnect data sources on the Integrations page.", "integrationsHintLink": "Open integrations", "cardTitle": "Source priority by metric", - "help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.", + "help": "When several sources deliver the same value on the same day, the top source counts as canonical. The others stay stored for the audit trail.", "resetDefaults": "Reset to defaults", "moveUp": "Move up", "moveDown": "Move down", @@ -3821,7 +3875,7 @@ }, "adminOpenai": { "title": "Admin OpenAI", - "body": "Operator-provided OpenAI key. Used as a last-ditch fallback when no personal provider is configured. There is nothing to configure on this row — visibility means the operator has set it up.", + "body": "Operator-provided OpenAI key, used as the last fallback when no personal provider is configured. Nothing to configure here.", "notConfigured": "The operator has not configured a shared OpenAI key on this instance." }, "testProvider": "Test active provider", @@ -4231,6 +4285,7 @@ "subtitle": "System status, users, and settings", "settingsSaveError": "Failed to save settings", "systemStatus": "System Status", + "systemStatusDescription": "Runtime state of this instance: database, worker, and record counts.", "database": "Database", "databaseConnected": "Connected", "databaseError": "Error", @@ -4252,6 +4307,23 @@ "empty": "Sampling started — data appears in 1–2 minutes.", "loadError": "Failed to load host metrics. Try refreshing the page." }, + "aiServerKey": { + "title": "AI server key", + "description": "An operator-wide provider key every account can fall back to when no personal key is configured.", + "byokHint": "A user's own key (BYOK, ChatGPT login or local provider) always takes precedence — the server key is the last step in the provider chain.", + "consentHint": "Requests over the server key require the user's active AI consent; without a consent receipt nothing is sent.", + "statusConfigured": "Configured", + "statusNotConfigured": "Not configured", + "keyLabel": "API key", + "keyPlaceholder": "sk-...", + "keyPlaceholderSet": "Stored ({preview}) — enter a new key to replace it", + "modelLabel": "Model", + "baseUrlLabel": "Base URL", + "save": "Save", + "remove": "Remove key", + "saved": "AI server key settings saved", + "error": "Saving the AI server key failed" + }, "aiQuality": { "title": "Insights Quality", "loading": "Loading feedback summary...", @@ -4287,6 +4359,7 @@ "workerStopped": "Stopped", "lastReminderCheck": "Last Reminder Check", "appSettings": "Application Settings", + "appSettingsDescription": "Global defaults for registration, language, and the timezone of new accounts.", "registrationEnabled": "Allow registration", "registrationEnabledDescription": "When disabled, new users cannot register.", "defaultLanguage": "Default language", @@ -4417,6 +4490,7 @@ "revokeError": "Could not revoke the invite." }, "userManagement": "User Management", + "userManagementDescription": "Manage accounts: roles, password resets, and forced logouts.", "userName": "Name", "userEmail": "Email", "userRole": "Role", @@ -4438,6 +4512,7 @@ "reset": "Reset", "loadingUsers": "Loading users...", "apiTokens": "API Tokens", + "apiTokensDescription": "All active and revoked API tokens across all accounts.", "tokenUser": "User", "tokenName": "Name", "tokenPermissions": "Permissions", @@ -4450,6 +4525,7 @@ "tokensEmptyTitle": "No API tokens yet", "tokensEmptyDescription": "Tokens are issued when a native client signs in or via Settings → Account.", "loginOverview": "Login Overview", + "loginOverviewDescription": "Sign-in and auth events across all accounts, filterable and exportable as CSV.", "allAuthEvents": "All auth events", "failedOnly": "Failed only", "loginEmptyDescription": "Sign-ins, sign-outs, and passkey changes will show up here.", @@ -5109,10 +5185,21 @@ }, "coachNudge": { "title": "Proactive Coach", - "description": "Reaches out at most once a week when your data suggests a look at the Coach — for example low medication adherence or an elevated weekly blood-pressure average.", + "description": "Reaches out when your data suggests a look at the Coach — for example low medication adherence or an elevated weekly blood-pressure average. Topics and frequency are adjustable.", "toggleAria": "Toggle proactive Coach nudges", - "statusOn": "Active — max. once a week", + "statusOn": "Active", "statusOff": "Off", + "groupsLabel": "Topics", + "groupMedication": "Medication", + "groupMedicationDesc": "Patchy intakes over the past week.", + "groupVitals": "Vitals", + "groupVitalsDesc": "Blood pressure, recovery, weight trend, sleep.", + "groupRoutine": "Routine", + "groupRoutineDesc": "Abrupt tracking stops and self-context check-ups.", + "frequencyLabel": "Frequency", + "frequencyWeekly": "At most once a week", + "frequencyBiweekly": "At most every two weeks", + "frequencyAria": "Select nudge frequency", "enabledToast": "Proactive Coach nudges enabled.", "disabledToast": "Proactive Coach nudges disabled.", "saveError": "Could not save. Please try again later." @@ -5312,6 +5399,13 @@ "scoreBody": "Your recovery score fell noticeably this week. The Coach can help you narrow down possible causes.", "selfContextTitle": "Help your Coach know you better", "selfContextBody": "Your self-description is incomplete or has not been updated in a while. Two minutes in the settings keep the Coach's context fresh.", + "weightTitle": "Your weight is drifting from your target", + "weightBody": "Your weekly average moved further away from your target range. The Coach can help you take stock calmly.", + "sleepDebtTitle": "Several short nights this week", + "sleepDebtBody": "Most of your recent nights came in under your sleep target. The Coach can help you look for patterns.", + "measurementGapTitle": "Your log went quiet", + "measurementGapBody": "No new readings arrived for a week. A quick word with the Coach helps you pick the routine back up — or spot a broken sync.", + "focusSuffix": "You wanted to keep an eye on: {focus}.", "questionConditions": "Do you have any chronic conditions the Coach should know about?", "questionFocus": "Is there anything specific the Coach should pay attention to?", "questionAllergies": "Do you have any allergies or intolerances?", diff --git a/messages/es.json b/messages/es.json index 4fe64b082..8f816c012 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1599,6 +1599,42 @@ "graceEcho": "Ventana de recordatorio: {minutes} minutos", "reminderTitle": "Recordatorio", "manageChannels": "Gestionar canales de notificación", + "history": { + "title": "Historial del plan", + "currentSince": "Plan actual desde {date}", + "noTimes": "Sin horas fijas", + "manualChip": "Añadida manualmente", + "showErasOne": "Mostrar 1 etapa anterior", + "showErasOther": "Mostrar {count} etapas anteriores", + "hideEras": "Ocultar etapas anteriores", + "addEra": "Añadir etapa anterior", + "addEraTitle": "Añadir etapa anterior", + "addEraDescription": "Registra a qué horas se tomaba antes este medicamento, por ejemplo antes de ajustarlo aquí.", + "editAria": "Editar etapa", + "editEraTitle": "Editar etapa", + "editEraDescription": "Ajusta las horas de toma y el periodo de esta etapa.", + "archivedEditHint": "Esta etapa se registró automáticamente: tu cambio corrige el historial registrado; el original se conserva internamente como constancia.", + "updatedToast": "Etapa actualizada", + "updateError": "No se pudo actualizar la etapa. No debe solaparse con otras etapas y debe terminar antes del plan actual.", + "timesLabel": "Horas de toma", + "fromLabel": "Desde", + "untilLabel": "Hasta", + "untilHint": "«Hasta» es el día en que empezó a regir el plan siguiente (exclusivo).", + "validationTimes": "Indica al menos una hora.", + "validationRangeMissing": "Indica las fechas de inicio y fin.", + "validationRangeOrder": "La fecha de inicio debe ser anterior a la fecha de fin.", + "cancel": "Cancelar", + "submit": "Guardar etapa", + "createdToast": "Etapa anterior guardada", + "createError": "No se pudo guardar la etapa. No debe solaparse con etapas existentes y debe terminar antes del plan actual.", + "deletedToast": "Etapa eliminada", + "deleteError": "No se pudo eliminar la etapa", + "deleteAria": "Eliminar etapa", + "deleteConfirmTitle": "¿Eliminar la etapa?", + "deleteConfirmBody": "El historial y la adherencia de este período volverán a calcularse con el plan actual.", + "deleteConfirmAction": "Eliminar", + "loadError": "No se pudo cargar el historial del plan" + }, "save": "Guardar horario", "saved": "Horario guardado", "failed": "No se pudo guardar el horario", @@ -1995,7 +2031,6 @@ "thinking": "Pensando…", "composerPlaceholder": "Pregúntame sobre tus datos…", "nudgeBubbleLabel": "Nuevo mensaje del coach — abrir el chat", - "selfContextChipsLabel": "Al Coach le gustaría saber:", "selfContextAdopt": { "offer": "¿Añadir esta respuesta a tu información personal?", "confirm": "Añadir", @@ -2004,8 +2039,25 @@ "duplicate": "Ya está en tu información personal", "failed": "No se pudo añadir. Inténtalo de nuevo." }, - "selfContextChipDismiss": "Descartar pregunta", - "selfContextChipDismissError": "No se pudo descartar la pregunta", + "guided": { + "offerTitle": "El Coach tiene {count} preguntas para ti", + "offerTitleOne": "El Coach tiene una pregunta para ti", + "offerBody": "Respuestas breves afinan tu información personal — una pregunta a la vez, aquí en el chat.", + "offerStart": "Responder ahora", + "offerLater": "Más tarde", + "offerDismiss": "No volver a preguntar", + "dismissError": "No se pudieron descartar las preguntas. Inténtalo de nuevo.", + "progress": "Pregunta {current} de {total}", + "skip": "Omitir", + "later": "Más tarde", + "dismissRemaining": "No volver a preguntar", + "answerPlaceholder": "Tu respuesta…", + "summaryTitle": "¡Listo — gracias por tus respuestas!", + "summaryAnswered": "{answered} de {total} respondidas", + "summaryAdopted": "{adopted} añadidas a tu información personal", + "summaryAdoptedNone": "no se añadió nada nuevo a tu información personal", + "summaryLink": "Ver tu información personal" + }, "voiceComingSoon": "La entrada por voz llega con la app de iOS en la v1.5.", "threadEmpty": "Pregúntame algo sobre tus tendencias, medicamentos o mediciones para empezar.", "historyTitle": "Conversaciones", @@ -2082,6 +2134,8 @@ "settingsVerbosityBrief": "Breve", "settingsVerbosityDefault": "Estándar", "settingsVerbosityDetailed": "Detallado", + "settingsSourcesPointer": "Los grupos de datos que el coach puede usar y la ventana de análisis predeterminada se gestionan directamente en el chat del coach (panel «En qué me baso»).", + "settingsSourcesPointerLink": "Abrir el coach", "settingsExcludeLabel": "Excluir métricas", "settingsExcludeHint": "El coach no verá los datos de las métricas seleccionadas.", "settingsEvidenceLabel": "Mostrar evidencia por defecto", @@ -3461,7 +3515,7 @@ "integrationsHint": "Conecta o desconecta fuentes de datos en la página de integraciones.", "integrationsHintLink": "Abrir integraciones", "cardTitle": "Prioridad de fuentes por métrica", - "help": "Cuando más de una fuente entrega el mismo valor el mismo día (p. ej. Withings y Apple Health informan ambos 1200 pasos), la fuente superior cuenta como la lectura canónica. Las demás fuentes se conservan para la traza de auditoría.", + "help": "Si varias fuentes entregan el mismo valor el mismo día, la fuente superior cuenta como canónica. Las demás se conservan para la traza de auditoría.", "resetDefaults": "Restablecer valores predeterminados", "moveUp": "Subir", "moveDown": "Bajar", @@ -3821,7 +3875,7 @@ }, "adminOpenai": { "title": "OpenAI de administrador", - "body": "Clave de OpenAI proporcionada por el operador. Se usa como último recurso cuando no hay ningún proveedor personal configurado. No hay nada que configurar en esta fila — su visibilidad significa que el operador la ha habilitado.", + "body": "Clave de OpenAI proporcionada por el operador, usada como último recurso sin proveedor personal. No hay nada que configurar aquí.", "notConfigured": "El operador no ha configurado una clave de OpenAI compartida en esta instancia." }, "testProvider": "Probar el proveedor activo", @@ -4231,6 +4285,7 @@ "subtitle": "Estado del sistema, usuarios y ajustes", "settingsSaveError": "No se pudieron guardar los ajustes", "systemStatus": "Estado del sistema", + "systemStatusDescription": "Estado de ejecución de esta instancia: base de datos, worker y contadores.", "database": "Base de datos", "databaseConnected": "Conectada", "databaseError": "Error", @@ -4252,6 +4307,23 @@ "empty": "Muestreo iniciado: los datos aparecen en 1–2 minutos.", "loadError": "No se pudieron cargar las métricas del host. Prueba a recargar la página." }, + "aiServerKey": { + "title": "Clave de IA del servidor", + "description": "Una clave de proveedor a nivel de operador a la que puede recurrir cualquier cuenta cuando no hay una clave personal configurada.", + "byokHint": "La clave propia del usuario (BYOK, inicio de sesión de ChatGPT o proveedor local) siempre tiene prioridad — la clave del servidor es el último paso de la cadena de proveedores.", + "consentHint": "Las solicitudes con la clave del servidor requieren el consentimiento de IA activo del usuario; sin un recibo de consentimiento no se envía nada.", + "statusConfigured": "Configurada", + "statusNotConfigured": "Sin configurar", + "keyLabel": "Clave de API", + "keyPlaceholder": "sk-...", + "keyPlaceholderSet": "Guardada ({preview}) — introduce una clave nueva para reemplazarla", + "modelLabel": "Modelo", + "baseUrlLabel": "URL base", + "save": "Guardar", + "remove": "Eliminar clave", + "saved": "Ajustes de la clave de IA del servidor guardados", + "error": "No se pudo guardar la clave de IA del servidor" + }, "aiQuality": { "title": "Calidad de los análisis", "loading": "Cargando resumen de comentarios...", @@ -4287,6 +4359,7 @@ "workerStopped": "Detenido", "lastReminderCheck": "Última comprobación de recordatorios", "appSettings": "Ajustes de la aplicación", + "appSettingsDescription": "Valores globales por defecto para registro, idioma y zona horaria de cuentas nuevas.", "registrationEnabled": "Permitir el registro", "registrationEnabledDescription": "Cuando está desactivado, los nuevos usuarios no pueden registrarse.", "defaultLanguage": "Idioma predeterminado", @@ -4417,6 +4490,7 @@ "revokeError": "No se pudo revocar la invitación." }, "userManagement": "Gestión de usuarios", + "userManagementDescription": "Gestiona las cuentas: roles, restablecimientos de contraseña y cierres de sesión forzados.", "userName": "Nombre", "userEmail": "Correo electrónico", "userRole": "Rol", @@ -4438,6 +4512,7 @@ "reset": "Restablecer", "loadingUsers": "Cargando usuarios...", "apiTokens": "Tokens de API", + "apiTokensDescription": "Todos los tokens de API activos y revocados de todas las cuentas.", "tokenUser": "Usuario", "tokenName": "Nombre", "tokenPermissions": "Permisos", @@ -4450,6 +4525,7 @@ "tokensEmptyTitle": "Aún no hay tokens de API", "tokensEmptyDescription": "Los tokens se emiten cuando un cliente nativo inicia sesión o desde Ajustes → Cuenta.", "loginOverview": "Resumen de inicios de sesión", + "loginOverviewDescription": "Eventos de inicio de sesión y autenticación de todas las cuentas, filtrables y exportables como CSV.", "allAuthEvents": "Todos los eventos de autenticación", "failedOnly": "Solo fallidos", "loginEmptyDescription": "Aquí aparecerán los inicios de sesión, los cierres de sesión y los cambios de passkey.", @@ -5109,9 +5185,20 @@ }, "coachNudge": { "title": "Coach proactivo", - "description": "Se pone en contacto como máximo una vez por semana cuando tus datos sugieren consultar al Coach, por ejemplo baja adherencia a la medicación o una media semanal de tensión elevada.", + "description": "Se pone en contacto cuando tus datos sugieren consultar al Coach, por ejemplo baja adherencia a la medicación o una media semanal de tensión elevada. Los temas y la frecuencia son ajustables.", "toggleAria": "Activar o desactivar los avisos proactivos del Coach", - "statusOn": "Activo — máx. 1 vez por semana", + "statusOn": "Activo", + "groupsLabel": "Temas", + "groupMedication": "Medicación", + "groupMedicationDesc": "Tomas irregulares durante la última semana.", + "groupVitals": "Constantes vitales", + "groupVitalsDesc": "Tensión, recuperación, tendencia del peso, sueño.", + "groupRoutine": "Rutina", + "groupRoutineDesc": "Pausas bruscas en los registros y revisiones de la autodescripción.", + "frequencyLabel": "Frecuencia", + "frequencyWeekly": "Como máximo una vez por semana", + "frequencyBiweekly": "Como máximo cada dos semanas", + "frequencyAria": "Seleccionar la frecuencia de los avisos", "statusOff": "Desactivado", "enabledToast": "Avisos proactivos del Coach activados.", "disabledToast": "Avisos proactivos del Coach desactivados.", @@ -5312,6 +5399,13 @@ "scoreBody": "Tu puntuación de recuperación ha caído notablemente esta semana. El Coach puede ayudarte a acotar las posibles causas.", "selfContextTitle": "Ayuda a tu Coach a conocerte mejor", "selfContextBody": "Tu autodescripción está incompleta o lleva tiempo sin actualizarse. Dos minutos en los ajustes mantienen fresco el contexto del Coach.", + "weightTitle": "Tu peso se aleja de tu rango objetivo", + "weightBody": "Tu media semanal se ha alejado más de tu rango objetivo. El Coach puede ayudarte a hacer balance con calma.", + "sleepDebtTitle": "Varias noches cortas esta semana", + "sleepDebtBody": "La mayoría de tus últimas noches quedaron por debajo de tu objetivo de sueño. El Coach puede ayudarte a buscar patrones.", + "measurementGapTitle": "Tu registro se ha quedado en silencio", + "measurementGapBody": "Hace una semana que no llegan valores nuevos. Una breve charla con el Coach ayuda a retomar la rutina — o a detectar una sincronización rota.", + "focusSuffix": "Querías prestar atención a: {focus}.", "questionConditions": "¿Tienes enfermedades crónicas que el Coach deba conocer?", "questionFocus": "¿Hay algo concreto en lo que el Coach deba fijarse?", "questionAllergies": "¿Tienes alergias o intolerancias?", diff --git a/messages/fr.json b/messages/fr.json index bf128ed8b..69e0638d8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1599,6 +1599,42 @@ "graceEcho": "Fenêtre de rappel : {minutes} minutes", "reminderTitle": "Rappel", "manageChannels": "Gérer les canaux de notification", + "history": { + "title": "Historique du plan", + "currentSince": "Plan actuel depuis {date}", + "noTimes": "Aucune heure fixe", + "manualChip": "Ajoutée manuellement", + "showErasOne": "Afficher 1 période antérieure", + "showErasOther": "Afficher {count} périodes antérieures", + "hideEras": "Masquer les périodes antérieures", + "addEra": "Ajouter une période antérieure", + "addEraTitle": "Ajouter une période antérieure", + "addEraDescription": "Notez à quelles heures ce médicament était pris auparavant — par exemple avant de l'ajuster ici.", + "editAria": "Modifier la période", + "editEraTitle": "Modifier la période", + "editEraDescription": "Ajustez les heures de prise et la plage de dates de cette période.", + "archivedEditHint": "Cette période a été enregistrée automatiquement — votre modification corrige l'historique enregistré ; l'original est conservé en interne comme trace.", + "updatedToast": "Période mise à jour", + "updateError": "Impossible de mettre à jour la période. Elle ne doit pas chevaucher d'autres périodes et doit se terminer avant le plan actuel.", + "timesLabel": "Heures de prise", + "fromLabel": "Du", + "untilLabel": "Jusqu'au", + "untilHint": "« Jusqu'au » est le jour où le plan suivant a pris effet (exclusif).", + "validationTimes": "Indiquez au moins une heure.", + "validationRangeMissing": "Indiquez les dates de début et de fin.", + "validationRangeOrder": "La date de début doit précéder la date de fin.", + "cancel": "Annuler", + "submit": "Enregistrer la période", + "createdToast": "Période antérieure enregistrée", + "createError": "Impossible d'enregistrer la période. Elle ne doit pas chevaucher des périodes existantes et doit se terminer avant le plan actuel.", + "deletedToast": "Période supprimée", + "deleteError": "Impossible de supprimer la période", + "deleteAria": "Supprimer la période", + "deleteConfirmTitle": "Supprimer la période ?", + "deleteConfirmBody": "L'historique et l'observance de cette période seront recalculés selon le plan actuel.", + "deleteConfirmAction": "Supprimer", + "loadError": "Impossible de charger l'historique du plan" + }, "save": "Enregistrer l'horaire", "saved": "Horaire enregistré", "failed": "Impossible d'enregistrer l'horaire", @@ -1995,7 +2031,6 @@ "thinking": "Réflexion en cours…", "composerPlaceholder": "Pose-moi une question sur tes données…", "nudgeBubbleLabel": "Nouveau message du coach — ouvrir le chat", - "selfContextChipsLabel": "Le Coach aimerait savoir :", "selfContextAdopt": { "offer": "Ajouter cette réponse à vos informations personnelles ?", "confirm": "Ajouter", @@ -2004,8 +2039,25 @@ "duplicate": "Déjà présent dans vos informations personnelles", "failed": "Échec de l’ajout. Veuillez réessayer." }, - "selfContextChipDismiss": "Ignorer la question", - "selfContextChipDismissError": "Impossible de masquer la question", + "guided": { + "offerTitle": "Le Coach a {count} questions pour vous", + "offerTitleOne": "Le Coach a une question pour vous", + "offerBody": "De courtes réponses affinent vos informations personnelles — une question à la fois, ici dans le chat.", + "offerStart": "Répondre maintenant", + "offerLater": "Plus tard", + "offerDismiss": "Ne plus demander", + "dismissError": "Impossible d’écarter les questions. Veuillez réessayer.", + "progress": "Question {current} sur {total}", + "skip": "Passer", + "later": "Plus tard", + "dismissRemaining": "Ne plus demander", + "answerPlaceholder": "Votre réponse…", + "summaryTitle": "C’est fait — merci pour vos réponses !", + "summaryAnswered": "{answered} sur {total} répondues", + "summaryAdopted": "{adopted} ajoutées à vos informations personnelles", + "summaryAdoptedNone": "rien de nouveau ajouté à vos informations personnelles", + "summaryLink": "Voir vos informations personnelles" + }, "voiceComingSoon": "La saisie vocale arrive avec l’app iOS en v1.5.", "threadEmpty": "Pose une question sur tes tendances, médicaments ou mesures pour démarrer.", "historyTitle": "Conversations", @@ -2082,6 +2134,8 @@ "settingsVerbosityBrief": "Bref", "settingsVerbosityDefault": "Standard", "settingsVerbosityDetailed": "Détaillé", + "settingsSourcesPointer": "Les groupes de données que le coach peut utiliser et la fenêtre d'analyse par défaut se gèrent directement dans le chat du coach (volet « Ce sur quoi je m'appuie »).", + "settingsSourcesPointerLink": "Ouvrir le coach", "settingsExcludeLabel": "Exclure des métriques", "settingsExcludeHint": "Le coach ne verra pas les données des métriques sélectionnées.", "settingsEvidenceLabel": "Afficher les preuves par défaut", @@ -3461,7 +3515,7 @@ "integrationsHint": "Connectez ou déconnectez des sources de données sur la page Intégrations.", "integrationsHintLink": "Ouvrir les intégrations", "cardTitle": "Priorité des sources par métrique", - "help": "Lorsque plusieurs sources fournissent la même valeur le même jour (p. ex. Withings et Apple Health indiquent tous deux 1 200 pas), la source en tête compte comme la lecture canonique. Les autres sources restent enregistrées pour la piste d’audit.", + "help": "Si plusieurs sources fournissent la même valeur le même jour, la source en tête compte comme canonique. Les autres restent enregistrées pour la piste d’audit.", "resetDefaults": "Rétablir les valeurs par défaut", "moveUp": "Monter", "moveDown": "Descendre", @@ -3821,7 +3875,7 @@ }, "adminOpenai": { "title": "OpenAI administrateur", - "body": "Clé OpenAI fournie par l’opérateur. Utilisée en dernier recours lorsqu’aucun fournisseur personnel n’est configuré. Rien à configurer sur cette ligne — sa visibilité signifie que l’opérateur l’a mise en place.", + "body": "Clé OpenAI fournie par l’opérateur, utilisée en dernier recours sans fournisseur personnel. Rien à configurer ici.", "notConfigured": "L’opérateur n’a pas configuré de clé OpenAI partagée sur cette instance." }, "testProvider": "Tester le fournisseur actif", @@ -4231,6 +4285,7 @@ "subtitle": "État du système, utilisateurs et paramètres", "settingsSaveError": "Échec de l'enregistrement des paramètres", "systemStatus": "État du système", + "systemStatusDescription": "État d’exécution de cette instance : base de données, worker et compteurs.", "database": "Base de données", "databaseConnected": "Connectée", "databaseError": "Erreur", @@ -4252,6 +4307,23 @@ "empty": "Échantillonnage démarré — les données apparaissent dans 1 à 2 minutes.", "loadError": "Échec du chargement des métriques de l'hôte. Essayez d'actualiser la page." }, + "aiServerKey": { + "title": "Clé IA du serveur", + "description": "Une clé de fournisseur à l'échelle de l'opérateur sur laquelle chaque compte peut se rabattre lorsqu'aucune clé personnelle n'est configurée.", + "byokHint": "La clé personnelle de l'utilisateur (BYOK, connexion ChatGPT ou fournisseur local) a toujours la priorité — la clé du serveur est la dernière étape de la chaîne de fournisseurs.", + "consentHint": "Les requêtes via la clé du serveur nécessitent le consentement IA actif de l'utilisateur ; sans justificatif de consentement, rien n'est envoyé.", + "statusConfigured": "Configurée", + "statusNotConfigured": "Non configurée", + "keyLabel": "Clé API", + "keyPlaceholder": "sk-...", + "keyPlaceholderSet": "Enregistrée ({preview}) — saisissez une nouvelle clé pour la remplacer", + "modelLabel": "Modèle", + "baseUrlLabel": "URL de base", + "save": "Enregistrer", + "remove": "Supprimer la clé", + "saved": "Paramètres de la clé IA du serveur enregistrés", + "error": "Échec de l'enregistrement de la clé IA du serveur" + }, "aiQuality": { "title": "Qualité des analyses", "loading": "Chargement du résumé des retours...", @@ -4287,6 +4359,7 @@ "workerStopped": "Arrêté", "lastReminderCheck": "Dernière vérification des rappels", "appSettings": "Paramètres de l'application", + "appSettingsDescription": "Valeurs globales par défaut pour l’inscription, la langue et le fuseau horaire des nouveaux comptes.", "registrationEnabled": "Autoriser l'inscription", "registrationEnabledDescription": "Lorsque cette option est désactivée, les nouveaux utilisateurs ne peuvent pas s'inscrire.", "defaultLanguage": "Langue par défaut", @@ -4417,6 +4490,7 @@ "revokeError": "Impossible de révoquer l'invitation." }, "userManagement": "Gestion des utilisateurs", + "userManagementDescription": "Gérez les comptes : rôles, réinitialisations de mot de passe et déconnexions forcées.", "userName": "Nom", "userEmail": "E-mail", "userRole": "Rôle", @@ -4438,6 +4512,7 @@ "reset": "Réinitialiser", "loadingUsers": "Chargement des utilisateurs...", "apiTokens": "Jetons d'API", + "apiTokensDescription": "Tous les jetons d’API actifs et révoqués de l’ensemble des comptes.", "tokenUser": "Utilisateur", "tokenName": "Nom", "tokenPermissions": "Autorisations", @@ -4450,6 +4525,7 @@ "tokensEmptyTitle": "Aucun jeton d'API pour l'instant", "tokensEmptyDescription": "Les jetons sont émis lorsqu'un client natif se connecte ou via Paramètres → Compte.", "loginOverview": "Aperçu des connexions", + "loginOverviewDescription": "Événements de connexion et d’authentification de tous les comptes, filtrables et exportables en CSV.", "allAuthEvents": "Tous les événements d'authentification", "failedOnly": "Échecs uniquement", "loginEmptyDescription": "Les connexions, déconnexions et modifications de passkey apparaîtront ici.", @@ -5109,9 +5185,20 @@ }, "coachNudge": { "title": "Coach proactif", - "description": "Vous contacte au plus une fois par semaine lorsque vos données suggèrent de consulter le Coach — par exemple une faible observance ou une moyenne hebdomadaire de tension élevée.", + "description": "Vous contacte lorsque vos données suggèrent de consulter le Coach — par exemple une faible observance ou une moyenne hebdomadaire de tension élevée. Les thèmes et la fréquence sont réglables.", "toggleAria": "Activer ou désactiver les rappels proactifs du Coach", - "statusOn": "Actif — max. 1 fois par semaine", + "statusOn": "Actif", + "groupsLabel": "Thèmes", + "groupMedication": "Médication", + "groupMedicationDesc": "Prises irrégulières au cours de la dernière semaine.", + "groupVitals": "Constantes vitales", + "groupVitalsDesc": "Tension, récupération, tendance du poids, sommeil.", + "groupRoutine": "Routine", + "groupRoutineDesc": "Arrêts brusques des mesures et bilans de l'auto-description.", + "frequencyLabel": "Fréquence", + "frequencyWeekly": "Au plus une fois par semaine", + "frequencyBiweekly": "Au plus toutes les deux semaines", + "frequencyAria": "Choisir la fréquence des rappels", "statusOff": "Désactivé", "enabledToast": "Rappels proactifs du Coach activés.", "disabledToast": "Rappels proactifs du Coach désactivés.", @@ -5312,6 +5399,13 @@ "scoreBody": "Votre score de récupération a nettement chuté cette semaine. Le Coach peut vous aider à cerner les causes possibles.", "selfContextTitle": "Aidez votre Coach à mieux vous connaître", "selfContextBody": "Votre auto-description est incomplète ou n'a pas été mise à jour depuis un moment. Deux minutes dans les réglages gardent le contexte du Coach à jour.", + "weightTitle": "Votre poids s'éloigne de votre zone cible", + "weightBody": "Votre moyenne hebdomadaire s'est encore éloignée de votre zone cible. Le Coach peut vous aider à faire le point sereinement.", + "sleepDebtTitle": "Plusieurs nuits courtes cette semaine", + "sleepDebtBody": "La plupart de vos dernières nuits sont restées sous votre objectif de sommeil. Le Coach peut vous aider à chercher des régularités.", + "measurementGapTitle": "Votre journal est devenu silencieux", + "measurementGapBody": "Aucune nouvelle valeur depuis une semaine. Un bref échange avec le Coach aide à reprendre la routine — ou à repérer une synchronisation en panne.", + "focusSuffix": "Vous vouliez surveiller : {focus}.", "questionConditions": "Avez-vous des maladies chroniques que le Coach devrait connaître ?", "questionFocus": "Y a-t-il quelque chose de précis auquel le Coach devrait veiller ?", "questionAllergies": "Avez-vous des allergies ou des intolérances ?", diff --git a/messages/it.json b/messages/it.json index b47026e16..c43b80afa 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1599,6 +1599,42 @@ "graceEcho": "Finestra di promemoria: {minutes} minuti", "reminderTitle": "Promemoria", "manageChannels": "Gestisci i canali di notifica", + "history": { + "title": "Cronologia del piano", + "currentSince": "Piano attuale dal {date}", + "noTimes": "Nessun orario fisso", + "manualChip": "Aggiunta manualmente", + "showErasOne": "Mostra 1 periodo precedente", + "showErasOther": "Mostra {count} periodi precedenti", + "hideEras": "Nascondi i periodi precedenti", + "addEra": "Aggiungi periodo precedente", + "addEraTitle": "Aggiungi periodo precedente", + "addEraDescription": "Registra a che ora veniva assunto questo farmaco in passato, ad esempio prima di modificarlo qui.", + "editAria": "Modifica periodo", + "editEraTitle": "Modifica periodo", + "editEraDescription": "Modifica gli orari di assunzione e l'intervallo di date di questo periodo.", + "archivedEditHint": "Questo periodo è stato registrato automaticamente: la modifica corregge la cronologia registrata; l'originale viene conservato internamente come traccia.", + "updatedToast": "Periodo aggiornato", + "updateError": "Impossibile aggiornare il periodo. Non deve sovrapporsi ad altri periodi e deve terminare prima del piano attuale.", + "timesLabel": "Orari di assunzione", + "fromLabel": "Dal", + "untilLabel": "Fino al", + "untilHint": "«Fino al» è il giorno in cui è entrato in vigore il piano successivo (escluso).", + "validationTimes": "Indica almeno un orario.", + "validationRangeMissing": "Indica le date di inizio e fine.", + "validationRangeOrder": "La data di inizio deve precedere la data di fine.", + "cancel": "Annulla", + "submit": "Salva periodo", + "createdToast": "Periodo precedente salvato", + "createError": "Impossibile salvare il periodo. Non deve sovrapporsi a periodi esistenti e deve terminare prima del piano attuale.", + "deletedToast": "Periodo eliminato", + "deleteError": "Impossibile eliminare il periodo", + "deleteAria": "Elimina periodo", + "deleteConfirmTitle": "Eliminare il periodo?", + "deleteConfirmBody": "La cronologia e l'aderenza di questo intervallo verranno ricalcolate in base al piano attuale.", + "deleteConfirmAction": "Elimina", + "loadError": "Impossibile caricare la cronologia del piano" + }, "save": "Salva programma", "saved": "Programma salvato", "failed": "Impossibile salvare il programma", @@ -1995,7 +2031,6 @@ "thinking": "Sto pensando…", "composerPlaceholder": "Chiedimi qualcosa sui tuoi dati…", "nudgeBubbleLabel": "Nuovo messaggio dal coach — apri la chat", - "selfContextChipsLabel": "Il Coach vorrebbe sapere:", "selfContextAdopt": { "offer": "Aggiungere questa risposta alle tue informazioni personali?", "confirm": "Aggiungi", @@ -2004,8 +2039,25 @@ "duplicate": "Già presente nelle tue informazioni personali", "failed": "Aggiunta non riuscita. Riprova." }, - "selfContextChipDismiss": "Ignora la domanda", - "selfContextChipDismissError": "Impossibile ignorare la domanda", + "guided": { + "offerTitle": "Il Coach ha {count} domande per te", + "offerTitleOne": "Il Coach ha una domanda per te", + "offerBody": "Risposte brevi affinano le tue informazioni personali — una domanda alla volta, qui in chat.", + "offerStart": "Rispondi ora", + "offerLater": "Più tardi", + "offerDismiss": "Non chiedere più", + "dismissError": "Impossibile scartare le domande. Riprova.", + "progress": "Domanda {current} di {total}", + "skip": "Salta", + "later": "Più tardi", + "dismissRemaining": "Non chiedere più", + "answerPlaceholder": "La tua risposta…", + "summaryTitle": "Fatto — grazie per le tue risposte!", + "summaryAnswered": "{answered} di {total} risposte", + "summaryAdopted": "{adopted} aggiunte alle tue informazioni personali", + "summaryAdoptedNone": "nulla di nuovo aggiunto alle tue informazioni personali", + "summaryLink": "Vedi le tue informazioni personali" + }, "voiceComingSoon": "L’input vocale arriva con l’app iOS in v1.5.", "threadEmpty": "Chiedimi qualcosa sui tuoi trend, farmaci o misurazioni per iniziare.", "historyTitle": "Conversazioni", @@ -2082,6 +2134,8 @@ "settingsVerbosityBrief": "Breve", "settingsVerbosityDefault": "Standard", "settingsVerbosityDetailed": "Dettagliata", + "settingsSourcesPointer": "I gruppi di dati che il coach può usare e la finestra di analisi predefinita si gestiscono direttamente nella chat del coach (pannello «Su cosa mi baso»).", + "settingsSourcesPointerLink": "Apri il coach", "settingsExcludeLabel": "Escludi metriche", "settingsExcludeHint": "Il coach non vede i dati delle metriche selezionate.", "settingsEvidenceLabel": "Mostra le evidenze per default", @@ -3461,7 +3515,7 @@ "integrationsHint": "Collega o scollega le fonti dati nella pagina Integrazioni.", "integrationsHintLink": "Apri le integrazioni", "cardTitle": "Priorità delle fonti per metrica", - "help": "Quando più fonti forniscono lo stesso valore nello stesso giorno (ad es. Withings e Apple Health riportano entrambe 1.200 passi), la fonte in cima conta come lettura canonica. Le altre fonti restano memorizzate per la traccia di controllo.", + "help": "Se più fonti forniscono lo stesso valore nello stesso giorno, la fonte in cima conta come canonica. Le altre restano memorizzate per la traccia di controllo.", "resetDefaults": "Ripristina i valori predefiniti", "moveUp": "Sposta su", "moveDown": "Sposta giù", @@ -3821,7 +3875,7 @@ }, "adminOpenai": { "title": "OpenAI amministratore", - "body": "Chiave OpenAI fornita dall’operatore. Usata come ultima risorsa quando nessun provider personale è configurato. Non c’è nulla da configurare in questa riga — la sua visibilità indica che l’operatore l’ha attivata.", + "body": "Chiave OpenAI fornita dall’operatore, usata come ultima risorsa senza provider personale. Non c’è nulla da configurare qui.", "notConfigured": "L’operatore non ha configurato una chiave OpenAI condivisa su questa istanza." }, "testProvider": "Prova il provider attivo", @@ -4231,6 +4285,7 @@ "subtitle": "Stato del sistema, utenti e impostazioni", "settingsSaveError": "Impossibile salvare le impostazioni", "systemStatus": "Stato del sistema", + "systemStatusDescription": "Stato di esecuzione dell’istanza: database, worker e conteggi.", "database": "Database", "databaseConnected": "Connesso", "databaseError": "Errore", @@ -4252,6 +4307,23 @@ "empty": "Campionamento avviato: i dati compaiono tra 1–2 minuti.", "loadError": "Impossibile caricare le metriche dell'host. Prova a ricaricare la pagina." }, + "aiServerKey": { + "title": "Chiave IA del server", + "description": "Una chiave del provider a livello di operatore su cui ogni account può ripiegare quando non è configurata una chiave personale.", + "byokHint": "La chiave personale dell'utente (BYOK, accesso ChatGPT o provider locale) ha sempre la precedenza — la chiave del server è l'ultimo passo della catena dei provider.", + "consentHint": "Le richieste tramite la chiave del server richiedono il consenso IA attivo dell'utente; senza una ricevuta di consenso non viene inviato nulla.", + "statusConfigured": "Configurata", + "statusNotConfigured": "Non configurata", + "keyLabel": "Chiave API", + "keyPlaceholder": "sk-...", + "keyPlaceholderSet": "Salvata ({preview}) — inserisci una nuova chiave per sostituirla", + "modelLabel": "Modello", + "baseUrlLabel": "URL di base", + "save": "Salva", + "remove": "Rimuovi chiave", + "saved": "Impostazioni della chiave IA del server salvate", + "error": "Salvataggio della chiave IA del server non riuscito" + }, "aiQuality": { "title": "Qualità delle analisi", "loading": "Caricamento riepilogo feedback...", @@ -4287,6 +4359,7 @@ "workerStopped": "Arrestato", "lastReminderCheck": "Ultimo controllo dei promemoria", "appSettings": "Impostazioni dell'applicazione", + "appSettingsDescription": "Impostazioni globali predefinite per registrazione, lingua e fuso orario dei nuovi account.", "registrationEnabled": "Consentire la registrazione", "registrationEnabledDescription": "Se disattivato, i nuovi utenti non possono registrarsi.", "defaultLanguage": "Lingua predefinita", @@ -4417,6 +4490,7 @@ "revokeError": "Impossibile revocare l'invito." }, "userManagement": "Gestione utenti", + "userManagementDescription": "Gestisci gli account: ruoli, reset delle password e disconnessioni forzate.", "userName": "Nome", "userEmail": "E-mail", "userRole": "Ruolo", @@ -4438,6 +4512,7 @@ "reset": "Reimposta", "loadingUsers": "Caricamento utenti...", "apiTokens": "Token API", + "apiTokensDescription": "Tutti i token API attivi e revocati di tutti gli account.", "tokenUser": "Utente", "tokenName": "Nome", "tokenPermissions": "Autorizzazioni", @@ -4450,6 +4525,7 @@ "tokensEmptyTitle": "Ancora nessun token API", "tokensEmptyDescription": "I token vengono emessi quando un client nativo accede o tramite Impostazioni → Account.", "loginOverview": "Panoramica degli accessi", + "loginOverviewDescription": "Eventi di accesso e autenticazione di tutti gli account, filtrabili ed esportabili in CSV.", "allAuthEvents": "Tutti gli eventi di autenticazione", "failedOnly": "Solo non riusciti", "loginEmptyDescription": "Qui compariranno accessi, disconnessioni e modifiche delle passkey.", @@ -5109,9 +5185,20 @@ }, "coachNudge": { "title": "Coach proattivo", - "description": "Si fa sentire al massimo una volta a settimana quando i tuoi dati suggeriscono di consultare il Coach — ad esempio bassa aderenza terapeutica o una media settimanale della pressione elevata.", + "description": "Si fa sentire quando i tuoi dati suggeriscono di consultare il Coach — ad esempio bassa aderenza terapeutica o una media settimanale della pressione elevata. Temi e frequenza sono regolabili.", "toggleAria": "Attiva o disattiva gli avvisi proattivi del Coach", - "statusOn": "Attivo — max 1 volta a settimana", + "statusOn": "Attivo", + "groupsLabel": "Temi", + "groupMedication": "Terapia", + "groupMedicationDesc": "Assunzioni irregolari nell'ultima settimana.", + "groupVitals": "Parametri vitali", + "groupVitalsDesc": "Pressione, recupero, tendenza del peso, sonno.", + "groupRoutine": "Routine", + "groupRoutineDesc": "Interruzioni improvvise delle misurazioni e controlli dell'autodescrizione.", + "frequencyLabel": "Frequenza", + "frequencyWeekly": "Al massimo una volta a settimana", + "frequencyBiweekly": "Al massimo ogni due settimane", + "frequencyAria": "Seleziona la frequenza degli avvisi", "statusOff": "Disattivato", "enabledToast": "Avvisi proattivi del Coach attivati.", "disabledToast": "Avvisi proattivi del Coach disattivati.", @@ -5312,6 +5399,13 @@ "scoreBody": "Il tuo punteggio di recupero è calato sensibilmente questa settimana. Il Coach può aiutarti a circoscrivere le possibili cause.", "selfContextTitle": "Aiuta il tuo Coach a conoscerti meglio", "selfContextBody": "La tua autodescrizione è incompleta o non viene aggiornata da tempo. Due minuti nelle impostazioni mantengono fresco il contesto del Coach.", + "weightTitle": "Il tuo peso si sta allontanando dall'obiettivo", + "weightBody": "La tua media settimanale si è allontanata ulteriormente dal tuo intervallo obiettivo. Il Coach può aiutarti a fare il punto con calma.", + "sleepDebtTitle": "Diverse notti brevi questa settimana", + "sleepDebtBody": "La maggior parte delle tue ultime notti è rimasta sotto il tuo obiettivo di sonno. Il Coach può aiutarti a cercare schemi ricorrenti.", + "measurementGapTitle": "Il tuo diario è rimasto in silenzio", + "measurementGapBody": "Da una settimana non arrivano nuovi valori. Due parole con il Coach aiutano a riprendere la routine — o a scoprire una sincronizzazione interrotta.", + "focusSuffix": "Volevi tenere d'occhio: {focus}.", "questionConditions": "Hai malattie croniche che il Coach dovrebbe conoscere?", "questionFocus": "C'è qualcosa di specifico a cui il Coach dovrebbe fare attenzione?", "questionAllergies": "Hai allergie o intolleranze?", diff --git a/messages/pl.json b/messages/pl.json index 2cca67983..48785c4f9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1599,6 +1599,42 @@ "graceEcho": "Okno przypomnienia: {minutes} minut", "reminderTitle": "Przypomnienie", "manageChannels": "Zarządzaj kanałami powiadomień", + "history": { + "title": "Historia planu", + "currentSince": "Aktualny plan od {date}", + "noTimes": "Brak stałych godzin", + "manualChip": "Dodano ręcznie", + "showErasOne": "Pokaż 1 wcześniejszy okres", + "showErasOther": "Pokaż wcześniejsze okresy ({count})", + "hideEras": "Ukryj wcześniejsze okresy", + "addEra": "Dodaj wcześniejszy okres", + "addEraTitle": "Dodaj wcześniejszy okres", + "addEraDescription": "Zapisz, o których godzinach ten lek był wcześniej przyjmowany — np. zanim zmieniono go tutaj.", + "editAria": "Edytuj okres", + "editEraTitle": "Edytuj okres", + "editEraDescription": "Dostosuj godziny przyjmowania i zakres dat tego okresu.", + "archivedEditHint": "Ten okres został zapisany automatycznie — Twoja zmiana koryguje zarejestrowaną historię; oryginał pozostaje wewnętrznie jako zapis.", + "updatedToast": "Okres zaktualizowany", + "updateError": "Nie udało się zaktualizować okresu. Nie może nakładać się na inne okresy i musi kończyć się przed aktualnym planem.", + "timesLabel": "Godziny przyjmowania", + "fromLabel": "Od", + "untilLabel": "Do", + "untilHint": "„Do\" to dzień, od którego obowiązywał kolejny plan (wyłącznie).", + "validationTimes": "Podaj co najmniej jedną godzinę.", + "validationRangeMissing": "Podaj daty od i do.", + "validationRangeOrder": "Data od musi być wcześniejsza niż data do.", + "cancel": "Anuluj", + "submit": "Zapisz okres", + "createdToast": "Wcześniejszy okres zapisany", + "createError": "Nie udało się zapisać okresu. Nie może nakładać się na istniejące okresy i musi kończyć się przed aktualnym planem.", + "deletedToast": "Okres usunięty", + "deleteError": "Nie udało się usunąć okresu", + "deleteAria": "Usuń okres", + "deleteConfirmTitle": "Usunąć okres?", + "deleteConfirmBody": "Historia i przestrzeganie zaleceń w tym zakresie zostaną ponownie obliczone według aktualnego planu.", + "deleteConfirmAction": "Usuń", + "loadError": "Nie udało się wczytać historii planu" + }, "save": "Zapisz harmonogram", "saved": "Zapisano harmonogram", "failed": "Nie udało się zapisać harmonogramu", @@ -1995,7 +2031,6 @@ "thinking": "Myślę…", "composerPlaceholder": "Zapytaj o swoje dane…", "nudgeBubbleLabel": "Nowa wiadomość od trenera — otwórz czat", - "selfContextChipsLabel": "Coach chciałby wiedzieć:", "selfContextAdopt": { "offer": "Dodać tę odpowiedź do Twoich informacji o sobie?", "confirm": "Dodaj", @@ -2004,8 +2039,25 @@ "duplicate": "Już znajduje się w informacjach o sobie", "failed": "Nie udało się dodać. Spróbuj ponownie." }, - "selfContextChipDismiss": "Odrzuć pytanie", - "selfContextChipDismissError": "Nie udało się odrzucić pytania", + "guided": { + "offerTitle": "Coach ma {count} pytania do Ciebie", + "offerTitleOne": "Coach ma pytanie do Ciebie", + "offerBody": "Krótkie odpowiedzi doprecyzują Twoje informacje o sobie — jedno pytanie po drugim, tutaj na czacie.", + "offerStart": "Odpowiedz teraz", + "offerLater": "Później", + "offerDismiss": "Nie pytaj więcej", + "dismissError": "Nie udało się odrzucić pytań. Spróbuj ponownie.", + "progress": "Pytanie {current} z {total}", + "skip": "Pomiń", + "later": "Później", + "dismissRemaining": "Nie pytaj więcej", + "answerPlaceholder": "Twoja odpowiedź…", + "summaryTitle": "Gotowe — dzięki za odpowiedzi!", + "summaryAnswered": "odpowiedziano na {answered} z {total}", + "summaryAdopted": "{adopted} dodano do informacji o sobie", + "summaryAdoptedNone": "nie dodano nic nowego do informacji o sobie", + "summaryLink": "Zobacz informacje o sobie" + }, "voiceComingSoon": "Wejście głosowe pojawi się z aplikacją iOS w v1.5.", "threadEmpty": "Zapytaj o swoje trendy, leki lub pomiary, aby zacząć.", "historyTitle": "Rozmowy", @@ -2082,6 +2134,8 @@ "settingsVerbosityBrief": "Krótka", "settingsVerbosityDefault": "Standardowa", "settingsVerbosityDetailed": "Szczegółowa", + "settingsSourcesPointer": "Tym, z jakich grup danych może korzystać coach, oraz domyślnym oknem analizy zarządzasz bezpośrednio w czacie coacha (panel „Z czego korzystam”).", + "settingsSourcesPointerLink": "Otwórz coacha", "settingsExcludeLabel": "Wyklucz metryki", "settingsExcludeHint": "Coach nie zobaczy danych wybranych metryk.", "settingsEvidenceLabel": "Pokazuj dowody domyślnie", @@ -3461,7 +3515,7 @@ "integrationsHint": "Źródła danych podłączysz lub odłączysz na stronie integracji.", "integrationsHintLink": "Otwórz integracje", "cardTitle": "Priorytet źródeł według metryki", - "help": "Gdy więcej niż jedno źródło dostarcza tę samą wartość tego samego dnia (np. Withings i Apple Health raportują po 1200 kroków), źródło na górze liczy się jako odczyt kanoniczny. Pozostałe źródła pozostają zapisane na potrzeby ścieżki audytu.", + "help": "Gdy kilka źródeł dostarcza tę samą wartość tego samego dnia, źródło na górze liczy się jako kanoniczne. Pozostałe pozostają zapisane na potrzeby audytu.", "resetDefaults": "Przywróć wartości domyślne", "moveUp": "W górę", "moveDown": "W dół", @@ -3821,7 +3875,7 @@ }, "adminOpenai": { "title": "OpenAI administratora", - "body": "Klucz OpenAI udostępniony przez operatora. Używany jako ostateczność, gdy nie skonfigurowano żadnego osobistego dostawcy. W tym wierszu nie ma nic do skonfigurowania — jego widoczność oznacza, że operator go skonfigurował.", + "body": "Klucz OpenAI udostępniony przez operatora, używany jako ostateczność bez osobistego dostawcy. Nie ma tu nic do skonfigurowania.", "notConfigured": "Operator nie skonfigurował współdzielonego klucza OpenAI w tej instancji." }, "testProvider": "Przetestuj aktywnego dostawcę", @@ -4231,6 +4285,7 @@ "subtitle": "Stan systemu, użytkownicy i ustawienia", "settingsSaveError": "Nie udało się zapisać ustawień", "systemStatus": "Stan systemu", + "systemStatusDescription": "Stan działania tej instancji: baza danych, worker i liczniki rekordów.", "database": "Baza danych", "databaseConnected": "Połączona", "databaseError": "Błąd", @@ -4252,6 +4307,23 @@ "empty": "Rozpoczęto próbkowanie — dane pojawią się w ciągu 1–2 minut.", "loadError": "Nie udało się załadować metryk hosta. Spróbuj odświeżyć stronę." }, + "aiServerKey": { + "title": "Serwerowy klucz AI", + "description": "Klucz dostawcy na poziomie operatora, z którego może skorzystać każde konto, gdy nie skonfigurowano klucza osobistego.", + "byokHint": "Własny klucz użytkownika (BYOK, logowanie ChatGPT lub dostawca lokalny) zawsze ma pierwszeństwo — klucz serwera jest ostatnim ogniwem łańcucha dostawców.", + "consentHint": "Żądania przez klucz serwera wymagają aktywnej zgody użytkownika na AI; bez potwierdzenia zgody nic nie jest wysyłane.", + "statusConfigured": "Skonfigurowany", + "statusNotConfigured": "Nieskonfigurowany", + "keyLabel": "Klucz API", + "keyPlaceholder": "sk-...", + "keyPlaceholderSet": "Zapisany ({preview}) — wpisz nowy klucz, aby go zastąpić", + "modelLabel": "Model", + "baseUrlLabel": "Adres bazowy URL", + "save": "Zapisz", + "remove": "Usuń klucz", + "saved": "Zapisano ustawienia serwerowego klucza AI", + "error": "Nie udało się zapisać serwerowego klucza AI" + }, "aiQuality": { "title": "Jakość analiz", "loading": "Ładowanie podsumowania opinii...", @@ -4287,6 +4359,7 @@ "workerStopped": "Zatrzymano", "lastReminderCheck": "Ostatnie sprawdzenie przypomnień", "appSettings": "Ustawienia aplikacji", + "appSettingsDescription": "Globalne ustawienia domyślne rejestracji, języka i strefy czasowej nowych kont.", "registrationEnabled": "Zezwól na rejestrację", "registrationEnabledDescription": "Gdy wyłączone, nowi użytkownicy nie mogą się zarejestrować.", "defaultLanguage": "Domyślny język", @@ -4417,6 +4490,7 @@ "revokeError": "Nie udało się unieważnić zaproszenia." }, "userManagement": "Zarządzanie użytkownikami", + "userManagementDescription": "Zarządzaj kontami: role, resety haseł i wymuszone wylogowania.", "userName": "Nazwa", "userEmail": "E-mail", "userRole": "Rola", @@ -4438,6 +4512,7 @@ "reset": "Resetuj", "loadingUsers": "Ładowanie użytkowników...", "apiTokens": "Tokeny API", + "apiTokensDescription": "Wszystkie aktywne i unieważnione tokeny API ze wszystkich kont.", "tokenUser": "Użytkownik", "tokenName": "Nazwa", "tokenPermissions": "Uprawnienia", @@ -4450,6 +4525,7 @@ "tokensEmptyTitle": "Brak tokenów API", "tokensEmptyDescription": "Tokeny są wydawane, gdy klient natywny się loguje, lub przez Ustawienia → Konto.", "loginOverview": "Przegląd logowań", + "loginOverviewDescription": "Zdarzenia logowania i uwierzytelniania wszystkich kont, z filtrowaniem i eksportem CSV.", "allAuthEvents": "Wszystkie zdarzenia uwierzytelniania", "failedOnly": "Tylko nieudane", "loginEmptyDescription": "Tutaj pojawią się logowania, wylogowania i zmiany kluczy passkey.", @@ -5109,9 +5185,20 @@ }, "coachNudge": { "title": "Proaktywny Coach", - "description": "Odzywa się najwyżej raz w tygodniu, gdy Twoje dane sugerują zajrzenie do Coacha — np. niska systematyczność przyjmowania leków lub podwyższona tygodniowa średnia ciśnienia.", + "description": "Odzywa się, gdy Twoje dane sugerują zajrzenie do Coacha — np. niska systematyczność przyjmowania leków lub podwyższona tygodniowa średnia ciśnienia. Tematy i częstotliwość można dostosować.", "toggleAria": "Przełącz proaktywne podpowiedzi Coacha", - "statusOn": "Aktywne — maks. 1 raz w tygodniu", + "statusOn": "Aktywne", + "groupsLabel": "Tematy", + "groupMedication": "Leki", + "groupMedicationDesc": "Nieregularne przyjmowanie leków w ostatnim tygodniu.", + "groupVitals": "Parametry życiowe", + "groupVitalsDesc": "Ciśnienie, regeneracja, trend wagi, sen.", + "groupRoutine": "Rutyna", + "groupRoutineDesc": "Nagłe przerwy w pomiarach i przegląd opisu o sobie.", + "frequencyLabel": "Częstotliwość", + "frequencyWeekly": "Najwyżej raz w tygodniu", + "frequencyBiweekly": "Najwyżej co dwa tygodnie", + "frequencyAria": "Wybierz częstotliwość podpowiedzi", "statusOff": "Wyłączone", "enabledToast": "Proaktywne podpowiedzi Coacha włączone.", "disabledToast": "Proaktywne podpowiedzi Coacha wyłączone.", @@ -5312,6 +5399,13 @@ "scoreBody": "Twój wskaźnik regeneracji wyraźnie spadł w tym tygodniu. Coach pomoże Ci zawęzić możliwe przyczyny.", "selfContextTitle": "Pomóż Coachowi lepiej Cię poznać", "selfContextBody": "Twój opis o sobie jest niepełny lub dawno nie był aktualizowany. Dwie minuty w ustawieniach utrzymają kontekst Coacha w aktualnym stanie.", + "weightTitle": "Twoja waga oddala się od zakresu docelowego", + "weightBody": "Twoja tygodniowa średnia jeszcze bardziej oddaliła się od zakresu docelowego. Coach pomoże Ci spokojnie podsumować sytuację.", + "sleepDebtTitle": "Kilka krótkich nocy w tym tygodniu", + "sleepDebtBody": "Większość Twoich ostatnich nocy była poniżej celu snu. Coach pomoże Ci poszukać wzorców.", + "measurementGapTitle": "Twój dziennik ucichł", + "measurementGapBody": "Od tygodnia nie pojawiają się nowe wartości. Krótka rozmowa z Coachem pomoże wrócić do rutyny — albo wykryć zepsutą synchronizację.", + "focusSuffix": "Chciałeś/chciałaś zwracać uwagę na: {focus}.", "questionConditions": "Czy masz choroby przewlekłe, o których Coach powinien wiedzieć?", "questionFocus": "Czy jest coś konkretnego, na co Coach powinien zwracać uwagę?", "questionAllergies": "Czy masz alergie lub nietolerancje?", diff --git a/package.json b/package.json index 0750df209..131710ed1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.16.4", + "version": "1.16.6", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0146_v1165_schedule_revision_source/migration.sql b/prisma/migrations/0146_v1165_schedule_revision_source/migration.sql new file mode 100644 index 000000000..9d3d04533 --- /dev/null +++ b/prisma/migrations/0146_v1165_schedule_revision_source/migration.sql @@ -0,0 +1,19 @@ +-- v1.16.5 — schedule-revision provenance. +-- +-- The Zeitplan tab now lets a user append a PRE-tracking era by hand +-- ("the medication dosed at 07:00/19:00 from March to June, before I +-- edited it here"). Those rows are user-entered, not write-path +-- archives, so they must stay deletable without touching the archive +-- the schedule-replace path mints. +-- +-- `source` records the provenance: +-- 'ARCHIVED' — minted by the wholesale-replace write path (immutable +-- history; never deletable through the API). +-- 'MANUAL' — entered through POST +-- /api/medications/{id}/schedule-revisions (deletable by +-- the owner). +-- +-- Additive + non-destructive: every existing row is a write-path +-- archive, so the default backfills correctly. +ALTER TABLE "medication_schedule_revisions" + ADD COLUMN "source" TEXT NOT NULL DEFAULT 'ARCHIVED'; diff --git a/prisma/migrations/0147_v1166_schedule_revision_supersede/migration.sql b/prisma/migrations/0147_v1166_schedule_revision_supersede/migration.sql new file mode 100644 index 000000000..3b25de727 --- /dev/null +++ b/prisma/migrations/0147_v1166_schedule_revision_supersede/migration.sql @@ -0,0 +1,14 @@ +-- v1.16.6 — audit trail for corrected schedule archives. +-- +-- The Zeitplan tab now lets the owner CORRECT a recorded era. A MANUAL +-- era is edited in place; an ARCHIVED era (minted by the write path) +-- stays immutable — the correction is written as a new MANUAL row and +-- this column on the original points at it. Every era consumer (the +-- splitter, the list endpoint, live-boundary reads) skips superseded +-- rows, so the correction takes the original's place while the +-- original remains as the audit record. +-- +-- Additive + non-destructive: NULL means "active era", which is what +-- every existing row is. +ALTER TABLE "medication_schedule_revisions" + ADD COLUMN "superseded_by_revision_id" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 31f037ec0..2809aeaea 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1470,6 +1470,19 @@ model MedicationScheduleRevision { /// doseWindows, label, dose, reminderGraceMinutes }` objects — enough /// to rebuild a `CanonicalSchedule` list for era minting. payload Json + /// v1.16.5 — provenance. 'ARCHIVED' = minted by the schedule-replace + /// write path (immutable history). 'MANUAL' = user-entered pre-tracking + /// era via POST /api/medications/{id}/schedule-revisions (deletable by + /// the owner). + source String @default("ARCHIVED") + /// v1.16.6 — audit trail for corrected archives. When the owner + /// corrects an ARCHIVED era through PATCH + /// /api/medications/{id}/schedule-revisions/{revisionId}, the route + /// mints a MANUAL replacement and points this column at it. The + /// original row stays as the audit record; every era consumer + /// (splitter, list endpoint, live-boundary reads) skips rows where + /// this is set. + supersededByRevisionId String? @map("superseded_by_revision_id") createdAt DateTime @default(now()) @map("created_at") medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) diff --git a/scripts/promote-digest.ts b/scripts/promote-digest.ts new file mode 100644 index 000000000..4426a958d --- /dev/null +++ b/scripts/promote-digest.ts @@ -0,0 +1,242 @@ +/** + * promote-digest.ts — pin a released image to its immutable digest. + * + * After `docker-publish.yml` pushes `vX.Y.Z` (+ `:latest`) to GHCR, this + * script turns the mutable tag into a deterministic deploy reference: + * + * 1. Resolves the multi-arch index digest for the tag straight from + * the GHCR registry API (anonymous pull token — the image is + * public; no docker daemon needed). + * 2. Verifies the keyless cosign signature the publish workflow + * attached to that digest (GitHub Actions OIDC identity). Fails + * closed: no cosign binary or a failed verification aborts the + * promotion. + * 3. Emits the `HEALTHLOG_IMAGE_REF=@sha256:…` value the compose + * file interpolates into `image:` — and, when a Coolify API token + * is present in the environment, writes it to the application's + * env vars via the documented `/envs` endpoint. Without a token + * it prints the exact curl commands instead of guessing. + * + * Why an env var and not the compose file itself: for a git-based + * "Docker Compose" application Coolify re-reads the compose file from + * the repository checkout on every deploy (`docker_compose_raw` is its + * stored copy of that file and is not part of the application-update + * API surface), so editing the stored compose is not durable. The + * application env vars ARE durable and are interpolated into the + * compose at deploy time — `image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}` + * resolves to the pinned digest when the var is set and to `:latest` + * when it is not. + * + * Usage (one-shot, not a recurring job): + * pnpm dlx tsx scripts/promote-digest.ts v1.16.5 + * + * Env (all optional — without them the script prints commands only): + * COOLIFY_API_TOKEN API token (Coolify → Keys & Tokens → API tokens) + * COOLIFY_API_URL Base URL of the Coolify instance, e.g. https://coolify.example.com + * COOLIFY_APP_UUID UUID of the HealthLog application resource + * PROMOTE_DEPLOY=1 Additionally trigger a deploy after the env update + * + * See docs/ops/deploy.md ("Digest pinning") for the full runbook, + * including rollback (re-point the var at the previous digest). + */ + +import { execFileSync } from "node:child_process"; + +const IMAGE_REPO = "mbombeck/healthlog"; +const IMAGE = `ghcr.io/${IMAGE_REPO}`; +const ENV_KEY = "HEALTHLOG_IMAGE_REF"; +const COSIGN_ISSUER = "https://token.actions.githubusercontent.com"; +const COSIGN_IDENTITY_RE = "^https://github.com/MBombeck/HealthLog/"; + +// The OCI index media types a multi-arch publish can produce. GHCR +// answers the manifest request with the digest of whichever it stores; +// accepting both keeps the script working across buildx versions. +const INDEX_ACCEPT = [ + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json", +].join(", "); + +function fail(message: string): never { + console.error(`\nERROR: ${message}`); + process.exit(1); +} + +async function resolveDigest(tag: string): Promise { + // Anonymous pull token — the package is public, so no credentials. + const tokenRes = await fetch( + `https://ghcr.io/token?service=ghcr.io&scope=repository:${IMAGE_REPO}:pull`, + ); + if (!tokenRes.ok) { + fail(`GHCR token request failed: HTTP ${tokenRes.status}`); + } + const { token } = (await tokenRes.json()) as { token?: string }; + if (!token) fail("GHCR token response carried no token"); + + // HEAD is enough — the digest rides the Docker-Content-Digest header. + const manifestRes = await fetch( + `https://ghcr.io/v2/${IMAGE_REPO}/manifests/${encodeURIComponent(tag)}`, + { + method: "HEAD", + headers: { Authorization: `Bearer ${token}`, Accept: INDEX_ACCEPT }, + }, + ); + if (!manifestRes.ok) { + fail( + `manifest lookup for ${IMAGE}:${tag} failed: HTTP ${manifestRes.status} — does the tag exist on GHCR?`, + ); + } + const digest = manifestRes.headers.get("docker-content-digest"); + if (!digest || !/^sha256:[0-9a-f]{64}$/.test(digest)) { + fail(`registry returned no usable digest (got: ${digest ?? "nothing"})`); + } + return digest; +} + +function verifyCosign(digest: string): void { + const ref = `${IMAGE}@${digest}`; + try { + execFileSync( + "cosign", + [ + "verify", + "--certificate-oidc-issuer", + COSIGN_ISSUER, + "--certificate-identity-regexp", + COSIGN_IDENTITY_RE, + ref, + ], + { stdio: ["ignore", "ignore", "pipe"] }, + ); + } catch (err) { + const e = err as NodeJS.ErrnoException & { stderr?: Buffer }; + if (e.code === "ENOENT") { + fail( + "cosign binary not found. Install it (https://docs.sigstore.dev/cosign/system_config/installation/) — the promotion fails closed without signature verification.", + ); + } + fail( + `cosign verification FAILED for ${ref} — do not deploy this digest.\n${e.stderr?.toString() ?? ""}`, + ); + } + console.log(`cosign signature verified for ${ref}`); +} + +interface CoolifyEnvRow { + uuid: string; + key: string; + value?: string; +} + +async function coolifyRequest( + base: string, + token: string, + path: string, + init?: RequestInit, +): Promise { + return fetch(`${base.replace(/\/$/, "")}/api/v1${path}`, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + redirect: "manual", + signal: AbortSignal.timeout(15_000), + }); +} + +async function updateCoolifyEnv( + base: string, + token: string, + appUuid: string, + value: string, +): Promise { + const list = await coolifyRequest(base, token, `/applications/${appUuid}/envs`); + if (!list.ok) { + fail(`Coolify env list failed: HTTP ${list.status} — check token/UUID`); + } + const rows = (await list.json()) as CoolifyEnvRow[]; + const existing = Array.isArray(rows) + ? rows.find((r) => r.key === ENV_KEY) + : undefined; + + const method = existing ? "PATCH" : "POST"; + const res = await coolifyRequest( + base, + token, + `/applications/${appUuid}/envs`, + { method, body: JSON.stringify({ key: ENV_KEY, value }) }, + ); + if (!res.ok) { + fail(`Coolify env ${method} failed: HTTP ${res.status}`); + } + console.log( + `Coolify env ${ENV_KEY} ${existing ? "updated" : "created"} (was: ${existing?.value ?? "unset"})`, + ); +} + +async function triggerDeploy( + base: string, + token: string, + appUuid: string, +): Promise { + const res = await coolifyRequest(base, token, `/deploy?uuid=${appUuid}`); + if (!res.ok) fail(`deploy trigger failed: HTTP ${res.status}`); + console.log("Deploy triggered. Verify /api/version after it settles."); +} + +function printManualCommands(value: string): void { + console.log(` +No COOLIFY_API_TOKEN in the environment — manual path (fill in host, +token and application UUID; the UUID is in the Coolify URL of the app): + + # set / update the pin (POST creates, PATCH updates an existing key) + curl -sS -X PATCH "https:///api/v1/applications//envs" \\ + -H "Authorization: Bearer " -H "Content-Type: application/json" \\ + -d '{"key":"${ENV_KEY}","value":"${value}"}' + + # redeploy with the pinned digest + curl -sS "https:///api/v1/deploy?uuid=" \\ + -H "Authorization: Bearer " +`); +} + +async function main(): Promise { + const tag = process.argv[2]; + if (!tag || !/^v\d+\.\d+\.\d+/.test(tag)) { + fail("usage: pnpm dlx tsx scripts/promote-digest.ts vX.Y.Z"); + } + + console.log(`Resolving multi-arch digest for ${IMAGE}:${tag} …`); + const digest = await resolveDigest(tag); + console.log(`digest: ${digest}`); + + verifyCosign(digest); + + const value = `@${digest}`; + console.log(`\n${ENV_KEY}=${value}`); + + const token = process.env.COOLIFY_API_TOKEN; + const base = process.env.COOLIFY_API_URL; + const appUuid = process.env.COOLIFY_APP_UUID; + + if (token && base && appUuid) { + await updateCoolifyEnv(base, token, appUuid, value); + if (process.env.PROMOTE_DEPLOY === "1") { + await triggerDeploy(base, token, appUuid); + } else { + console.log( + "Env updated only — trigger the deploy from Coolify (or re-run with PROMOTE_DEPLOY=1).", + ); + } + } else { + if (token || base || appUuid) { + console.log( + "Partial Coolify config — need COOLIFY_API_TOKEN + COOLIFY_API_URL + COOLIFY_APP_UUID together; printing the manual path instead.", + ); + } + printManualCommands(value); + } +} + +main().catch((err) => fail(err instanceof Error ? err.message : String(err))); diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index f7bb0234e..4b49212d2 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -18,6 +18,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { AboutSection } from "@/components/settings/about-section"; import { AiQualitySection } from "@/components/admin/ai-quality-section"; +import { AiServerKeySection } from "@/components/admin/ai-server-key-section"; import { AssistantSection } from "@/components/admin/assistant-section"; import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section"; import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section"; @@ -104,6 +105,7 @@ export function AdminSectionRenderer({ subtitle={t("admin.section.assistant.subtitle")} > + ); case "coach-feedback": diff --git a/src/app/api/admin/ai-settings/__tests__/route.test.ts b/src/app/api/admin/ai-settings/__tests__/route.test.ts new file mode 100644 index 000000000..1ccb38c55 --- /dev/null +++ b/src/app/api/admin/ai-settings/__tests__/route.test.ts @@ -0,0 +1,217 @@ +/** + * v1.16.6 — admin CRUD contract for the operator-wide AI key + * (`/api/admin/ai-settings`). The endpoint predates its UI; the new + * admin "AI server key" card now drives it, so the contract gets + * pinned: admin-only access, encrypted storage, key removal, the + * HTTPS + hostname allowlist on the base URL, and the masked + * `keyPreview` in every response. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + h: T, + ): T => h, + requireAdmin: vi.fn(), + }; +}); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => null), +})); + +vi.mock("@/lib/crypto", () => ({ + encrypt: vi.fn((value: string) => `enc(${value})`), + decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), +})); + +import { GET, PUT } from "../route"; +import { prisma } from "@/lib/db"; +import { requireAdmin, HttpError } from "@/lib/api-handler"; +import { encrypt } from "@/lib/crypto"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const ADMIN_CTX = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "admin-1", + username: "admin", + role: "ADMIN", + } as never, +}; + +function jsonReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/admin/ai-settings", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: new Date(Date.now() + 60_000), + } as never); +}); + +describe("GET /api/admin/ai-settings", () => { + it("propagates the admin gate (non-admin cannot read)", async () => { + vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden")); + await expect(GET()).rejects.toMatchObject({ statusCode: 403 }); + }); + + it("returns defaults + hasKey=false when nothing is configured", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + const res = await GET(); + const body = await res.json(); + expect(body.data).toEqual({ + hasKey: false, + keyPreview: null, + model: "gpt-4o", + baseUrl: "https://api.openai.com/v1", + }); + }); + + it("returns hasKey=true with a masked preview when a key is stored", async () => { + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ + adminAiKeyEncrypted: "enc(sk-test-12cd)", + adminAiModel: "gpt-4o-mini", + adminAiBaseUrl: "https://api.openai.com/v1", + } as never); + const res = await GET(); + const body = await res.json(); + expect(body.data.hasKey).toBe(true); + expect(body.data.keyPreview).toBe("...12cd"); + expect(body.data.model).toBe("gpt-4o-mini"); + // The plaintext key never leaks into the envelope. + expect(JSON.stringify(body)).not.toContain("sk-test-12cd"); + }); +}); + +describe("PUT /api/admin/ai-settings", () => { + function upsertEcho() { + vi.mocked(prisma.appSettings.upsert).mockImplementation( + (async (args: { update: Record }) => ({ + adminAiKeyEncrypted: null, + adminAiModel: "gpt-4o", + adminAiBaseUrl: "https://api.openai.com/v1", + ...args.update, + })) as never, + ); + } + + it("propagates the admin gate (non-admin cannot write)", async () => { + vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden")); + await expect(PUT(jsonReq({ model: "gpt-4o" }))).rejects.toMatchObject({ + statusCode: 403, + }); + }); + + it("encrypts and stores a new key, audit-logged", async () => { + upsertEcho(); + const res = await PUT(jsonReq({ apiKey: "sk-new-key-89ab" })); + const body = await res.json(); + + expect(encrypt).toHaveBeenCalledWith("sk-new-key-89ab"); + expect(prisma.appSettings.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + update: { adminAiKeyEncrypted: "enc(sk-new-key-89ab)" }, + }), + ); + expect(body.data.hasKey).toBe(true); + expect(body.data.keyPreview).toBe("...89ab"); + expect(auditLog).toHaveBeenCalledWith( + "admin.ai-settings.update", + expect.objectContaining({ userId: "admin-1" }), + ); + // The audit detail records THAT the key changed, never the key. + const details = vi.mocked(auditLog).mock.calls[0][1] as { + details: Record; + }; + expect(JSON.stringify(details)).not.toContain("sk-new-key-89ab"); + }); + + it("clears the key when apiKey is empty", async () => { + upsertEcho(); + const res = await PUT(jsonReq({ apiKey: "" })); + const body = await res.json(); + + expect(prisma.appSettings.upsert).toHaveBeenCalledWith( + expect.objectContaining({ update: { adminAiKeyEncrypted: null } }), + ); + expect(body.data.hasKey).toBe(false); + expect(body.data.keyPreview).toBeNull(); + }); + + it("updates model and baseUrl for an allowlisted https host", async () => { + upsertEcho(); + const res = await PUT( + jsonReq({ model: "gpt-4o-mini", baseUrl: "https://openrouter.ai/v1" }), + ); + const body = await res.json(); + expect(body.data.model).toBe("gpt-4o-mini"); + expect(body.data.baseUrl).toBe("https://openrouter.ai/v1"); + }); + + it("rejects a plain-http base URL", async () => { + await expect( + PUT(jsonReq({ baseUrl: "http://api.openai.com/v1" })), + ).rejects.toMatchObject({ statusCode: 422 }); + }); + + it("rejects a base URL host outside the allowlist", async () => { + await expect( + PUT(jsonReq({ baseUrl: "https://attacker.example/v1" })), + ).rejects.toMatchObject({ statusCode: 422 }); + expect(prisma.appSettings.upsert).not.toHaveBeenCalled(); + }); + + it("rejects an empty model", async () => { + await expect(PUT(jsonReq({ model: " " }))).rejects.toMatchObject({ + statusCode: 422, + }); + }); + + it("rejects a body with no recognised fields", async () => { + await expect(PUT(jsonReq({ unrelated: true }))).rejects.toMatchObject({ + statusCode: 422, + }); + }); + + it("returns 429 when the admin surface is rate-limited", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never); + await expect(PUT(jsonReq({ model: "gpt-4o" }))).rejects.toMatchObject({ + statusCode: 429, + }); + }); +}); diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index 8632c2e7d..3137ecb03 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -91,7 +91,7 @@ describe("GET /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }); }); @@ -112,7 +112,7 @@ describe("GET /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }); }); @@ -135,7 +135,7 @@ describe("GET /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }); }); }); @@ -169,7 +169,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }); expect(prisma.user.update).toHaveBeenCalledWith({ @@ -179,7 +179,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }, }, }); @@ -193,13 +193,13 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }, next: { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }, changed: ["medication"], }), @@ -261,7 +261,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }, }, }); @@ -287,7 +287,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }); expect(prisma.user.update).toHaveBeenCalledWith({ @@ -297,7 +297,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }, }, }); @@ -326,7 +326,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 9 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }, }, }); diff --git a/src/app/api/coach/about-me/adopt/__tests__/route.test.ts b/src/app/api/coach/about-me/adopt/__tests__/route.test.ts new file mode 100644 index 000000000..09e0fea66 --- /dev/null +++ b/src/app/api/coach/about-me/adopt/__tests__/route.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * POST /api/coach/about-me/adopt — locks the read-modify-write. + * + * The original handler read the self-context through the global client, + * computed the appended value, then upserted: two concurrent adoptions + * could read the same base value and one append silently lost. The + * suite pins the transactional shape — empty upsert → `FOR UPDATE` row + * lock → read through the SAME `tx` client → single-column update — + * so a refactor can't quietly fall back to the racy read. + */ + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +const tx = { + userHealthProfile: { + upsert: vi.fn(), + update: vi.fn(), + findUnique: vi.fn(), + }, + $queryRaw: vi.fn(), +}; + +vi.mock("@/lib/db", () => ({ + prisma: { + $transaction: vi.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)), + }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(async () => ({ allowed: true })), + rateLimitHeaders: vi.fn(() => ({})), +})); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: (s: string) => Buffer.from(s), +})); +vi.mock("@/lib/ai/coach/about-me", () => ({ + getSelfContextForUser: vi.fn(), +})); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), + apiError: (error: string, status: number) => ({ data: null, error, status }), + getClientIp: () => "127.0.0.1", + returnAllZodIssues: (_e: unknown, status: number) => ({ + data: null, + error: "validation", + status, + }), + safeJson: async (req: Request) => ({ data: await req.json(), error: null }), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSelfContextForUser } from "@/lib/ai/coach/about-me"; + +const transaction = prisma.$transaction as ReturnType; +const getCtx = getSelfContextForUser as ReturnType; + +function adoptRequest(body: { question: string; answer: string }): Request { + return new Request("http://localhost/api/coach/about-me/adopt", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +type Envelope = { data: unknown; error: string | null; status: number }; +const post = POST as unknown as (req: Request) => Promise; + +const emptyCtx = { + aboutMe: null, + conditions: null, + allergies: null, + coachFocus: null, +}; + +describe("POST /api/coach/about-me/adopt", () => { + beforeEach(() => { + vi.clearAllMocks(); + tx.userHealthProfile.upsert.mockResolvedValue({ id: "p1" }); + tx.userHealthProfile.update.mockResolvedValue({ updatedAt: new Date() }); + tx.$queryRaw.mockResolvedValue([{ id: "p1" }]); + }); + + it("runs the read-modify-write inside one transaction with a row lock", async () => { + getCtx.mockResolvedValue(emptyCtx); + + const res = await post( + adoptRequest({ question: "What matters?", answer: "evening walks" }), + ); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ adopted: true, field: "coachFocus" }); + expect(transaction).toHaveBeenCalledTimes(1); + // Row pinned, then locked FOR UPDATE before the read. + expect(tx.userHealthProfile.upsert).toHaveBeenCalledWith({ + where: { userId: "u1" }, + create: { userId: "u1" }, + update: {}, + select: { id: true }, + }); + expect(tx.$queryRaw).toHaveBeenCalledTimes(1); + // The read goes through the SAME tx client, not the global one. + expect(getCtx).toHaveBeenCalledWith("u1", tx); + // Exactly one encrypted column written. + const data = tx.userHealthProfile.update.mock.calls[0][0].data; + expect(Object.keys(data)).toEqual(["coachFocusEncrypted"]); + }); + + it("dedupes inside the locked section without writing", async () => { + getCtx.mockResolvedValue({ ...emptyCtx, coachFocus: "Evening walks" }); + + const res = await post( + adoptRequest({ question: "What matters?", answer: "evening walks" }), + ); + + expect(res.data).toEqual({ + adopted: false, + field: "coachFocus", + reason: "duplicate", + }); + expect(tx.userHealthProfile.update).not.toHaveBeenCalled(); + }); + + it("appends to an existing value read under the lock", async () => { + getCtx.mockResolvedValue({ ...emptyCtx, allergies: "pollen" }); + + const res = await post( + adoptRequest({ question: "Any allergies?", answer: "penicillin" }), + ); + + expect(res.data).toEqual({ adopted: true, field: "allergies" }); + const data = tx.userHealthProfile.update.mock.calls[0][0].data; + expect(Buffer.from(data.allergiesEncrypted).toString()).toBe( + "pollen\npenicillin", + ); + }); + + it("422s when the overflow target aboutMe is full, still without a write", async () => { + getCtx.mockResolvedValue({ + ...emptyCtx, + coachFocus: "x".repeat(499), + aboutMe: "y".repeat(3999), + }); + + const res = await post( + adoptRequest({ question: "What matters?", answer: "z".repeat(10) }), + ); + + expect(res.status).toBe(422); + expect(tx.userHealthProfile.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/coach/about-me/adopt/route.ts b/src/app/api/coach/about-me/adopt/route.ts index d158bb8b4..ee5463153 100644 --- a/src/app/api/coach/about-me/adopt/route.ts +++ b/src/app/api/coach/about-me/adopt/route.ts @@ -113,56 +113,82 @@ export const POST = apiHandler(async (req: Request) => { } const { question, answer } = parsed.data; - const ctx = await getSelfContextForUser(user.id); - - let field: StructuredField | "aboutMe" = matchSelfContextField(question); const normAnswer = normalise(answer); - // Dedupe BEFORE any write: an answer already present in the target - // field or in the free-text aboutMe never stacks a second copy. - const targetExisting = ctx[field]; - if ( - (targetExisting && normalise(targetExisting).includes(normAnswer)) || - (ctx.aboutMe && normalise(ctx.aboutMe).includes(normAnswer)) - ) { + // Read-modify-write under a row lock: two concurrent adoptions used + // to read the same base value and one append silently lost (or the + // dedupe missed the other's in-flight write). The empty upsert pins + // the row, `FOR UPDATE` serialises every adoption for this user, and + // the read happens through the same `tx` so it sees the locked state. + const outcome = await prisma.$transaction(async (tx) => { + await tx.userHealthProfile.upsert({ + where: { userId: user.id }, + create: { userId: user.id }, + update: {}, + select: { id: true }, + }); + await tx.$queryRaw` + SELECT id FROM user_health_profiles + WHERE user_id = ${user.id} + FOR UPDATE + `; + const ctx = await getSelfContextForUser(user.id, tx); + + let field: StructuredField | "aboutMe" = matchSelfContextField(question); + + // Dedupe BEFORE any write: an answer already present in the target + // field or in the free-text aboutMe never stacks a second copy. + const targetExisting = ctx[field]; + if ( + (targetExisting && normalise(targetExisting).includes(normAnswer)) || + (ctx.aboutMe && normalise(ctx.aboutMe).includes(normAnswer)) + ) { + return { kind: "duplicate" as const, field }; + } + + // Append on its own line; a structured field at its cap overflows + // into the free-text aboutMe (4000 chars) rather than failing the + // adoption outright. + let nextValue = targetExisting ? `${targetExisting}\n${answer}` : answer; + if (nextValue.length > ABOUT_ME_FIELD_MAX_CHARS) { + field = "aboutMe"; + nextValue = ctx.aboutMe ? `${ctx.aboutMe}\n${answer}` : answer; + if (nextValue.length > ABOUT_ME_MAX_CHARS) { + return { kind: "full" as const, field }; + } + } + + // Field-by-field assembly (no mass assignment): exactly one + // encrypted column is written per adoption. + const column = ( + { + conditions: "conditionsEncrypted", + allergies: "allergiesEncrypted", + coachFocus: "coachFocusEncrypted", + aboutMe: "aboutMeEncrypted", + } as const + )[field]; + + await tx.userHealthProfile.update({ + where: { userId: user.id }, + data: { [column]: encryptToBytes(nextValue) }, + select: { updatedAt: true }, + }); + return { kind: "adopted" as const, field }; + }); + + const { field } = outcome; + if (outcome.kind === "duplicate") { annotate({ action: { name: "coach.about_me.adopt_deduped" }, meta: { field }, }); return apiSuccess({ adopted: false, field, reason: "duplicate" }); } - - // Append on its own line; a structured field at its cap overflows - // into the free-text aboutMe (4000 chars) rather than failing the - // adoption outright. - let nextValue = targetExisting ? `${targetExisting}\n${answer}` : answer; - if (nextValue.length > ABOUT_ME_FIELD_MAX_CHARS) { - field = "aboutMe"; - nextValue = ctx.aboutMe ? `${ctx.aboutMe}\n${answer}` : answer; - if (nextValue.length > ABOUT_ME_MAX_CHARS) { - return apiError("Self-context is full", 422); - } + if (outcome.kind === "full") { + return apiError("Self-context is full", 422); } - // Field-by-field assembly (no mass assignment): exactly one encrypted - // column is written per adoption. - const column = ( - { - conditions: "conditionsEncrypted", - allergies: "allergiesEncrypted", - coachFocus: "coachFocusEncrypted", - aboutMe: "aboutMeEncrypted", - } as const - )[field]; - const payload = { [column]: encryptToBytes(nextValue) }; - - await prisma.userHealthProfile.upsert({ - where: { userId: user.id }, - create: { userId: user.id, ...payload }, - update: payload, - select: { updatedAt: true }, - }); - await auditLog("coach.about_me.adopted", { userId: user.id, ipAddress: getClientIp(req), diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 541c4b6c6..73bf1c728 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -179,7 +179,13 @@ async function handleChatRequest(request: NextRequest): Promise { { status: 422 }, ); } - const { conversationId, message, locale: bodyLocale, scope } = parsed.data; + const { + conversationId, + message, + locale: bodyLocale, + scope, + guidedQuestion, + } = parsed.data; // Per-user request-rate ceiling layered in front of the daily budget // gate. The budget catches the cost dimension; this catches the @@ -206,7 +212,14 @@ async function handleChatRequest(request: NextRequest): Promise { }); // ── Refusal short-circuit ──────────────────────────────────── - const refusal = detectRefusal({ message, locale }); + // v1.16.6 — the guided-flow question rides the prompt too, so it + // runs through the same regex bank as the message. The questions + // are server-derived in the honest case; this guards the dishonest + // one (a crafted client using the field as an unchecked channel). + const refusal = detectRefusal({ + message: guidedQuestion ? `${guidedQuestion}\n${message}` : message, + locale, + }); if (refusal.refuse && refusal.message) { annotate({ action: { name: "insights.coach.refused" }, @@ -358,9 +371,20 @@ async function handleChatRequest(request: NextRequest): Promise { const transcript = window .map((t) => `${t.role.toUpperCase()}: ${t.content}`) .join("\n\n"); + // v1.16.6 — guided clarifying-questions flow: the question the user + // is answering exists only as a client-side bubble, so hand it to + // the model as delimited context. The reaction should read as a + // natural reply to the answer, not as a re-ask. + const guidedBlock = guidedQuestion + ? `\nGUIDED QUESTION (user-provided context) +The user's message answers this clarifying question from their self-context questionnaire: +"""${guidedQuestion}""" +React briefly and personally to the answer; do not repeat the question and do not ask it again. +` + : ""; const userPrompt = `SNAPSHOT ${snapshot.snapshotJson || "(no metric data in this user's log yet)"} - +${guidedBlock} CONVERSATION ${transcript} diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 9b6517294..cbca72a9d 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -143,7 +143,9 @@ export const GET = apiHandler( // the LIVE schedule rows, so it must not reach past the newest // revision boundary into a previous era's cadence. const latestRevision = await prisma.medicationScheduleRevision.findFirst({ - where: { medicationId: id }, + // Superseded rows are audit records — a correction may have + // shortened the era, so the boundary reads only active rows. + where: { medicationId: id, supersededByRevisionId: null }, orderBy: { validUntil: "desc" }, select: { validUntil: true }, }); @@ -424,7 +426,9 @@ export const PUT = apiHandler( ) ) { const lastRevision = await prisma.medicationScheduleRevision.findFirst({ - where: { medicationId: id }, + // Chain from the ACTIVE boundary: a superseded row is an + // audit record whose `validUntil` may sit past its correction. + where: { medicationId: id, supersededByRevisionId: null }, orderBy: { validUntil: "desc" }, select: { validUntil: true }, }); diff --git a/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts new file mode 100644 index 000000000..10ead9ff6 --- /dev/null +++ b/src/app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts @@ -0,0 +1,328 @@ +/** + * v1.16.5 — DELETE /api/medications/[id]/schedule-revisions/[revisionId] + * v1.16.6 — PATCH /api/medications/[id]/schedule-revisions/[revisionId] + * + * DELETE removes a MANUAL schedule era (one the owner appended through + * the sibling POST, or a correction minted by PATCH). Write-path + * archives (`source: "ARCHIVED"`) are immutable history — the + * wholesale-replace path minted them from rows that actually existed, + * so deleting one would falsify the ledger; the route refuses with 409. + * Deleting a correction restores the archived original it superseded. + * + * PATCH corrects an era. A MANUAL era updates in place. An ARCHIVED era + * stays untouched as the audit record: the correction is minted as a + * new MANUAL revision and the original's `supersededByRevisionId` + * points at it, so every era consumer reads the correction while the + * recorded history remains inspectable. Validation mirrors the sibling + * POST (bounds order, live-plan ceiling, no overlap with other active + * eras); the check-then-write runs under a `FOR UPDATE` lock on the + * medication row so concurrent era writes serialise. + * + * Auth: requireAuth() + medication ownership; a revision belonging to + * another medication (or another user's medication) surfaces as 404, + * existence channel sealed like every medication sub-route. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { scheduleRevisionUpdateSchema } from "@/lib/validations/schedule-revision"; +import { toRevisionPayloadEntry } from "@/lib/medications/scheduling/schedule-eras"; +import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; +import type { Prisma } from "@/generated/prisma/client"; + +type RouteParams = { params: Promise<{ id: string; revisionId: string }> }; + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id, revisionId } = await params; + + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + + const med = await prisma.medication.findUnique({ + where: { id }, + select: { createdAt: true }, + }); + if (!med) { + return apiError("Medication not found", 404); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = scheduleRevisionUpdateSchema.safeParse(body ?? {}); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + const validFrom = new Date(parsed.data.validFrom); + const validUntil = new Date(parsed.data.validUntil); + + // Sanity floor — mirrors the sibling POST. + if (validFrom.getUTCFullYear() < 1900) { + return apiError("validFrom must be a date after 1900", 422); + } + + // Corrected snapshot, shaped exactly like the POST path: daily + // recurrence at the (schema-deduped, sorted) times, window pulled + // to their min/max. + const times = parsed.data.timesOfDay; + const entry = toRevisionPayloadEntry({ + timesOfDay: times, + windowStart: times[0], + windowEnd: times[times.length - 1], + daysOfWeek: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + label: null, + dose: null, + reminderGraceMinutes: null, + }); + + // Validate-then-write under the per-medication row lock — the same + // serialisation as the sibling POST, so a concurrent era write can + // never slip an overlapping interval past the check. + const outcome = await prisma.$transaction(async (tx) => { + await tx.$queryRaw` + SELECT id FROM medications + WHERE id = ${id} + FOR UPDATE + `; + + const target = await tx.medicationScheduleRevision.findUnique({ + where: { id: revisionId }, + select: { + id: true, + medicationId: true, + source: true, + supersededByRevisionId: true, + validUntil: true, + }, + }); + if (!target || target.medicationId !== id) { + return { + ok: false as const, + status: 404 as const, + error: "Schedule revision not found", + }; + } + if (target.supersededByRevisionId !== null) { + return { + ok: false as const, + status: 409 as const, + error: "This era has already been corrected", + }; + } + + const others = await tx.medicationScheduleRevision.findMany({ + where: { + medicationId: id, + supersededByRevisionId: null, + id: { not: revisionId }, + }, + select: { validFrom: true, validUntil: true }, + }); + + // Live-plan ceiling, mirroring the POST rule: the live era began + // at the newest active `validUntil` (never earlier than + // `createdAt`). The edited era's own recorded end counts — an + // ARCHIVED era adjacent to the live plan may keep its boundary, + // but no correction may extend into tracked live history. + const liveBoundary = [target.validUntil, ...others.map((r) => r.validUntil)].reduce( + (latest, v) => (v > latest ? v : latest), + med.createdAt, + ); + if (validUntil.getTime() > liveBoundary.getTime()) { + return { + ok: false as const, + status: 422 as const, + error: "A corrected era must end before the current plan begins", + }; + } + + // No overlap with any OTHER active interval `[validFrom, + // validUntil)` — the corrected bounds may of course cover the + // era's own previous interval. + const overlaps = others.some( + (r) => + validFrom.getTime() < r.validUntil.getTime() && + validUntil.getTime() > r.validFrom.getTime(), + ); + if (overlaps) { + return { + ok: false as const, + status: 422 as const, + error: "The era overlaps an existing schedule era", + }; + } + + const revisionSelect = { + id: true, + validFrom: true, + validUntil: true, + source: true, + } as const; + + if (target.source === "MANUAL") { + const revision = await tx.medicationScheduleRevision.update({ + where: { id: revisionId }, + data: { + validFrom, + validUntil, + payload: [entry] as unknown as Prisma.InputJsonValue, + }, + select: revisionSelect, + }); + return { ok: true as const, revision, mode: "in_place" as const }; + } + + // ARCHIVED — immutable. Mint the correction as a MANUAL row and + // park the original behind it as the audit record. + const revision = await tx.medicationScheduleRevision.create({ + data: { + medicationId: id, + validFrom, + validUntil, + source: "MANUAL", + payload: [entry] as unknown as Prisma.InputJsonValue, + }, + select: revisionSelect, + }); + await tx.medicationScheduleRevision.update({ + where: { id: revisionId }, + data: { supersededByRevisionId: revision.id }, + }); + return { ok: true as const, revision, mode: "supersede" as const }; + }); + + if (!outcome.ok) { + return apiError(outcome.error, outcome.status); + } + + await auditLog("medication.schedule_revision.updated", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + medicationId: id, + revisionId, + mode: outcome.mode, + ...(outcome.mode === "supersede" && { + correctionRevisionId: outcome.revision.id, + }), + }, + }); + + annotate({ + action: { + name: + outcome.mode === "in_place" + ? "medication.schedule_revision.manual_updated" + : "medication.schedule_revision.archived_corrected", + entity_type: "medication", + entity_id: id, + }, + meta: { + revision_id: outcome.revision.id, + ...(outcome.mode === "supersede" && { + superseded_revision_id: revisionId, + }), + }, + }); + + // The corrected era re-segments history; refresh the pre-aggregated + // compliance rollups asynchronously (best-effort). + await enqueueUserMedicationComplianceBackfill(user.id); + + return apiSuccess({ + id: outcome.revision.id, + validFrom: outcome.revision.validFrom.toISOString(), + validUntil: outcome.revision.validUntil.toISOString(), + source: outcome.revision.source, + entries: [ + { + timesOfDay: times, + label: null, + dose: null, + scheduleType: "SCHEDULED", + }, + ], + }); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id, revisionId } = await params; + + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + + const revision = await prisma.medicationScheduleRevision.findUnique({ + where: { id: revisionId }, + select: { id: true, medicationId: true, source: true }, + }); + if (!revision || revision.medicationId !== id) { + return apiError("Schedule revision not found", 404); + } + + if (revision.source !== "MANUAL") { + return apiError( + "Only manually added schedule eras can be deleted", + 409, + ); + } + + // Deleting a correction restores the archived original it had + // superseded — the audit record becomes the era again, atomically + // with the delete. + await prisma.$transaction([ + prisma.medicationScheduleRevision.delete({ + where: { id: revisionId }, + }), + prisma.medicationScheduleRevision.updateMany({ + where: { medicationId: id, supersededByRevisionId: revisionId }, + data: { supersededByRevisionId: null }, + }), + ]); + + await auditLog("medication.schedule_revision.deleted", { + userId: user.id, + ipAddress: getClientIp(request), + details: { medicationId: id, revisionId }, + }); + + annotate({ + action: { + name: "medication.schedule_revision.manual_deleted", + entity_type: "medication", + entity_id: id, + }, + meta: { revision_id: revisionId }, + }); + + // History re-segments without the era; refresh the pre-aggregated + // compliance rollups asynchronously (best-effort). + await enqueueUserMedicationComplianceBackfill(user.id); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts new file mode 100644 index 000000000..adcf90a9a --- /dev/null +++ b/src/app/api/medications/[id]/schedule-revisions/__tests__/route.test.ts @@ -0,0 +1,597 @@ +/** + * v1.16.5 — schedule-era endpoints: ownership, validation, provenance. + * + * GET /api/medications/[id]/schedule-revisions + * POST /api/medications/[id]/schedule-revisions + * PATCH /api/medications/[id]/schedule-revisions/[revisionId] + * DELETE /api/medications/[id]/schedule-revisions/[revisionId] + * + * The POST guards are the contract under test: a manual era must end + * at or before the start of the live plan, must not overlap an + * archived era, and is always minted with `source: "MANUAL"`. The + * DELETE refuses write-path archives (409) so the immutable history + * the replace path mints can never be falsified through the API. + * PATCH (v1.16.6) corrects an era: MANUAL updates in place, ARCHIVED + * stays as the audit record behind a superseding MANUAL correction; + * both write paths run inside the per-medication `FOR UPDATE` + * transaction so concurrent era writes serialise. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => { + const prisma: Record = { + medication: { + findUnique: vi.fn(), + }, + medicationScheduleRevision: { + findMany: vi.fn().mockResolvedValue([]), + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + delete: vi.fn(), + }, + $queryRaw: vi.fn(), + }; + prisma.$transaction = vi.fn(); + return { prisma }; +}); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + enqueueUserMedicationComplianceBackfill: vi + .fn() + .mockResolvedValue({ enqueued: true, error: null }), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, POST } from "../route"; +import { DELETE, PATCH } from "../[revisionId]/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) }; + +function getReq(): NextRequest { + return new NextRequest( + "http://localhost/api/medications/m1/schedule-revisions", + ); +} + +function postReq(body: unknown): NextRequest { + return new NextRequest( + "http://localhost/api/medications/m1/schedule-revisions", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +function deleteReq(revisionId: string): NextRequest { + return new NextRequest( + `http://localhost/api/medications/m1/schedule-revisions/${revisionId}`, + { method: "DELETE" }, + ); +} + +function deleteCtx(revisionId: string) { + return { params: Promise.resolve({ id: "m1", revisionId }) }; +} + +function patchReq(revisionId: string, body: unknown): NextRequest { + return new NextRequest( + `http://localhost/api/medications/m1/schedule-revisions/${revisionId}`, + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +const MED_CREATED_AT = new Date("2026-01-10T08:00:00Z"); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // Interactive form runs the callback against the same mock client + // (the routes only touch models the flat mock already carries); the + // array form resolves the batched operations like Promise.all. + vi.mocked(prisma.$transaction).mockImplementation((async (arg: unknown) => + typeof arg === "function" + ? (arg as (tx: typeof prisma) => unknown)(prisma) + : Promise.all(arg as Array>)) as never); + vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); + // Ownership guard + GET both read `medication.findUnique`; the guard + // selects `{ id, userId }`, the GET selects `{ createdAt }` — one + // superset row satisfies both. + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "m1", + userId: "user-1", + createdAt: MED_CREATED_AT, + } as never); + vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue( + [] as never, + ); +}); + +describe("GET /api/medications/[id]/schedule-revisions", () => { + it("404s for another user's medication (existence sealed)", async () => { + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "m1", + userId: "someone-else", + createdAt: MED_CREATED_AT, + } as never); + const res = await GET(getReq(), ROUTE_CTX); + expect(res.status).toBe(404); + }); + + it("returns currentSince = createdAt when no revision exists", async () => { + const res = await GET(getReq(), ROUTE_CTX); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.currentSince).toBe(MED_CREATED_AT.toISOString()); + expect(json.data.revisions).toEqual([]); + }); + + it("returns currentSince = newest validUntil and summarised entries", async () => { + vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([ + { + id: "rev-2", + validFrom: new Date("2026-03-01T00:00:00Z"), + validUntil: new Date("2026-05-01T00:00:00Z"), + source: "ARCHIVED", + payload: [ + { + timesOfDay: ["07:00", "19:00"], + label: "Morgens", + dose: null, + scheduleType: "SCHEDULED", + }, + ], + }, + { + id: "rev-1", + validFrom: new Date("2026-01-10T08:00:00Z"), + validUntil: new Date("2026-03-01T00:00:00Z"), + source: "MANUAL", + // Malformed payload must degrade, never throw on a read path. + payload: { not: "an array" }, + }, + ] as never); + + const res = await GET(getReq(), ROUTE_CTX); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.currentSince).toBe("2026-05-01T00:00:00.000Z"); + expect(json.data.revisions).toHaveLength(2); + expect(json.data.revisions[0].entries[0].timesOfDay).toEqual([ + "07:00", + "19:00", + ]); + expect(json.data.revisions[1].entries).toEqual([]); + expect(json.data.revisions[1].source).toBe("MANUAL"); + }); +}); + +describe("POST /api/medications/[id]/schedule-revisions", () => { + const VALID_BODY = { + validFrom: "2025-03-12T00:00:00.000Z", + validUntil: "2025-06-01T00:00:00.000Z", + timesOfDay: ["19:00", "07:00"], + }; + + it("404s for another user's medication", async () => { + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "m1", + userId: "someone-else", + createdAt: MED_CREATED_AT, + } as never); + const res = await POST(postReq(VALID_BODY), ROUTE_CTX); + expect(res.status).toBe(404); + expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled(); + }); + + it("422s when validFrom is not before validUntil", async () => { + const res = await POST( + postReq({ ...VALID_BODY, validUntil: VALID_BODY.validFrom }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + }); + + it("422s on a malformed time literal", async () => { + const res = await POST( + postReq({ ...VALID_BODY, timesOfDay: ["25:99"] }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + }); + + it("422s when the era overlaps an archived era", async () => { + vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([ + { + validFrom: new Date("2025-05-01T00:00:00Z"), + validUntil: new Date("2026-01-01T00:00:00Z"), + }, + ] as never); + const res = await POST(postReq(VALID_BODY), ROUTE_CTX); + expect(res.status).toBe(422); + const json = await res.json(); + expect(json.error).toMatch(/overlap/i); + }); + + it("422s when the era ends after the live plan began", async () => { + // Realistic archive: the plan was replaced on 2026-03-01, after + // tracking began (createdAt 2026-01-10) — the live plan has run + // since 2026-03-01. + vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([ + { + validFrom: MED_CREATED_AT, + validUntil: new Date("2026-03-01T00:00:00Z"), + }, + ] as never); + // Ends 2026-04-01 — after the live plan start — and does NOT + // overlap the archived era; the boundary check must fire. + const res = await POST( + postReq({ + ...VALID_BODY, + validFrom: "2026-03-05T00:00:00.000Z", + validUntil: "2026-04-01T00:00:00.000Z", + }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + const json = await res.json(); + expect(json.error).toMatch(/current plan/i); + }); + + it("accepts a manual era filling the gap between an older manual era and createdAt", async () => { + // Two-chunk pre-tracking history entered oldest-first: the second + // chunk ends after the first chunk's validUntil but still at or + // before createdAt. The boundary is max(createdAt, newest active + // validUntil) — order of entry must not matter. + vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([ + { + validFrom: new Date("2025-01-01T00:00:00Z"), + validUntil: new Date("2025-04-01T00:00:00Z"), + }, + ] as never); + vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue({ + id: "rev-new", + validFrom: new Date("2025-04-01T00:00:00.000Z"), + validUntil: new Date("2025-06-01T00:00:00.000Z"), + source: "MANUAL", + payload: [], + } as never); + const res = await POST( + postReq({ ...VALID_BODY, validFrom: "2025-04-01T00:00:00.000Z" }), + ROUTE_CTX, + ); + expect(res.status).toBe(201); + }); + + it("422s when no revision exists and the era reaches past createdAt", async () => { + // With zero archived revisions the live plan has covered everything + // since `createdAt` (2026-01-10); a manual era ending inside the + // tracked window would re-score live compliance against the manual + // snapshot. The boundary is createdAt, not "now". + const res = await POST( + postReq({ + ...VALID_BODY, + validFrom: "2026-01-01T00:00:00.000Z", + validUntil: "2026-02-01T00:00:00.000Z", + }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + const json = await res.json(); + expect(json.error).toMatch(/current plan/i); + expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled(); + }); + + it("creates a MANUAL revision with a daily snapshot entry", async () => { + vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue({ + id: "rev-new", + validFrom: new Date(VALID_BODY.validFrom), + validUntil: new Date(VALID_BODY.validUntil), + source: "MANUAL", + payload: [], + } as never); + + const res = await POST(postReq(VALID_BODY), ROUTE_CTX); + expect(res.status).toBe(201); + + const createArgs = vi.mocked(prisma.medicationScheduleRevision.create) + .mock.calls[0][0] as unknown as { + data: { + medicationId: string; + source: string; + payload: Array<{ + timesOfDay: string[]; + rrule: string | null; + windowStart: string; + windowEnd: string; + scheduleType: string; + }>; + }; + }; + expect(createArgs.data.medicationId).toBe("m1"); + expect(createArgs.data.source).toBe("MANUAL"); + expect(createArgs.data.payload).toHaveLength(1); + // Times sorted ascending; window pulled to their min/max; daily. + expect(createArgs.data.payload[0].timesOfDay).toEqual(["07:00", "19:00"]); + expect(createArgs.data.payload[0].windowStart).toBe("07:00"); + expect(createArgs.data.payload[0].windowEnd).toBe("19:00"); + expect(createArgs.data.payload[0].rrule).toBe("FREQ=DAILY"); + expect(createArgs.data.payload[0].scheduleType).toBe("SCHEDULED"); + + // History re-segmented — the rollup backfill must be enqueued. + expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( + "user-1", + ); + + // Check-then-insert ran inside the transaction with the + // per-medication `FOR UPDATE` lock taken first. + expect(prisma.$transaction).toHaveBeenCalled(); + expect(prisma.$queryRaw).toHaveBeenCalled(); + }); + + it("collapses duplicate timesOfDay before snapshotting", async () => { + vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue({ + id: "rev-new", + validFrom: new Date(VALID_BODY.validFrom), + validUntil: new Date(VALID_BODY.validUntil), + source: "MANUAL", + payload: [], + } as never); + + const res = await POST( + postReq({ ...VALID_BODY, timesOfDay: ["19:00", "07:00", "19:00"] }), + ROUTE_CTX, + ); + expect(res.status).toBe(201); + const createArgs = vi.mocked(prisma.medicationScheduleRevision.create) + .mock.calls[0][0] as unknown as { + data: { payload: Array<{ timesOfDay: string[] }> }; + }; + // The Zod transform dedupes + sorts — a double-tapped chip must + // never mint a double-counted slot. + expect(createArgs.data.payload[0].timesOfDay).toEqual(["07:00", "19:00"]); + }); +}); + +describe("DELETE /api/medications/[id]/schedule-revisions/[revisionId]", () => { + it("404s when the revision belongs to another medication", async () => { + vi.mocked( + prisma.medicationScheduleRevision.findUnique, + ).mockResolvedValue({ + id: "rev-x", + medicationId: "other-med", + source: "MANUAL", + } as never); + const res = await DELETE(deleteReq("rev-x"), deleteCtx("rev-x")); + expect(res.status).toBe(404); + expect(prisma.medicationScheduleRevision.delete).not.toHaveBeenCalled(); + }); + + it("409s for a write-path archive", async () => { + vi.mocked( + prisma.medicationScheduleRevision.findUnique, + ).mockResolvedValue({ + id: "rev-a", + medicationId: "m1", + source: "ARCHIVED", + } as never); + const res = await DELETE(deleteReq("rev-a"), deleteCtx("rev-a")); + expect(res.status).toBe(409); + expect(prisma.medicationScheduleRevision.delete).not.toHaveBeenCalled(); + }); + + it("deletes a MANUAL era, restores a superseded original, and enqueues the rollup backfill", async () => { + vi.mocked( + prisma.medicationScheduleRevision.findUnique, + ).mockResolvedValue({ + id: "rev-m", + medicationId: "m1", + source: "MANUAL", + } as never); + vi.mocked(prisma.medicationScheduleRevision.delete).mockResolvedValue( + {} as never, + ); + vi.mocked( + prisma.medicationScheduleRevision.updateMany, + ).mockResolvedValue({ count: 0 } as never); + const res = await DELETE(deleteReq("rev-m"), deleteCtx("rev-m")); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.deleted).toBe(true); + expect(prisma.medicationScheduleRevision.delete).toHaveBeenCalledWith({ + where: { id: "rev-m" }, + }); + // If the deleted row was a correction, the archived original it + // superseded becomes the era again. + expect(prisma.medicationScheduleRevision.updateMany).toHaveBeenCalledWith({ + where: { medicationId: "m1", supersededByRevisionId: "rev-m" }, + data: { supersededByRevisionId: null }, + }); + expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( + "user-1", + ); + }); +}); + +describe("PATCH /api/medications/[id]/schedule-revisions/[revisionId]", () => { + const PATCH_BODY = { + validFrom: "2025-03-12T00:00:00.000Z", + validUntil: "2025-06-01T00:00:00.000Z", + timesOfDay: ["08:00", "20:00", "08:00"], + }; + + function mockTarget(overrides: Record = {}) { + vi.mocked( + prisma.medicationScheduleRevision.findUnique, + ).mockResolvedValue({ + id: "rev-t", + medicationId: "m1", + source: "MANUAL", + supersededByRevisionId: null, + validUntil: new Date("2025-06-01T00:00:00Z"), + ...overrides, + } as never); + } + + it("404s when the revision belongs to another medication", async () => { + mockTarget({ medicationId: "other-med" }); + const res = await PATCH( + patchReq("rev-t", PATCH_BODY), + deleteCtx("rev-t"), + ); + expect(res.status).toBe(404); + expect(prisma.medicationScheduleRevision.update).not.toHaveBeenCalled(); + }); + + it("409s when the era has already been corrected", async () => { + mockTarget({ source: "ARCHIVED", supersededByRevisionId: "rev-fix" }); + const res = await PATCH( + patchReq("rev-t", PATCH_BODY), + deleteCtx("rev-t"), + ); + expect(res.status).toBe(409); + expect(prisma.medicationScheduleRevision.update).not.toHaveBeenCalled(); + expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled(); + }); + + it("422s when the corrected era overlaps another active era", async () => { + mockTarget(); + vi.mocked(prisma.medicationScheduleRevision.findMany).mockResolvedValue([ + { + validFrom: new Date("2025-06-01T00:00:00Z"), + validUntil: new Date("2025-08-01T00:00:00Z"), + }, + ] as never); + const res = await PATCH( + patchReq("rev-t", { ...PATCH_BODY, validUntil: "2025-07-01T00:00:00.000Z" }), + deleteCtx("rev-t"), + ); + expect(res.status).toBe(422); + const json = await res.json(); + expect(json.error).toMatch(/overlap/i); + }); + + it("422s when the correction extends past the live boundary", async () => { + // ARCHIVED era ending 2025-06-01, no other revisions; the live + // ceiling is max(own recorded end, createdAt 2026-01-10) — a + // correction reaching past it would swallow tracked live history. + mockTarget({ source: "ARCHIVED" }); + const res = await PATCH( + patchReq("rev-t", { + ...PATCH_BODY, + validUntil: "2026-02-01T00:00:00.000Z", + }), + deleteCtx("rev-t"), + ); + expect(res.status).toBe(422); + const json = await res.json(); + expect(json.error).toMatch(/current plan/i); + }); + + it("updates a MANUAL era in place with the deduped snapshot", async () => { + mockTarget(); + vi.mocked(prisma.medicationScheduleRevision.update).mockResolvedValue({ + id: "rev-t", + validFrom: new Date(PATCH_BODY.validFrom), + validUntil: new Date(PATCH_BODY.validUntil), + source: "MANUAL", + } as never); + + const res = await PATCH( + patchReq("rev-t", PATCH_BODY), + deleteCtx("rev-t"), + ); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.data.id).toBe("rev-t"); + expect(json.data.entries[0].timesOfDay).toEqual(["08:00", "20:00"]); + + const updateArgs = vi.mocked(prisma.medicationScheduleRevision.update) + .mock.calls[0][0] as unknown as { + where: { id: string }; + data: { payload: Array<{ timesOfDay: string[]; rrule: string }> }; + }; + expect(updateArgs.where.id).toBe("rev-t"); + expect(updateArgs.data.payload[0].timesOfDay).toEqual(["08:00", "20:00"]); + expect(updateArgs.data.payload[0].rrule).toBe("FREQ=DAILY"); + // In-place — no superseding row is minted. + expect(prisma.medicationScheduleRevision.create).not.toHaveBeenCalled(); + expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( + "user-1", + ); + // Check-then-write ran inside the locked transaction. + expect(prisma.$transaction).toHaveBeenCalled(); + expect(prisma.$queryRaw).toHaveBeenCalled(); + }); + + it("corrects an ARCHIVED era via a superseding MANUAL revision", async () => { + mockTarget({ source: "ARCHIVED" }); + vi.mocked(prisma.medicationScheduleRevision.create).mockResolvedValue({ + id: "rev-fix", + validFrom: new Date(PATCH_BODY.validFrom), + validUntil: new Date(PATCH_BODY.validUntil), + source: "MANUAL", + } as never); + vi.mocked(prisma.medicationScheduleRevision.update).mockResolvedValue( + {} as never, + ); + + const res = await PATCH( + patchReq("rev-t", PATCH_BODY), + deleteCtx("rev-t"), + ); + expect(res.status).toBe(200); + const json = await res.json(); + // The response carries the correction, which now takes the + // original's place in every era read. + expect(json.data.id).toBe("rev-fix"); + expect(json.data.source).toBe("MANUAL"); + + const createArgs = vi.mocked(prisma.medicationScheduleRevision.create) + .mock.calls[0][0] as unknown as { + data: { source: string; payload: Array<{ timesOfDay: string[] }> }; + }; + expect(createArgs.data.source).toBe("MANUAL"); + expect(createArgs.data.payload[0].timesOfDay).toEqual(["08:00", "20:00"]); + // The ARCHIVED original is parked behind the correction, not + // touched in its recorded content. + expect(prisma.medicationScheduleRevision.update).toHaveBeenCalledWith({ + where: { id: "rev-t" }, + data: { supersededByRevisionId: "rev-fix" }, + }); + expect(enqueueUserMedicationComplianceBackfill).toHaveBeenCalledWith( + "user-1", + ); + }); +}); diff --git a/src/app/api/medications/[id]/schedule-revisions/route.ts b/src/app/api/medications/[id]/schedule-revisions/route.ts new file mode 100644 index 000000000..9eda687b5 --- /dev/null +++ b/src/app/api/medications/[id]/schedule-revisions/route.ts @@ -0,0 +1,296 @@ +/** + * v1.16.5 — schedule-era management for the Zeitplan tab. + * + * GET /api/medications/[id]/schedule-revisions + * - lists the medication's archived schedule eras (newest first) + * plus `currentSince`, the instant the LIVE plan took over (the + * newest revision's `validUntil`, or `medication.createdAt` when + * no era has been archived yet). + * + * POST /api/medications/[id]/schedule-revisions + * - appends a MANUAL era for pre-tracking history ("dosed at + * 07:00/19:00 from March to June"). Validation: the era must end + * at or before the start of the live era and must not overlap + * any existing revision. The snapshot payload mirrors what the + * write-path archive mints (`FREQ=DAILY`, window pulled to the + * min/max of the times) so the era minter — ledger, compliance, + * cadence chips — reads it like any other revision. + * + * Auth: cookie-session or Bearer via requireAuth(); the medication is + * verified to belong to the caller before any read or write. Deleting + * a manual era lives in the `[revisionId]` sub-route. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { scheduleRevisionCreateSchema } from "@/lib/validations/schedule-revision"; +import { + toRevisionPayloadEntry, + type ScheduleRevisionEntry, +} from "@/lib/medications/scheduling/schedule-eras"; +import { enqueueUserMedicationComplianceBackfill } from "@/lib/rollups/medication-compliance-rollups"; +import type { Prisma } from "@/generated/prisma/client"; + +type RouteParams = { params: Promise<{ id: string }> }; + +/** + * Display projection of one archived schedule row. Defensive parsing — + * a malformed payload entry degrades to an empty summary instead of + * throwing on a read path (mirrors `canonicalSchedulesFromRevision`). + */ +function summariseEntries(payload: unknown): Array<{ + timesOfDay: string[]; + label: string | null; + dose: string | null; + scheduleType: string; +}> { + if (!Array.isArray(payload)) return []; + return payload.map((raw) => { + const e = (raw ?? {}) as Partial; + return { + timesOfDay: Array.isArray(e.timesOfDay) + ? e.timesOfDay.filter((t): t is string => typeof t === "string") + : [], + label: typeof e.label === "string" ? e.label : null, + dose: typeof e.dose === "string" ? e.dose : null, + scheduleType: typeof e.scheduleType === "string" ? e.scheduleType : "SCHEDULED", + }; + }); +} + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + + const med = await prisma.medication.findUnique({ + where: { id }, + select: { createdAt: true }, + }); + if (!med) { + return apiError("Medication not found", 404); + } + + const revisions = await prisma.medicationScheduleRevision.findMany({ + // Superseded rows are audit records behind a correction — the + // timeline shows the corrected state, like every era consumer. + where: { medicationId: id, supersededByRevisionId: null }, + orderBy: { validFrom: "desc" }, + select: { + id: true, + validFrom: true, + validUntil: true, + source: true, + payload: true, + }, + }); + + // The live plan has been current since the newest archived era + // ended; with no archive it has been current since creation. + const currentSince = + revisions.length > 0 + ? revisions.reduce( + (latest, r) => (r.validUntil > latest ? r.validUntil : latest), + revisions[0].validUntil, + ) + : med.createdAt; + + annotate({ + action: { + name: "medication.schedule_revisions.list", + entity_type: "medication", + entity_id: id, + }, + meta: { revision_count: revisions.length }, + }); + + return apiSuccess({ + currentSince: currentSince.toISOString(), + revisions: revisions.map((r) => ({ + id: r.id, + validFrom: r.validFrom.toISOString(), + validUntil: r.validUntil.toISOString(), + source: r.source, + entries: summariseEntries(r.payload), + })), + }); + }, +); + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const { id } = await params; + + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + + const med = await prisma.medication.findUnique({ + where: { id }, + select: { createdAt: true }, + }); + if (!med) { + return apiError("Medication not found", 404); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = scheduleRevisionCreateSchema.safeParse(body ?? {}); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + const validFrom = new Date(parsed.data.validFrom); + const validUntil = new Date(parsed.data.validUntil); + + // Sanity floor — a fat-fingered year ("0203") would otherwise mint + // an era the timeline renders as antiquity. + if (validFrom.getUTCFullYear() < 1900) { + return apiError("validFrom must be a date after 1900", 422); + } + + // Snapshot entry shaped exactly like the write-path archive: daily + // recurrence at the given times, window pulled to their min/max. + // The schema already deduped + sorted the times. + const times = parsed.data.timesOfDay; + const entry = toRevisionPayloadEntry({ + timesOfDay: times, + windowStart: times[0], + windowEnd: times[times.length - 1], + daysOfWeek: null, + rrule: "FREQ=DAILY", + rollingIntervalDays: null, + scheduleType: "SCHEDULED", + cyclicOnWeeks: null, + cyclicOffWeeks: null, + doseWindows: null, + label: null, + dose: null, + reminderGraceMinutes: null, + }); + + // Validate-then-write under a per-medication row lock: two + // concurrent era writes used to both pass the overlap check against + // the same snapshot and both insert. `FOR UPDATE` on the medication + // row serialises every era write for this medication; the reads run + // through the same `tx` so they see the locked state. + const outcome = await prisma.$transaction(async (tx) => { + await tx.$queryRaw` + SELECT id FROM medications + WHERE id = ${id} + FOR UPDATE + `; + + const existing = await tx.medicationScheduleRevision.findMany({ + where: { medicationId: id, supersededByRevisionId: null }, + select: { validFrom: true, validUntil: true }, + }); + + // "Before the current plan": the live era began at the newest + // active `validUntil`, and never earlier than `createdAt` — the + // era splitter reads `[newest validUntil, ∞)`, or the whole range + // when no revision exists. Allowing a manual era to reach past + // that instant would let the manual snapshot swallow tracked live + // history and re-score compliance against the wrong plan. + const liveBoundary = existing.reduce( + (latest, r) => (r.validUntil > latest ? r.validUntil : latest), + med.createdAt, + ); + if (validUntil.getTime() > liveBoundary.getTime()) { + return { + ok: false, + error: "A manual era must end before the current plan begins", + } as const; + } + + // No overlap with any active interval `[validFrom, validUntil)`. + const overlaps = existing.some( + (r) => + validFrom.getTime() < r.validUntil.getTime() && + validUntil.getTime() > r.validFrom.getTime(), + ); + if (overlaps) { + return { + ok: false, + error: "The era overlaps an existing schedule era", + } as const; + } + + const revision = await tx.medicationScheduleRevision.create({ + data: { + medicationId: id, + validFrom, + validUntil, + source: "MANUAL", + payload: [entry] as unknown as Prisma.InputJsonValue, + }, + select: { + id: true, + validFrom: true, + validUntil: true, + source: true, + payload: true, + }, + }); + return { ok: true, revision } as const; + }); + + if (!outcome.ok) { + return apiError(outcome.error, 422); + } + const revision = outcome.revision; + + await auditLog("medication.schedule_revision.created", { + userId: user.id, + ipAddress: getClientIp(request), + details: { medicationId: id, revisionId: revision.id }, + }); + + annotate({ + action: { + name: "medication.schedule_revision.manual_created", + entity_type: "medication", + entity_id: id, + }, + meta: { + revision_id: revision.id, + era_days: Math.round( + (validUntil.getTime() - validFrom.getTime()) / 86_400_000, + ), + }, + }); + + // The new era re-segments history the compliance rollups already + // pre-aggregated; refresh them asynchronously. Best-effort like + // every other rollup write-hook. + await enqueueUserMedicationComplianceBackfill(user.id); + + return apiSuccess( + { + id: revision.id, + validFrom: revision.validFrom.toISOString(), + validUntil: revision.validUntil.toISOString(), + source: revision.source, + entries: summariseEntries(revision.payload), + }, + 201, + ); + }, +); diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index 2526095a9..ee3f6b32d 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -99,7 +99,9 @@ async function buildMedicationsList( // past this boundary into a previous era's cadence. prisma.medicationScheduleRevision.groupBy({ by: ["medicationId"], - where: { medication: { userId } }, + // Superseded rows are audit records — a correction may have + // shortened the era, so the boundary reads only active rows. + where: { medication: { userId }, supersededByRevisionId: null }, _max: { validUntil: true }, }), ]); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 25b3f7998..af70c3348 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -14,6 +14,8 @@ import { describePasskeyError } from "@/lib/passkey-errors"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch"; +import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; +import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; export default function LoginPage() { const router = useRouter(); @@ -58,6 +60,18 @@ export default function LoginPage() { return "/"; } + // v1.16.6 first-load waterfall fix — the session cookie exists the + // moment the login POST resolves, so the dashboard snapshot request + // can ride in parallel with the route transition + page-chunk + // download instead of queueing behind the dashboard mount. + function navigateAfterLogin() { + const target = getRedirectTarget(); + if (target === "/" && isDashboardSnapshotEnabled()) { + prefetchDashboardSnapshot(queryClient); + } + router.push(target); + } + async function handlePasskeyLogin() { setError(null); setLoading(true); @@ -77,7 +91,7 @@ export default function LoginPage() { }); await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); - router.push(getRedirectTarget()); + navigateAfterLogin(); } catch (err) { // Route rejections carry the envelope error verbatim; everything // else (WebAuthn ceremony failures) keeps the descriptive mapping. @@ -101,7 +115,7 @@ export default function LoginPage() { await apiPost("/api/auth/login", { email, password }); await queryClient.invalidateQueries({ queryKey: queryKeys.auth() }); - router.push(getRedirectTarget()); + navigateAfterLogin(); } catch (err) { setError( err instanceof ApiError && err.message diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index a186d4541..259179bad 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -15,6 +15,7 @@ import { orderedVisibleSectionIds, type InsightsSectionId, } from "@/lib/insights-layout"; +import { useMounted } from "@/hooks/use-mounted"; import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route"; import { useTranslations } from "@/lib/i18n/context"; import { Button } from "@/components/ui/button"; @@ -221,6 +222,7 @@ interface ComprehensiveData { export default function InsightsPage() { const { isAuthenticated, user } = useAuth(); + const mounted = useMounted(); const { t } = useTranslations(); // v1.4.33 IW9 — scroll-to-top on route mount centralised in the @@ -340,8 +342,12 @@ export default function InsightsPage() { ); } + // Gated on `mounted`: the auth query can resolve before this + // boundary hydrates, and a greeting that personalises during the + // hydration render disagrees with the name-less SSR text — React + // #418. See `useMounted`. const heroGreetingName = - user?.username?.trim() && user.username.trim().length > 0 + mounted && user?.username?.trim() && user.username.trim().length > 0 ? user.username.split(/\s+/)[0] : null; const briefingPayload = advisor.payload?.dailyBriefing ?? null; diff --git a/src/app/page.tsx b/src/app/page.tsx index 020a340e3..25e254d44 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -591,7 +591,11 @@ export default function DashboardPage() { // changes when the user's timezone changes; recomputing it every // render churned the parts-array and walked the format ICU twice per // render across a 1 400-line component body. - const userTimezone = user?.timezone; + // Gated on `mounted`: `user` comes from the auth query, which can + // resolve before this boundary hydrates — the greeting text must + // match the name-less SSR output during hydration (React #418) and + // may only personalise from the first client re-render. + const userTimezone = mounted ? user?.timezone : undefined; const hour = useMemo( () => (userTimezone ? getHourForTimeZone(userTimezone) : null), [userTimezone], @@ -605,7 +609,7 @@ export default function DashboardPage() { ? t("dashboard.greeting.day") : t("dashboard.greeting.evening"); const welcomeText = - user?.username && user.username.trim().length > 0 + mounted && user?.username && user.username.trim().length > 0 ? t("dashboard.welcomeBackWithName", { greeting: timeGreeting, name: user.username, diff --git a/src/components/admin/__tests__/admin-shell.test.tsx b/src/components/admin/__tests__/admin-shell.test.tsx index fbbd28d3d..6f0ac9348 100644 --- a/src/components/admin/__tests__/admin-shell.test.tsx +++ b/src/components/admin/__tests__/admin-shell.test.tsx @@ -21,6 +21,19 @@ vi.mock("next/navigation", () => ({ usePathname: () => mockPathnameRef.value, })); +// The shell gates its own frame on the confirmed ADMIN role — a +// non-admin (or a still-loading auth state) must not see the section +// nav while AuthShell's redirect effect is catching up. +const mockUserRef = { + value: { role: "ADMIN", username: "op" } as { + role: string; + username: string; + } | null, +}; +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: mockUserRef.value }), +})); + import { readdirSync, statSync } from "node:fs"; import { join } from "node:path"; @@ -31,8 +44,11 @@ import { ADMIN_SECTIONS, AdminShell } from "../admin-shell"; function renderShell(props: { active?: (typeof ADMIN_SECTIONS)[number]["slug"]; pathname?: string; + user?: { role: string; username: string } | null; }) { mockPathnameRef.value = props.pathname ?? "/admin/system-status"; + mockUserRef.value = + props.user === undefined ? { role: "ADMIN", username: "op" } : props.user; return renderToStaticMarkup( @@ -133,6 +149,22 @@ describe("", () => { ).toBe(true); }); + it("renders neither frame nor children until the role is confirmed ADMIN", () => { + // Non-admin: no section nav, no children — the frame must not + // flash while AuthShell's redirect effect moves the user away. + const asUser = renderShell({ + active: "system-status", + user: { role: "USER", username: "u" }, + }); + expect(asUser).toBe(""); + // Auth still in flight (user === null): same blank render. + const loading = renderShell({ active: "system-status", user: null }); + expect(loading).toBe(""); + // Admin: frame + children render. + const asAdmin = renderShell({ active: "system-status" }); + expect(asAdmin).toContain("section body"); + }); + it("does NOT mark any section active on `/admin` overview", () => { const html = renderShell({ pathname: "/admin" }); // Overview-link only — no section gets aria-current. diff --git a/src/components/admin/__tests__/invite-tokens-section.test.tsx b/src/components/admin/__tests__/invite-tokens-section.test.tsx new file mode 100644 index 000000000..3b76578ed --- /dev/null +++ b/src/components/admin/__tests__/invite-tokens-section.test.tsx @@ -0,0 +1,203 @@ +/** + * v1.16.5 — admin invite section: multi-use surface contracts. + * + * SSR smoke tests per project convention (`renderToStaticMarkup` + + * seeded QueryClient; no `@testing-library/react`). Pinned: + * + * 1. The table renders `uses / maxUses` for a multi-use invite. + * 2. uses == maxUses surfaces the "Aufgebraucht" (exhausted) chip. + * 3. Multiple redemptions render the "+N weitere" popover trigger; + * a single redemption renders inline without one. + * 4. `clampMaxUses` pins the create-dialog field to the 1–50 range + * (the same clamp the submit path sends). + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { + InviteTokensSection, + clampMaxUses, +} from "@/components/admin/invite-tokens-section"; +import { queryKeys } from "@/lib/query-keys"; + +const FUTURE = new Date(Date.now() + 7 * 86_400_000).toISOString(); +const PAST = new Date(Date.now() - 86_400_000).toISOString(); + +interface SeedInvite { + id: string; + createdAt: string; + expiresAt: string; + usedAt: string | null; + revokedAt: string | null; + uses: number; + maxUses: number; + creator: { id: string; username: string } | null; + consumer: { id: string; username: string } | null; + redemptions: Array<{ + id: string; + redeemedAt: string; + user: { id: string; username: string; email: string | null } | null; + }>; +} + +function makeInvite(overrides: Partial): SeedInvite { + return { + id: "inv-1", + createdAt: PAST, + expiresAt: FUTURE, + usedAt: null, + revokedAt: null, + uses: 0, + maxUses: 1, + creator: { id: "admin-1", username: "operator" }, + consumer: null, + redemptions: [], + ...overrides, + }; +} + +function render(invites: SeedInvite[]): string { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + }, + }, + }); + client.setQueryData(queryKeys.adminInvites(), invites); + return renderToStaticMarkup( + + + + + , + ); +} + +describe("InviteTokensSection (multi-use)", () => { + it("renders uses / maxUses for a partially used multi-use invite", () => { + const html = render([ + makeInvite({ + uses: 2, + maxUses: 5, + redemptions: [ + { + id: "r1", + redeemedAt: PAST, + user: { id: "u1", username: "alpha", email: null }, + }, + { + id: "r2", + redeemedAt: PAST, + user: { id: "u2", username: "beta", email: null }, + }, + ], + }), + ]); + expect(html).toContain("/ 5"); + expect(html).toContain("invite-status-active"); + }); + + it("surfaces the exhausted chip when uses reaches maxUses", () => { + const html = render([ + makeInvite({ + uses: 3, + maxUses: 3, + redemptions: [ + { + id: "r1", + redeemedAt: PAST, + user: { id: "u1", username: "alpha", email: null }, + }, + { + id: "r2", + redeemedAt: PAST, + user: { id: "u2", username: "beta", email: null }, + }, + { + id: "r3", + redeemedAt: PAST, + user: { id: "u3", username: "gamma", email: null }, + }, + ], + }), + ]); + expect(html).toContain("invite-status-exhausted"); + expect(html).toContain("Aufgebraucht"); + }); + + it("multiple redemptions render the popover trigger with the +N summary", () => { + const html = render([ + makeInvite({ + uses: 3, + maxUses: 5, + redemptions: [ + { + id: "r1", + redeemedAt: PAST, + user: { id: "u1", username: "alpha", email: null }, + }, + { + id: "r2", + redeemedAt: PAST, + user: { id: "u2", username: "beta", email: null }, + }, + { + id: "r3", + redeemedAt: PAST, + user: { id: "u3", username: "gamma", email: null }, + }, + ], + }), + ]); + expect(html).toContain("admin-invite-redemptions-trigger"); + // de: "{username} + {count} weitere" — newest redemption leads. + expect(html).toContain("alpha + 2 weitere"); + }); + + it("a single redemption renders inline without a popover trigger", () => { + const html = render([ + makeInvite({ + uses: 1, + maxUses: 5, + redemptions: [ + { + id: "r1", + redeemedAt: PAST, + user: { id: "u1", username: "alpha", email: null }, + }, + ], + }), + ]); + expect(html).toContain("alpha"); + expect(html).not.toContain("admin-invite-redemptions-trigger"); + }); +}); + +describe("clampMaxUses", () => { + it("passes the 1–50 range through", () => { + expect(clampMaxUses("1")).toBe(1); + expect(clampMaxUses("25")).toBe(25); + expect(clampMaxUses("50")).toBe(50); + }); + + it("clamps above the cap to 50", () => { + expect(clampMaxUses("51")).toBe(50); + expect(clampMaxUses("9999")).toBe(50); + }); + + it("falls back to 1 for zero, negatives, and garbage", () => { + expect(clampMaxUses("0")).toBe(1); + expect(clampMaxUses("-3")).toBe(1); + expect(clampMaxUses("")).toBe(1); + expect(clampMaxUses("abc")).toBe(1); + }); + + it("truncates decimals to the integer part", () => { + expect(clampMaxUses("2.9")).toBe(2); + }); +}); diff --git a/src/components/admin/__tests__/login-overview-csv.test.ts b/src/components/admin/__tests__/login-overview-csv.test.ts index ab31f02da..d325e61bf 100644 --- a/src/components/admin/__tests__/login-overview-csv.test.ts +++ b/src/components/admin/__tests__/login-overview-csv.test.ts @@ -374,9 +374,7 @@ describe("carrierShortLabel — DACH carrier folding (v1.4.27 B3)", () => { }); it("folds Telefónica + O2 variants down to 'O2'", () => { - expect( - carrierShortLabel("Telefónica Germany GmbH & Co. OHG"), - ).toBe("O2"); + expect(carrierShortLabel("Telefónica Germany GmbH & Co. OHG")).toBe("O2"); expect(carrierShortLabel("Telefonica Germany Online Services")).toBe("O2"); expect(carrierShortLabel("O2 Deutschland")).toBe("O2"); }); @@ -384,6 +382,8 @@ describe("carrierShortLabel — DACH carrier folding (v1.4.27 B3)", () => { it("returns the raw organisation string for unknown carriers", () => { expect(carrierShortLabel("Google LLC")).toBe("Google LLC"); expect(carrierShortLabel("Amazon.com, Inc.")).toBe("Amazon.com, Inc."); - expect(carrierShortLabel("Hetzner Online GmbH")).toBe("Hetzner Online GmbH"); + expect(carrierShortLabel("Hetzner Online GmbH")).toBe( + "Hetzner Online GmbH", + ); }); }); diff --git a/src/components/admin/__tests__/user-management-responsive.test.tsx b/src/components/admin/__tests__/user-management-responsive.test.tsx index cdcc955f9..3dc75b13d 100644 --- a/src/components/admin/__tests__/user-management-responsive.test.tsx +++ b/src/components/admin/__tests__/user-management-responsive.test.tsx @@ -120,9 +120,12 @@ describe("UserManagementSection — mobile responsive", () => { expect(html).toMatch(/class="[^"]*\bp-4\b[^"]*sm:p-6/); }); - it("stacks header rows on mobile so filter pills don't collide with title", () => { + it("wraps the filter pills below the title on narrow viewports", () => { const html = render(); - // Header carries `flex-col` at base + `sm:flex-row` at small+. - expect(html).toMatch(/class="[^"]*\bflex-col\b[^"]*sm:flex-row/); + // The shared SettingsCardHeader title row wraps, so the status slot + // (filter pills) reflows under the title instead of colliding with it. + expect(html).toMatch( + /class="[^"]*\bflex-wrap\b[^"]*items-start justify-between/, + ); }); }); diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 14ae3aa41..c24a6957c 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react"; import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { PasswordInput as SharedPasswordInput } from "@/components/ui/password-input"; @@ -160,6 +161,18 @@ export const FEEDBACK_STATUS_TABS: FeedbackStatusType[] = [ "ARCHIVED", ]; +/** Shared green "configured" pill — the GlitchTip, Umami, bug-report, + * and Web-Push VAPID cards all surface the same state; one component + * keeps the tint and copy in lockstep. */ +export function ConfiguredBadge() { + const { t } = useTranslations(); + return ( + + {t("admin.configured")} + + ); +} + export function StatusItem({ icon: Icon, label, diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index a8b34b9a4..c8ce68f90 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -39,6 +39,7 @@ import { import { cn } from "@/lib/utils"; import { scrollBehaviorForUser } from "@/lib/motion"; +import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs"; @@ -172,6 +173,7 @@ function deriveActiveSlug( export function AdminShell({ active, children }: AdminShellProps) { const pathname = usePathname(); const { t } = useTranslations(); + const { user } = useAuth(); const activeSlug = deriveActiveSlug(pathname, active); // v1.4.34 IW-G — mirror the settings-shell auto-scroll pattern so @@ -199,6 +201,13 @@ export function AdminShell({ active, children }: AdminShellProps) { }); }, [activeSlug]); + // The section bodies are already role-gated, but the shell frame + // itself (the full section nav in two layouts) used to paint for a + // non-admin during the frames between auth resolving and AuthShell's + // redirect effect replacing the route. Render nothing — frame AND + // children — until the role is confirmed ADMIN. + if (!user || user.role !== "ADMIN") return null; + // v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6` // already, so this inner shell only carries the wider max-width. // Previously the duplicate `px-4 py-6 md:px-6 md:py-8` here was @@ -252,9 +261,9 @@ export function AdminShell({ active, children }: AdminShellProps) { aria-current={isActive ? "page" : undefined} className={cn( // v1.4.25 W8 — chip strip is the primary mobile-admin - // navigation surface. Pad to WCAG 2.5.5 44 px so the - // chips can be tapped without zoom on a Pixel-5. - "flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors", + // navigation surface. Pad to WCAG 2.5.5 44 px so the + // chips can be tapped without zoom on a Pixel-5. + "flex min-h-11 items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors", isActive ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-foreground hover:bg-accent", diff --git a/src/components/admin/ai-quality-section.tsx b/src/components/admin/ai-quality-section.tsx index 8529eb18a..4a026e59f 100644 --- a/src/components/admin/ai-quality-section.tsx +++ b/src/components/admin/ai-quality-section.tsx @@ -21,6 +21,7 @@ import { useQuery } from "@tanstack/react-query"; import { Sparkles, Loader2 } from "lucide-react"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; @@ -66,7 +67,7 @@ export function AiQualitySection() { if (query.isLoading) { return ( -
+
{t("admin.aiQuality.loading")} @@ -90,14 +91,12 @@ export function AiQualitySection() { if (!summary || summary.buckets.length === 0) { return ( -
-
- -
- {t("admin.aiQuality.title")} -
-
-

+

+ +

{t("admin.aiQuality.empty")}

@@ -105,23 +104,21 @@ export function AiQualitySection() { } return ( -
-
-
- -
- {t("admin.aiQuality.title")} -
-
-

- {t("admin.aiQuality.windowLabel", { - days: String(summary.windowDays), - })} - {" · "} - {t("admin.aiQuality.generatedAtLabel")}{" "} - {fmt.dateTime(summary.generatedAt)} -

-
+
+ + {t("admin.aiQuality.windowLabel", { + days: String(summary.windowDays), + })} + {" · "} + {t("admin.aiQuality.generatedAtLabel")}{" "} + {fmt.dateTime(summary.generatedAt)} +

+ } + />
diff --git a/src/components/admin/ai-server-key-section.tsx b/src/components/admin/ai-server-key-section.tsx new file mode 100644 index 000000000..5057b824d --- /dev/null +++ b/src/components/admin/ai-server-key-section.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { KeyRound, Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { apiGet, apiPut } from "@/lib/api/api-fetch"; +import { PasswordInput } from "./_shared"; + +/** + * v1.16.6 — operator panel for the server-wide AI key. + * + * The backing endpoint (`/api/admin/ai-settings`, admin-cookie-only) has + * existed since the `admin-openai` chain fallback shipped, but it never had + * an operator UI — configuring the shared key took a manual API call. This + * card is its home, beside the assistant feature flags. + * + * Semantics worth keeping visible to the operator: + * - A user's own key (BYOK / Codex OAuth / local) always wins; the server + * key sits at the END of the provider chain as the shared fallback. + * - Egress over the server key is consent-gated per user + * (`ConsentRequiredError` in `src/lib/ai/consent-guard.ts`) — setting a + * key here never silently forwards anyone's health data. + * - The base URL is locked server-side to HTTPS + a hostname allowlist + * (`ADMIN_AI_BASE_URL_ALLOWLIST` extends it). + */ + +interface AiServerKeyResponse { + hasKey: boolean; + keyPreview: string | null; + model: string; + baseUrl: string; +} + +export function AiServerKeySection() { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + + const { data: settings } = useQuery({ + queryKey: queryKeys.adminAiServerKey(), + queryFn: () => apiGet("/api/admin/ai-settings"), + }); + + const [keyDraft, setKeyDraft] = useState(""); + const [modelDraft, setModelDraft] = useState(null); + const [baseUrlDraft, setBaseUrlDraft] = useState(null); + + const modelValue = modelDraft ?? settings?.model ?? ""; + const baseUrlValue = baseUrlDraft ?? settings?.baseUrl ?? ""; + const configured = settings?.hasKey ?? false; + + const save = useMutation({ + mutationFn: (payload: Record) => + apiPut("/api/admin/ai-settings", payload), + onSuccess: () => { + setKeyDraft(""); + setModelDraft(null); + setBaseUrlDraft(null); + queryClient.invalidateQueries({ + queryKey: queryKeys.adminAiServerKey(), + }); + toast.success(t("admin.aiServerKey.saved")); + }, + onError: (error: unknown) => { + toast.error( + error instanceof Error ? error.message : t("admin.aiServerKey.error"), + ); + }, + }); + + function handleSave() { + const payload: Record = { + model: modelValue, + baseUrl: baseUrlValue, + }; + if (keyDraft.trim().length > 0) { + payload.apiKey = keyDraft.trim(); + } + save.mutate(payload); + } + + function handleRemove() { + save.mutate({ apiKey: "" }); + } + + return ( +
+ +

{t("admin.aiServerKey.description")}

+

{t("admin.aiServerKey.byokHint")}

+

{t("admin.aiServerKey.consentHint")}

+ + } + status={ + + {configured + ? t("admin.aiServerKey.statusConfigured") + : t("admin.aiServerKey.statusNotConfigured")} + + } + /> + +
+
+ + setKeyDraft(e.target.value)} + placeholder={ + configured && settings?.keyPreview + ? t("admin.aiServerKey.keyPlaceholderSet", { + preview: settings.keyPreview, + }) + : t("admin.aiServerKey.keyPlaceholder") + } + autoComplete="off" + /> +
+ +
+
+ + setModelDraft(e.target.value)} + placeholder="gpt-4o" + /> +
+
+ + setBaseUrlDraft(e.target.value)} + placeholder="https://api.openai.com/v1" + /> +
+
+ +
+ + {configured && ( + + )} +
+
+
+ ); +} diff --git a/src/components/admin/api-token-overview-section.tsx b/src/components/admin/api-token-overview-section.tsx index 66631e0b6..114c2917f 100644 --- a/src/components/admin/api-token-overview-section.tsx +++ b/src/components/admin/api-token-overview-section.tsx @@ -11,6 +11,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { formatDate, formatDateTime } from "@/lib/format"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { type ApiTokenInfo } from "./_shared"; @@ -157,10 +158,11 @@ export function ApiTokenOverviewSection() { return (
-
- -
{t("admin.apiTokens")}
-
+
{isLoading ? ( diff --git a/src/components/admin/app-log-preview-section.tsx b/src/components/admin/app-log-preview-section.tsx index 84d71c62d..61a46d8a0 100644 --- a/src/components/admin/app-log-preview-section.tsx +++ b/src/components/admin/app-log-preview-section.tsx @@ -46,6 +46,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { formatDateTime } from "@/lib/format"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { WideEvent, LogLevel } from "@/lib/logging/types"; @@ -104,7 +105,9 @@ export function AppLogPreviewSection() { const { data, isLoading, refetch, isFetching } = useQuery({ queryKey: queryKeys.adminAppLogs(traceId, actionFilter, level, range), queryFn: async () => { - return apiGet(`/api/admin/app-logs?${params.toString()}`); + return apiGet( + `/api/admin/app-logs?${params.toString()}`, + ); }, refetchInterval: 30_000, }); @@ -112,31 +115,25 @@ export function AppLogPreviewSection() { const events = data?.events ?? []; return ( -
-
-
- -
-

- {t("admin.section.app-logs.title")} -

-

- {t("admin.section.app-logs.processNote")} -

-
-
- -
+
+ refetch()} + disabled={isFetching} + aria-label={t("admin.section.app-logs.refresh")} + > + + + } + />
{ - return apiGet("/api/admin/settings/assistant-flags"); + return apiGet( + "/api/admin/settings/assistant-flags", + ); }, }); } @@ -61,13 +64,13 @@ function useUpdateAssistantFlags() { mutationFn: async ( patch: Partial, ): Promise => { - return apiPut("/api/admin/settings/assistant-flags", patch); + return apiPut( + "/api/admin/settings/assistant-flags", + patch, + ); }, onSuccess: (data) => { - client.setQueryData( - queryKeys.adminAssistantFlags(), - data, - ); + client.setQueryData(queryKeys.adminAssistantFlags(), data); // Bust the runtime `/api/feature-flags` cache so the operator // sees the toggled surface react within the same session. client.invalidateQueries({ queryKey: queryKeys.featureFlags() }); @@ -96,73 +99,72 @@ export function AssistantSection() { const disabledSubs = !masterOn || mutation.isPending; return ( -
-
- -
{t("admin.assistant.title")}
-
-

- {t("admin.assistant.description")} -

- - - mutation.mutate({ assistantEnabled: checked }) - } - disabled={mutation.isPending} +
+ +
+ + mutation.mutate({ assistantEnabled: checked }) + } + disabled={mutation.isPending} + /> -
- - mutation.mutate({ assistantCoachEnabled: checked }) - } - disabled={disabledSubs} - /> - - mutation.mutate({ assistantBriefingEnabled: checked }) - } - disabled={disabledSubs} - /> - - mutation.mutate({ assistantInsightStatusEnabled: checked }) - } - disabled={disabledSubs} - /> - - mutation.mutate({ assistantCorrelationsEnabled: checked }) - } - disabled={disabledSubs} - /> - - mutation.mutate({ - assistantHealthScoreExplainerEnabled: checked, - }) - } - disabled={disabledSubs} - /> +
+ + mutation.mutate({ assistantCoachEnabled: checked }) + } + disabled={disabledSubs} + /> + + mutation.mutate({ assistantBriefingEnabled: checked }) + } + disabled={disabledSubs} + /> + + mutation.mutate({ assistantInsightStatusEnabled: checked }) + } + disabled={disabledSubs} + /> + + mutation.mutate({ assistantCorrelationsEnabled: checked }) + } + disabled={disabledSubs} + /> + + mutation.mutate({ + assistantHealthScoreExplainerEnabled: checked, + }) + } + disabled={disabledSubs} + /> +
); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 312d78910..138146e38 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -40,6 +40,7 @@ import { EmptyState } from "@/components/ui/empty-state"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { formatDateTime } from "@/lib/format"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import type { BackupRow, BackupsList } from "@/types/backups"; @@ -331,34 +332,34 @@ export function BackupsSection() { } return ( -
-
-
- -
- {t("admin.section.backups.title")} -
- {data && ( +
+ {rows.length} - )} -
- -
-

+ ) : null + } + status={ + + } + /> +

{t("admin.section.backups.description")}{" "} {/* External docs link — Phase E will publish the matching page on the docs site. `noopener noreferrer` because this leaves the diff --git a/src/components/admin/bug-report-section.tsx b/src/components/admin/bug-report-section.tsx index 5ab87bbf3..36c4c7275 100644 --- a/src/components/admin/bug-report-section.tsx +++ b/src/components/admin/bug-report-section.tsx @@ -2,13 +2,18 @@ import { useState } from "react"; import { Bug, Loader2 } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { useTranslations } from "@/lib/i18n/context"; -import { PasswordInput, useAdminSettings, useUpdateSettings } from "./_shared"; +import { + ConfiguredBadge, + PasswordInput, + useAdminSettings, + useUpdateSettings, +} from "./_shared"; export function BugReportSection() { const { t } = useTranslations(); @@ -45,27 +50,15 @@ export function BugReportSection() { } return ( -

-
-
- -

- {t("admin.bugReportGithub")} -

-
-
- {configured && ( - - {t("admin.configured")} - - )} -
-
-

- {t("admin.bugReportGithubDescription")} -

+
+ : null} + /> -
+

{t("admin.bugReportEnabledLabel")} diff --git a/src/components/admin/coach-feedback-section.tsx b/src/components/admin/coach-feedback-section.tsx index 26e9a3cd0..c78619301 100644 --- a/src/components/admin/coach-feedback-section.tsx +++ b/src/components/admin/coach-feedback-section.tsx @@ -21,6 +21,7 @@ import { useQuery } from "@tanstack/react-query"; import { Loader2, Sparkles } from "lucide-react"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; @@ -69,25 +70,23 @@ export function CoachFeedbackSection() { // /admin/coach-feedback. Mirrors the canonical structure used by // (header outside the fetch-state branch). return ( -

-
-
- -
- {t("admin.coachFeedback.title")} -
-
- {hasData && summary && ( -

- {t("admin.coachFeedback.windowLabel", { - days: String(summary.windowDays), - })} - {" · "} - {t("admin.coachFeedback.generatedAtLabel")}{" "} - {fmt.dateTime(summary.generatedAt)} -

- )} -
+
+ + {t("admin.coachFeedback.windowLabel", { + days: String(summary.windowDays), + })} + {" · "} + {t("admin.coachFeedback.generatedAtLabel")}{" "} + {fmt.dateTime(summary.generatedAt)} +

+ ) : null + } + /> {query.isLoading ? (
diff --git a/src/components/admin/danger-zone-section.tsx b/src/components/admin/danger-zone-section.tsx index 41983cf92..93cebfdd3 100644 --- a/src/components/admin/danger-zone-section.tsx +++ b/src/components/admin/danger-zone-section.tsx @@ -59,7 +59,7 @@ export function DangerZoneSection() { }); return ( -
+
{/* v1.4.19 A8 / F-08: page header `admin.section.danger-zone.*` already names the section, so the card-level title was a duplicate of the page title. The destructive icon stays so diff --git a/src/components/admin/feedback-inbox-section.tsx b/src/components/admin/feedback-inbox-section.tsx index cbda34295..246656a0d 100644 --- a/src/components/admin/feedback-inbox-section.tsx +++ b/src/components/admin/feedback-inbox-section.tsx @@ -54,7 +54,9 @@ export function FeedbackInboxSection() { const { data, isLoading } = useQuery({ queryKey: queryKeys.adminFeedback(activeStatus), queryFn: async () => { - return apiGet(`/api/admin/feedback?status=${activeStatus}&limit=100`); + return apiGet( + `/api/admin/feedback?status=${activeStatus}&limit=100`, + ); }, }); @@ -65,7 +67,7 @@ export function FeedbackInboxSection() { } return ( -
+
{/* v1.4.19 A8 / F-08 + F-20: this is the only card on `/admin/feedback`, so the page header already provides the title + description — the card-level repetition was just @@ -287,7 +289,9 @@ function FeedbackDetailDialog({ const publish = useMutation({ mutationFn: async () => { - return apiPost<{ issueUrl: string }>(`/api/admin/feedback/${item.id}/github`); + return apiPost<{ issueUrl: string }>( + `/api/admin/feedback/${item.id}/github`, + ); }, onSuccess: (data) => { setIssueUrl(data.issueUrl); diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx index dcc5314c8..85244075c 100644 --- a/src/components/admin/general-settings-section.tsx +++ b/src/components/admin/general-settings-section.tsx @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { Settings } from "lucide-react"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { NativeSelect } from "@/components/ui/native-select"; import { SettingsToggle, useAdminSettings, useUpdateSettings } from "./_shared"; @@ -25,12 +26,13 @@ export function GeneralSettingsSection() { const currentDefaultTz = settings?.defaultUserTimezone ?? ""; return ( -
-
- -
{t("admin.appSettings")}
-
-
+
+ +
-
-
- -

{t("admin.glitchtipTitle")}

-
-
- {configured && ( - - {t("admin.configured")} - - )} -
-
-

- {t("admin.glitchtipDescription")} -

+
+ : null} + /> -
+
= 1 + ? Math.min(parsed, MAX_USES_CAP) + : 1; +} + /** Whole days (ceil) from `now` until `iso`; negative when past. */ function daysUntil(iso: string, now: Date): number { const ms = new Date(iso).getTime() - now.getTime(); @@ -253,12 +265,7 @@ export function InviteTokensSection() { } function submitCreate() { - const parsedMax = Number.parseInt(maxUses, 10); - const safeMax = - Number.isFinite(parsedMax) && parsedMax >= 1 - ? Math.min(parsedMax, MAX_USES_CAP) - : 1; - create.mutate({ expiresInDays: ttlDays, maxUses: safeMax }); + create.mutate({ expiresInDays: ttlDays, maxUses: clampMaxUses(maxUses) }); } const now = new Date(); @@ -307,12 +314,15 @@ export function InviteTokensSection() {
-
-

+

{t("admin.invites.description")}

@@ -552,73 +562,79 @@ export function InviteTokensSection() { }} className="space-y-4" > -

-
- - {t("admin.invites.ttlLegend")} - -
- {TTL_CHOICES.map((days) => ( - - ))} -
-
-
- - setMaxUses(e.target.value)} - className="w-24 tabular-nums" - data-testid="admin-invites-max-uses" - /> -

- {t("admin.invites.maxUsesHint")} -

-
-
- - + ))} +
+ +
+ + setMaxUses(e.target.value)} + onBlur={() => { + // Echo the submit-time clamp (1–50, integer) into + // the field so the admin sees what will be minted. + setMaxUses(String(clampMaxUses(maxUses))); + }} + className="w-24 tabular-nums" + data-testid="admin-invites-max-uses" /> - ) : ( -
+
+ + + ) : ( @@ -753,7 +769,12 @@ function RedemptionsCell({ count: invite.redemptions.length, })}

-
    + {/* v1.16.5 — a 50-use invite lists up to 50 signups; cap the + list height so the popover never outgrows the viewport. */} +
      {invite.redemptions.map((entry) => (
    • diff --git a/src/components/admin/login-overview-section.tsx b/src/components/admin/login-overview-section.tsx index a31532966..780f635f6 100644 --- a/src/components/admin/login-overview-section.tsx +++ b/src/components/admin/login-overview-section.tsx @@ -21,6 +21,7 @@ import { SelectValue, } from "@/components/ui/select"; import { formatDateTime } from "@/lib/format"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { toCSV } from "@/lib/export"; @@ -131,7 +132,9 @@ export function LoginOverviewSection() { if (filter === "failed") { params.set("action", "auth.login.failed"); } - return apiGet(`/api/admin/audit-log?${params.toString()}`); + return apiGet( + `/api/admin/audit-log?${params.toString()}`, + ); }, }); @@ -193,11 +196,12 @@ export function LoginOverviewSection() { } return ( -
      -
      - -
      {t("admin.loginOverview")}
      -
      +
      +
      {/* Quick-filter pills. Failed-only is the most common diff --git a/src/components/admin/recent-audit-preview.tsx b/src/components/admin/recent-audit-preview.tsx index 138e223c0..486afb127 100644 --- a/src/components/admin/recent-audit-preview.tsx +++ b/src/components/admin/recent-audit-preview.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import { EmptyState } from "@/components/ui/empty-state"; import { formatDateTime } from "@/lib/format"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { type AdminAuditEntry, useAuthActionLabels } from "./_shared"; @@ -38,7 +39,9 @@ export function RecentAuditPreview() { const { data, isLoading, isError } = useQuery({ queryKey: queryKeys.adminAuditOverview(), queryFn: async () => { - return apiGet(`/api/admin/audit-log?limit=${PREVIEW_LIMIT}&filter=auth`); + return apiGet( + `/api/admin/audit-log?limit=${PREVIEW_LIMIT}&filter=auth`, + ); }, // Avoid hitting the DB on every page focus; the overview is the // type of page admins keep open and re-render frequently. @@ -52,26 +55,22 @@ export function RecentAuditPreview() { return (
      -
      -
      -
      - - {t("admin.overview.auditViewAll")} -
      + {t("admin.overview.auditViewAll")} +
- +
)} - +
+
); } diff --git a/src/components/settings/cycle-tracking-card.tsx b/src/components/settings/cycle-tracking-card.tsx index 575f019ba..2a7cbf6af 100644 --- a/src/components/settings/cycle-tracking-card.tsx +++ b/src/components/settings/cycle-tracking-card.tsx @@ -59,7 +59,7 @@ export function CycleTrackingCard({ const enabled = data?.cycleTrackingEnabled ?? false; return ( -
+
{/* v1.4.19 A6 — title / action row uses the same stack-on-mobile, right-align-on-desktop contract as Account → Password + diff --git a/src/components/settings/dashboard-section.tsx b/src/components/settings/dashboard-section.tsx index 628192037..59f4e4dc1 100644 --- a/src/components/settings/dashboard-section.tsx +++ b/src/components/settings/dashboard-section.tsx @@ -12,10 +12,7 @@ export function DashboardSection() { className="space-y-6" >
-

+

{t("settings.sections.dashboard.title")}

diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx index 7652965e6..6761a8daf 100644 --- a/src/components/settings/export-section.tsx +++ b/src/components/settings/export-section.tsx @@ -79,10 +79,7 @@ export function ExportSection() { className="space-y-6" >

-

+

{t("settings.sections.export.title")}

@@ -158,7 +155,7 @@ function ExportCardShell({ return (

@@ -505,7 +502,10 @@ function CycleExportCard() { const prefsQuery = useQuery({ queryKey: queryKeys.cyclePrefs(), queryFn: async (): Promise<{ cycleTrackingEnabled: boolean }> => { - return apiGet<{ cycleTrackingEnabled: boolean }>("/api/auth/me/cycle-prefs", { credentials: "include" }); + return apiGet<{ cycleTrackingEnabled: boolean }>( + "/api/auth/me/cycle-prefs", + { credentials: "include" }, + ); }, staleTime: 5 * 60 * 1000, }); diff --git a/src/components/settings/import-panel.tsx b/src/components/settings/import-panel.tsx index 3a9fba2e3..7380a5844 100644 --- a/src/components/settings/import-panel.tsx +++ b/src/components/settings/import-panel.tsx @@ -93,10 +93,13 @@ function ImportCardShell({ return (
-

{description}

@@ -144,7 +147,10 @@ function AppleHealthImportCard() { return 2000; }, queryFn: async (): Promise => { - return apiGet(`/api/import/apple-health-export/${jobId}/status`, { credentials: "include" }); + return apiGet( + `/api/import/apple-health-export/${jobId}/status`, + { credentials: "include" }, + ); }, }); diff --git a/src/components/settings/injection-sites-card.tsx b/src/components/settings/injection-sites-card.tsx index 89549d7d4..0832df568 100644 --- a/src/components/settings/injection-sites-card.tsx +++ b/src/components/settings/injection-sites-card.tsx @@ -47,7 +47,9 @@ export function InjectionSitesCard({ const mutation = useMutation({ mutationFn: async (next: InjectionSiteKey[]) => { - await apiPatch("/api/auth/me/injection-site-prefs", { globalExcludedInjectionSites: next }); + await apiPatch("/api/auth/me/injection-site-prefs", { + globalExcludedInjectionSites: next, + }); }, onSuccess: () => { void queryClient.invalidateQueries({ diff --git a/src/components/settings/insights-pill-order-section.tsx b/src/components/settings/insights-pill-order-section.tsx index c4bf81792..29435a94a 100644 --- a/src/components/settings/insights-pill-order-section.tsx +++ b/src/components/settings/insights-pill-order-section.tsx @@ -125,12 +125,12 @@ export function InsightsPillOrderSection({ id }: { id?: string }) { const saveMutation = useMutation({ mutationFn: async (tiles: InsightsTileConfig[]) => { return apiPut("/api/insights/layout", { - version: 2, - // Preserve the overview section order/visibility verbatim — this - // surface owns the PILL order only. - sections: layout.sections, - tiles, - }); + version: 2, + // Preserve the overview section order/visibility verbatim — this + // surface owns the PILL order only. + sections: layout.sections, + tiles, + }); }, onSuccess: (saved) => { queryClient.setQueryData(queryKeys.insightsLayout(), saved); diff --git a/src/components/settings/insights-section.tsx b/src/components/settings/insights-section.tsx index 626efb7d9..aee780b3a 100644 --- a/src/components/settings/insights-section.tsx +++ b/src/components/settings/insights-section.tsx @@ -24,10 +24,7 @@ export function InsightsSection() { className="space-y-6" >
-

+

{t("settings.sections.insights.title")}

diff --git a/src/components/settings/integration-status-pill.tsx b/src/components/settings/integration-status-pill.tsx index 5e330400f..28806b036 100644 --- a/src/components/settings/integration-status-pill.tsx +++ b/src/components/settings/integration-status-pill.tsx @@ -106,12 +106,9 @@ export function IntegrationStatusPill({ // a reauth won't fix" vs "tried to keep going for 24h but the mismatch // persists, manual intervention required" — distinct from the red // reconnect pill (`error`). - const connectedChipClass = - "border-success/30 bg-success/15 text-success"; - const warningChipClass = - "border-warning/30 bg-warning/15 text-warning"; - const parkedChipClass = - "border-warning/30 bg-warning/15 text-warning"; + const connectedChipClass = "border-success/30 bg-success/15 text-success"; + const warningChipClass = "border-warning/30 bg-warning/15 text-warning"; + const parkedChipClass = "border-warning/30 bg-warning/15 text-warning"; let label: string; let icon: React.ReactNode; diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 3a8548709..5b777b5e8 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -99,10 +99,7 @@ export function IntegrationsSection() { className="space-y-6" >

-

+

{t("settings.sections.integrations.title")}

diff --git a/src/components/settings/integrations/fitbit-card.tsx b/src/components/settings/integrations/fitbit-card.tsx index 22f1c95ca..5b394c615 100644 --- a/src/components/settings/integrations/fitbit-card.tsx +++ b/src/components/settings/integrations/fitbit-card.tsx @@ -9,7 +9,14 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { HeartPulse, Link2, Loader2, RefreshCw, Save, Unlink } from "lucide-react"; +import { + HeartPulse, + Link2, + Loader2, + RefreshCw, + Save, + Unlink, +} from "lucide-react"; import { AlertDialog, @@ -182,7 +189,7 @@ export function FitbitCard({ return (

+
+
{enabled && (
-