Merge pull request #310 from MBombeck/release/v1.16.4

Release v1.16.4 — half the bundle, a typed client, and stock you can record
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-11 11:08:17 +02:00
commit 599b7e8c99
494 files changed
+18177 -14147

No files matched your search

+5
View File
@@ -68,6 +68,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# Optional: deployment-dashboard URL (e.g. the Coolify UI) linked in
# deploy-failure notifications. When unset the notification omits the link.
# DEPLOY_LOGS_URL=""
# Optional replay hardening: require a fresh X-Deploy-Webhook-Timestamp
# header (unix seconds / ms or ISO-8601, +/- 5 minutes) on every webhook
# call. Leave unset for Coolify's stock sender (it attaches no
# timestamp); see docs/ops/deploy.md.
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="false"
# -----------------------------------------------------------------------------
+35
View File
@@ -268,6 +268,12 @@ jobs:
permissions:
contents: read
packages: write
# v1.16.4 — keyless image signing. The job requests an OIDC
# token from GitHub's issuer; cosign exchanges it for a
# short-lived Fulcio certificate bound to this workflow identity
# and records the signature in the Rekor transparency log. No
# long-lived signing key exists anywhere in this pipeline.
id-token: write
steps:
- name: Download digest artifacts
@@ -333,6 +339,35 @@ jobs:
run: |
docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}'
# v1.16.4 — sign the multi-arch manifest index (keyless, Sigstore).
# Signing the INDEX digest covers both per-platform manifests in
# one signature, and the digest is the only stable identity — every
# tag (`:latest`, `:1.16.4`, …) is a mutable alias that a registry
# compromise could repoint. Operators verify with:
#
# cosign verify \
# --certificate-identity-regexp 'https://github.com/MBombeck/HealthLog/\.github/workflows/docker-publish\.yml@refs/tags/v.*' \
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
# ghcr.io/mbombeck/healthlog:<tag>
#
# See docs/ops/deploy.md ("Verify the image signature") for the
# digest-pinned deploy recommendation that pairs with this.
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Sign multi-arch manifest (keyless)
env:
IMAGE_VERSION: ${{ steps.meta.outputs.version }}
run: |
digest="$(docker buildx imagetools inspect "${IMAGE_REF}:${IMAGE_VERSION}" --format '{{json .Manifest.Digest}}' | tr -d '"')"
if [ -z "$digest" ]; then
echo "::error::Could not resolve the manifest digest for ${IMAGE_REF}:${IMAGE_VERSION} — signing aborted."
exit 1
fi
echo "Signing ${IMAGE_REF}@${digest}"
cosign sign --yes "${IMAGE_REF}@${digest}"
echo "::notice::Image index signed (keyless): ${IMAGE_REF}@${digest}. Verify with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/${{ github.repository }}/' ${IMAGE_REF}@${digest}"
# v1.4.15: trigger the Coolify deploy automatically once GHCR has
# the new image. Replaces the former manual SSH + force-pull recipe
# on the deployment host.
+23
View File
@@ -2,6 +2,29 @@
## [Unreleased]
## [1.16.4] — 2026-06-11 — half the bundle, a typed client, and stock you can record
### Added
- **The inventory tab records stock** — packs and pens with optional expiry, corrections and withdrawals. The detail page gains a dedicated API tab, an editor entry under lifecycle, and a danger zone without the red drama.
- **Record the dose you actually took.** Intakes carry an optional actual dose, editable in the quick-add and log dialogs and shown as a quiet deviation in the history.
- **Undo for deletes.** Removing a measurement or mood entry can be taken back from the toast.
- **Pull-to-refresh** on the measurement and mood lists, and the customize entry on every insights sub-page.
- **Answered coach follow-ups can flow into your self-context** with one tap.
### Changed
- **The app got dramatically lighter and faster.** Only the active language ships to the browser — the dashboard route drops from 2.1 MB to under 1 MB parsed (288 KB gzip), with no language flash. The shell paints immediately while the session resolves, a redundant request stage is gone, and insight sub-pages prefetch on approach: a first tab switch lands in tens of milliseconds instead of nearly a second.
- **Every client call goes through one typed API client** with the envelope, error and ok-handling in a single place, enforced by lint. The route registry, query keys, background worker and dashboard page split into feature-located modules behind thin indexes.
- **Settings behave one way.** Provider and integration cards live in focused subcomponents, every setting persists server-side at exactly one home, save conventions are documented, and duplicated controls collapsed into single owners with links.
- **Published images are signed**, dependabot merges only behind the required checks, a pre-commit secret scan guards every commit, the deploy webhook can demand a fresh timestamp, and a monthly drill proves the newest backup actually decrypts.
### Fixed
- Server and first client render now agree everywhere query state used to leak into the chrome.
- Delete confirmations admit the undo they offer, step counts drop their decimals, streaks know their singular, the medication header speaks your date format, and a failed coach turn keeps your message and says what went wrong.
- A language switch updates pending status notes and only flips once the new language is actually loaded.
## [1.16.3] — 2026-06-10 — schedules remember their history
### Fixed
+1 -1
View File
@@ -22,7 +22,7 @@
<a href="https://testflight.apple.com/join/bucuTBpa">iOS TestFlight</a>
</p>
<img width="2316" height="1207" alt="HealthLog dashboard: health metric tiles, trend charts, and medication overview" src="https://github.com/user-attachments/assets/aac0931d-5ea2-45b8-9a12-5f2f013d19e5" />
<img width="2560" height="1600" alt="HealthLog dashboard: health metric tiles with 7-day trends and a weight chart, dark theme" src="docs/assets/dashboard-hero.png" />
## Why HealthLog
+4
View File
@@ -77,6 +77,10 @@ services:
# .env reach the container (the `environment:` block is a whitelist).
DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
# Optional replay hardening: when "true", the deploy webhook
# requires a fresh X-Deploy-Webhook-Timestamp header (±5 min).
# See docs/ops/deploy.md.
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}"
# TLS leaf SPKI-change alarm baseline (optional). The native client
# pins the served TLS leaf; this is the known-good pin set the monitor
# alarms against on a rotation. Comma-separated for the dual-pin
+74 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.16.3
version: 1.16.4
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -1735,6 +1735,31 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/measurements/restore:
post:
tags:
- Measurements
summary: Restore soft-deleted measurements
description: Un-tombstones up to 200 of the caller's soft-deleted measurement rows in one call — the delete-Undo
counterpart to `/api/measurements/bulk-delete`. Idempotent via the `Idempotency-Key` header; rate-limited
`measurements:restore:<userId>`. Forged / foreign / not-deleted ids are silently skipped — `restored` is the
count of rows actually un-tombstoned. The restored rows re-surface in `/api/sync/changes` as upserts.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MeasurementsRestoreRequest"
responses:
"200":
description: Restore processed.
content:
application/json:
schema:
$ref: "#/components/schemas/MeasurementsRestoreResponse"
"401": *a1
"422": *a3
"429": *a4
/api/sleep/night:
get:
tags:
@@ -4229,6 +4254,20 @@ components:
required:
- ids
description: 1..200 measurement ids, scoped to the caller. Forged / foreign ids are silently skipped (no existence leak).
MeasurementsRestoreRequest:
type: object
properties:
ids:
minItems: 1
maxItems: 200
type: array
items:
type: string
minLength: 1
required:
- ids
description: 1..200 measurement ids, scoped to the caller. Forged / foreign / not-deleted ids are silently skipped (no
existence leak).
DeviceRegisterRequest:
type: object
properties:
@@ -4709,6 +4748,13 @@ components:
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)))$
doseTaken:
description: Dose actually consumed for THIS intake when it deviates from (or documents) the medication's configured
dose, e.g. a half tablet or a titration step. Free text, max 50 characters. Omit to record the take under
the medication's configured dose.
type: string
minLength: 1
maxLength: 50
required:
- medicationId
description: Per-medication intake log body. Idempotent via `idempotencyKey`; the server also dedupes by a 60-second
@@ -7316,6 +7362,33 @@ components:
required:
- deleted
additionalProperties: false
MeasurementsRestoreResponse:
type: object
properties:
data:
$ref: "#/components/schemas/MeasurementsRestoreResult"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MeasurementsRestoreResult:
type: object
properties:
restored:
type: integer
minimum: 0
maximum: 9007199254740991
required:
- restored
additionalProperties: false
SleepNightResponse:
type: object
properties:
Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

+27
View File
@@ -85,3 +85,30 @@ pnpm tsx scripts/restore-backup.ts \
The script downloads the object, decrypts it, and writes the JSON dump
to disk. Importing the JSON back into a HealthLog instance is left to
the operator (use `prisma db seed` or a custom script).
## Monthly restore drill (automatic)
Since v1.16.4 a pg-boss job (`data-restore-drill`, cron `11 4 1 * *` —
04:11 on the 1st of each month) exercises the read path end-to-end:
fetch the most recent backup object from the bucket, decrypt it under
the current `BACKUP_ENCRYPTION_KEY`, JSON-parse it, and sanity-check
the payload shape. It performs **no database restore** — it validates
the artefact, not the import path.
Outcomes:
- **Success** — record counts, object age, and sizes land in the
wide-event meta (`job.restore_drill`).
- **Stale chain** — the newest object is older than 3 days: the nightly
uploader has stalled (or the lifecycle rule is too aggressive). The
drill pages via the worker error reporter (stderr + GlitchTip).
- **Failure** — empty bucket, fetch error, decryption failure (wrong or
rotated key), malformed JSON: pages the same way. A decryption
failure right after a `BACKUP_ENCRYPTION_KEY` change means the new
key cannot read the existing objects — re-encrypt or accept that
pre-rotation backups are only readable with the retired key.
- **Not configured** — deployments without the `BACKUP_S3_*` vars skip
silently (wide-event warning only).
The drill needs no IAM grant beyond the uploader's existing
`GetObject` + `ListBucket`.
+74 -1
View File
@@ -70,7 +70,7 @@ roll back:
release pair shipped on the v1.4.x line.
3. If a column-drop migration ships in the rollback target, hand-roll
the column back via a fresh forward migration (`pnpm prisma migrate
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first.
@@ -90,3 +90,76 @@ pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
Pass `--only=prod` / `--only=demo` to check a single target, or
`--attempts=N --interval=ms` to widen the retry window while a slow
pull settles.
## Verify the image signature
Every release image (the multi-arch manifest **index**, covering both
`amd64` and `arm64`) is signed keyless via Sigstore in the
`docker-publish.yml` merge job: GitHub's OIDC token is exchanged for a
short-lived Fulcio certificate bound to the workflow identity, and the
signature lands in the Rekor transparency log. There is no long-lived
signing key to leak or rotate.
Verify before (or after) a deploy with [cosign](https://docs.sigstore.dev/cosign/system_config/installation/):
```sh
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp 'https://github.com/MBombeck/HealthLog/\.github/workflows/docker-publish\.yml@refs/tags/v.*' \
ghcr.io/mbombeck/healthlog:1.16.4
```
A passing verification proves the image was built by this repository's
release workflow on a `v*` tag — not by anyone who merely obtained push
access to the GHCR package.
### Prefer digest-pinned deploys
Tags (`:latest`, `:1.16.4`) are mutable aliases: a registry-side
compromise or an accidental re-push can repoint them. The digest is the
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:
```sh
cosign verify ... ghcr.io/mbombeck/healthlog:1.16.4 \
| jq -r '.[0].critical.image."docker-manifest-digest"'
```
2. Pin the compose `image:` (or the Coolify image reference) to
`ghcr.io/mbombeck/healthlog@sha256:<digest>` instead of the tag.
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.
## Deploy-status webhook: replay protection
`POST /api/internal/deploy-webhook` authenticates via the static
`X-Deploy-Webhook-Secret` header. Since v1.16.4 it additionally
enforces a freshness window: a request carrying an
`X-Deploy-Webhook-Timestamp` header (unix seconds, unix milliseconds,
or ISO-8601) is rejected with `401 {"status":"stale"}` when the
timestamp lies more than **5 minutes** from server time — so a captured
request cannot be replayed later to re-page admins or forge deploy
outcomes in the audit log.
Two modes:
- **Default (tolerant).** Requests _without_ the header pass — Coolify's
stock notification sender does not attach one. A header that is
present but stale or unparseable is always rejected.
- **Strict.** Set `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` in the app
environment to make the header mandatory, closing the replay window
completely. Use this when the sender (or a reverse proxy in front of
the app) can attach the timestamp, e.g.:
```sh
curl -X POST https://<host>/api/internal/deploy-webhook \
-H "X-Deploy-Webhook-Secret: $SECRET" \
-H "X-Deploy-Webhook-Timestamp: $(date +%s)" \
-H "Content-Type: application/json" \
-d '{"status":"success"}'
```
+32 -3
View File
@@ -149,9 +149,38 @@ Wrap inputs in `<label>` (visible) plus `aria-describedby` for helper text.
_and_ the user routinely sweeps. For most thresholds and counts, an
`<Input type="number">` plus stepper buttons is faster and less
fiddly. See §6 (Slider audit) for the migration path.
- **Boolean toggle**: `<Switch>` for "applies immediately" settings (e.g.
enable Telegram). `<Checkbox>` for "applies on Save" lists (e.g. select
measurements to export).
- **Boolean toggle**: `<Switch>` for settings. Exactly two persistence
patterns are legal (see §2.2.1); pick one per card, never mix.
`<Checkbox>` stays reserved for "applies on Save" _lists_ (e.g. select
measurements to export) and for action parameters that are not
persisted at all (export options).
#### 2.2.1 Settings persistence patterns
Every control under `/settings` follows exactly one of two save
patterns. The pattern is a property of the **card**, not of the
individual control:
1. **Immediate persist** — a standalone boolean that is the card's only
mutable state. The `<Switch>` fires the mutation in
`onCheckedChange`, disables itself while pending, updates
optimistically with rollback, and toasts on success/error. No Save
button anywhere on the card. Examples: hide-Coach, ntfy enable,
cycle tracking, research mode, mood reminder, Coach nudges.
2. **Draft + Save** — the card edits a multi-field payload (or a list
the server PUTs whole). All controls — including `<Switch>` rows —
mutate a local draft only; a visible Save button flushes the draft
in one request and the card surfaces its dirty state. Examples:
Coach preferences, Telegram (enable + bot token), provider fallback
chain, dashboard layout, threshold editor.
A `<Switch>` inside a draft+Save card is legal **only** when the card
shows the Save button; a card with both an auto-persisting switch and
a Save-gated field is a bug. Every setting presented under `/settings`
persists server-side. `localStorage` is reserved for device-local UI
state that is not a setting (sidebar collapse, last-update-check
timestamp, tour progress) — label such keys with a comment at the
declaration site and never render them with Save/Saved affordances.
**Validation.** Inline messages below the field. Field gets
`aria-invalid="true"` and a destructive border. Form-level errors land
+2 -2
View File
@@ -116,7 +116,7 @@ test.describe("axe-core authenticated surfaces", () => {
}
// The keyboard-only "skip to content" link is fixed at the top-left
// corner of every authenticated page. Marc reported it intercepting
// corner of every authenticated page. The maintainer reported it intercepting
// logo clicks in v1.4.14 — the fix combines `pointer-events-none`
// with `-translate-y-full` so the element sits offscreen until
// focused. Regression guard: with no keyboard focus, the logo's own
@@ -127,7 +127,7 @@ test.describe("axe-core authenticated surfaces", () => {
}) => {
// The desktop sidebar (and its logo) is `hidden md:flex` — the
// mobile profile (Pixel 5) hides it by design. The skip-link
// regression Marc originally reported in v1.4.14 was a desktop-
// regression the maintainer originally reported in v1.4.14 was a desktop-
// only artifact (mobile uses the topbar logo, which is a
// different DOM node). Skip the mobile project so we don't
// flake on a contract that doesn't apply there.
+8 -8
View File
@@ -5,7 +5,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.16 A3 — `/admin/api-tokens` must not produce horizontal page
* overflow on a Pixel-5 viewport (393 CSS px). v1.4.15 added column-
* hide breakpoints + tighter card padding, but Marc still saw a
* hide breakpoints + tighter card padding, but the maintainer still saw a
* scrollbar on prod. The v1.4.16 fix swaps the desktop `<table>` for a
* mobile card-list at <md, mirroring `<UserManagementSection>`.
*
@@ -23,7 +23,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
}) => {
// Mock the admin tokens endpoint with a moderately wide payload —
// a token with multiple permissions, a long name, and timestamps.
// This is the realistic worst case Marc hit on prod.
// This is the realistic worst case the maintainer hit on prod.
await page.route("**/api/admin/tokens", (route) =>
route.fulfill({
status: 200,
@@ -38,7 +38,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
expiresAt: null,
createdAt: "2026-05-01T08:00:00Z",
revoked: false,
user: { id: "u1", username: "marc-the-very-long-username" },
user: { id: "u1", username: "user-the-very-long-username" },
},
{
id: "tok2",
@@ -48,7 +48,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
expiresAt: "2026-08-01T00:00:00Z",
createdAt: "2026-04-15T08:00:00Z",
revoked: false,
user: { id: "u1", username: "marc-the-very-long-username" },
user: { id: "u1", username: "user-the-very-long-username" },
},
],
error: null,
@@ -76,7 +76,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
});
// v1.4.18 phase A2 — production probe at Pixel-5 viewport pinned the
// visible "scrollbar" Marc reported for the third time as the
// visible "scrollbar" the maintainer reported for the third time as the
// `<AdminShell>` mobile section strip, NOT the api-tokens table. The
// strip carries 13 admin sections so its scrollWidth (~1700 CSS px)
// permanently exceeds clientWidth (~360 CSS px). Adding the
@@ -120,7 +120,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
});
// v1.4.19 phase A7 (4th attempt) — even with the mobile-strip
// `no-scrollbar` fix from v1.4.18, Marc still reported a painted
// `no-scrollbar` fix from v1.4.18, the maintainer still reported a painted
// scrollbar. The card body itself must not contribute any
// horizontal-overflow culprit: every element inside the api-tokens
// section must satisfy `scrollWidth <= clientWidth + 1`. Pads with
@@ -149,7 +149,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
revoked: false,
user: {
id: "u1",
username: "marc-the-very-long-username-that-overflows",
username: "user-the-very-long-username-that-overflows",
},
},
],
@@ -166,7 +166,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
// Walk every element inside the mobile card-list and assert no
// child has a wider scrollWidth than its container. This is the
// Pixel-5 worst-case Marc kept hitting.
// Pixel-5 worst-case the maintainer kept hitting.
//
// Elements clamped via `truncate` (Tailwind's `overflow:hidden +
// text-overflow:ellipsis + white-space:nowrap` shorthand) are by
+1 -1
View File
@@ -9,7 +9,7 @@ import {
/**
* v1.4.18 — per-chart overlay-controls popover.
*
* Marc reverted v1.4.16's always-on chart overlays (gradient fill,
* The maintainer reverted v1.4.16's always-on chart overlays (gradient fill,
* personal-baseline line, target-zone shading) and asked for per-chart
* switches that the user can flip on or off. This spec exercises the
* end-to-end behaviour against the seeded test user:
+1 -1
View File
@@ -22,7 +22,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
*
* Walks the 7-step daily path through the modal dialog. The dialog
* mounts when `/medications/new` redirects to `/medications?new=1`.
* The DE label surface (Marc's primary locale) is the assertion
* The DE label surface (the maintainer's primary locale) is the assertion
* surface — "Schritt N von 7" tracks the visible counter.
*/
test.describe("medication wizard — daily", () => {
+3 -1
View File
@@ -78,7 +78,9 @@ test.describe("medication wizard — one-shot", () => {
await expectStep(page, 5, 5);
const summary = page.locator('[data-slot="wizard-summary"]');
await expect(summary).toContainText(/Einmaldosis/i);
await expect(summary).toContainText(/2026-10-15/);
// The summary renders the date through the locale-aware formatter
// (DE UI → "15.10.2026"), not the ISO value the input carries.
await expect(summary).toContainText(/15\.10\.2026/);
await clickSave(page);
+1 -1
View File
@@ -23,7 +23,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* Walks the 8-step recurring path with "Flexibel ab letzter
* Einnahme" picked on Step 5 and rollingDays = 7 set on Step 6.
* Emits `rollingIntervalDays: 7` (no rrule). The cadence row also
* surfaces Marc's literal "Counter startet neu" copy directly below
* surfaces the maintainer's literal "Counter startet neu" copy directly below
* the row label — the assertion below pins the phrase so a future
* copy edit can't silently drop the rolling explainer.
*/
+1 -1
View File
@@ -5,7 +5,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.16 A1 — Sidebar Admin entry must mirror the Settings entry
* exactly: a single link with no sub-item expansion in the global
* sidebar. Marc reported in v1.4.15 that the sidebar was auto-expanding
* sidebar. The maintainer reported in v1.4.15 that the sidebar was auto-expanding
* admin sub-items on `/admin/*` and the gravatar dropdown felt linked to
* the sidebar — both unwanted UX. The in-shell `<AdminShell>` already
* renders its own per-section nav inside the page itself.
@@ -0,0 +1,133 @@
/**
* @fileoverview ESLint rule — client-side `/api/...` calls must route
* through the typed `apiFetch` wrapper.
*
* `src/lib/api/api-fetch.ts` owns the `.ok` check, the
* `{ data, error, meta? }` envelope unwrap, and the `ApiError` shape
* (message via `readError`, `status`, error-side `meta`). A raw
* `fetch("/api/…")` in a component re-implements that contract by hand
* — the exact drift this wrapper retired — so this rule flags it at
* authoring time and in CI.
*
* Complements `healthlog/safe-fetch-required`: that rule covers
* OUTBOUND egress (absolute / variable-sourced URLs) and exempts
* same-origin relative paths; this rule covers exactly those same-origin
* `/api/...` paths inside the client surface.
*
* Allowed:
* - `apiFetch` / verb helpers / `apiFetchEnvelope` / `apiFetchRaw` —
* distinct identifiers, never match.
* - calls inside `src/lib/api/api-fetch.ts` — the wrapper's own `fetch`.
* - test files (`*.test.ts(x)`, `__tests__/`, `__mocks__/`) — they
* mock or assert against `fetch` directly.
*
* The check is a syntactic `CallExpression` match against a bare
* `fetch(` callee (Identifier `fetch`) and `globalThis.fetch(` /
* `window.fetch(` / `self.fetch(` member forms, where the first
* argument is a string literal or template literal whose head starts
* with `/api/`. Variable-sourced or absolute URLs are the
* safe-fetch-required rule's territory and stay out of scope here.
*
* @see src/lib/api/api-fetch.ts
* @see eslint-plugins/healthlog/safe-fetch-required.js
*/
"use strict";
// Files exempt from the rule — the wrapper internals and the test
// surface. Posix-style suffix matches against the absolute filename via
// `String#includes`, mirroring the safe-fetch-required convention.
const EXEMPT_FILES = ["src/lib/api/api-fetch.ts"];
// Enforce across the client-facing source roots. `src/lib/` stays out:
// server-side lib code never self-calls `/api/` routes, and the
// safe-fetch rule already covers its outbound calls. `src/hooks/` is
// client-only by construction (v1.16.4) — its `/api/` reads route
// through the wrapper like every component.
const ENFORCED_ROOTS = ["src/components/", "src/app/", "src/hooks/"];
function toPosix(filename) {
return filename.replace(/\\/g, "/");
}
function isTestFile(posix) {
return (
/\.test\.[cm]?[jt]sx?$/.test(posix) ||
/\.spec\.[cm]?[jt]sx?$/.test(posix) ||
posix.includes("/__tests__/") ||
posix.includes("/__mocks__/")
);
}
function isEnforced(filename) {
const posix = toPosix(filename);
if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false;
if (EXEMPT_FILES.some((f) => posix.includes(f))) return false;
if (isTestFile(posix)) return false;
return true;
}
/** @type {import("eslint").Rule.RuleModule} */
const apiFetchRequiredRule = {
meta: {
type: "problem",
docs: {
description:
"Same-origin /api/ calls must route through the apiFetch wrapper (src/lib/api/api-fetch.ts).",
},
schema: [],
messages: {
rawApiFetch:
'Raw fetch("/api/…") re-implements the envelope unwrap + error contract by hand. Import { apiFetch } (or a verb helper / apiFetchRaw) from "@/lib/api/api-fetch" and call it instead.',
},
},
create(context) {
const filename = context.filename ?? context.getFilename?.();
if (!filename || !isEnforced(filename)) {
return {};
}
function isFetchCallee(callee) {
// Bare `fetch(...)`.
if (callee.type === "Identifier" && callee.name === "fetch") return true;
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`.
if (
callee.type === "MemberExpression" &&
!callee.computed &&
callee.property.type === "Identifier" &&
callee.property.name === "fetch" &&
callee.object.type === "Identifier" &&
(callee.object.name === "globalThis" ||
callee.object.name === "window" ||
callee.object.name === "self")
) {
return true;
}
return false;
}
function isApiPath(arg) {
if (!arg) return false;
const isApiHead = (head) => head.startsWith("/api/");
// `fetch("/api/…")`
if (arg.type === "Literal" && typeof arg.value === "string") {
return isApiHead(arg.value);
}
// `fetch(`/api/${id}`)` — inspect the template's first cooked chunk.
if (arg.type === "TemplateLiteral" && arg.quasis.length > 0) {
return isApiHead(arg.quasis[0].value.cooked ?? "");
}
return false;
}
return {
CallExpression(node) {
if (!isFetchCallee(node.callee)) return;
if (!isApiPath(node.arguments[0])) return;
context.report({ node, messageId: "rawApiFetch" });
},
};
},
};
module.exports = apiFetchRequiredRule;
+3
View File
@@ -4,16 +4,19 @@
*
* - `queryKey-factory` — bare-array queryKey / mutationKey bypass guard.
* - `safe-fetch-required` — outbound fetch must route through safeFetch.
* - `api-fetch-required` — client /api/ calls must route through apiFetch.
*/
"use strict";
const queryKeyFactory = require("./queryKey-factory.js");
const safeFetchRequired = require("./safe-fetch-required.js");
const apiFetchRequired = require("./api-fetch-required.js");
module.exports = {
rules: {
"queryKey-factory": queryKeyFactory.rules["queryKey-factory"],
"safe-fetch-required": safeFetchRequired,
"api-fetch-required": apiFetchRequired,
},
};
+8 -5
View File
@@ -22,7 +22,7 @@
* test-guard substitute holds the "guarded surface" line until a
* future wave extends both the guard and this whitelist together.
*
* @see src/lib/query-keys.ts — factory home (do not lint as offender)
* @see src/lib/query-keys/ — factory home (do not lint as offender)
* @see src/lib/__tests__/query-keys.test.ts — test-guard substitute
* @see .planning/phase-W-PROCESS-DOCS-v1441-report.md
*/
@@ -35,14 +35,16 @@
// exempt for now. Path matches use `String#includes` semantics against
// the absolute filename — keep entries as posix-style suffix paths.
const FACTORY_HOME_FILES = [
// The factory definition file is allowed to declare literal keys —
// that's the whole point of the factory.
"src/lib/query-keys.ts",
// The factory's own unit-test file constructs literal keys to assert
// the factory output shape.
"src/lib/__tests__/query-keys.test.ts",
];
// The factory definition files are allowed to declare literal keys —
// that's the whole point of the factory. The factory is split into
// per-feature files under this directory (barrel at index.ts).
const FACTORY_HOME_DIRECTORY = "src/lib/query-keys";
// Directories under which any `queryKey: [ … ]` literal is an error.
// Extend in lockstep with the test-guard substitute's `guardedRoots`.
const GUARDED_DIRECTORIES = [
@@ -81,6 +83,7 @@ function isGuarded(filename) {
for (const home of FACTORY_HOME_FILES) {
if (filename.endsWith(home)) return false;
}
if (filename.includes(`/${FACTORY_HOME_DIRECTORY}/`)) return false;
for (const dir of GUARDED_DIRECTORIES) {
if (filename.includes(`/${dir}/`)) return true;
}
@@ -96,7 +99,7 @@ const queryKeyFactoryRule = {
type: "problem",
docs: {
description:
"Disallow literal-array `queryKey` / `mutationKey` declarations in guarded files. Use `queryKeys.<entry>()` from `src/lib/query-keys.ts` instead.",
"Disallow literal-array `queryKey` / `mutationKey` declarations in guarded files. Use `queryKeys.<entry>()` from `src/lib/query-keys/` instead.",
recommended: false,
},
messages: {
@@ -39,6 +39,10 @@
const EXEMPT_FILES = [
"src/lib/safe-fetch.ts",
"src/lib/safe-fetch-dispatcher.ts",
// The same-origin `/api/...` envelope wrapper: its `fetch` calls are
// relative-path by contract (the api-fetch-required rule pins every
// client call site onto it), so the egress defences don't apply.
"src/lib/api/api-fetch.ts",
];
// Only enforce inside the application source roots. Scripts, config, and
+6
View File
@@ -38,6 +38,12 @@ const eslintConfig = defineConfig([
// requirePublicHost connect-time pin). The wrapper internals and
// test files are exempt; see the rule file for the allowlist.
"healthlog/safe-fetch-required": "error",
// v1.16.4 — same-origin `/api/...` calls in the client surface
// (src/components + src/app) must route through the typed apiFetch
// wrapper (`src/lib/api/api-fetch.ts`): one `.ok` check, one
// envelope unwrap, one ApiError shape. Wrapper internals and test
// files are exempt; see the rule file for the allowlist.
"healthlog/api-fetch-required": "error",
},
},
]);
+32
View File
@@ -0,0 +1,32 @@
# Git-hook manager (v1.16.4). `pnpm install` wires the hooks via the
# `prepare` script in package.json; no manual step needed.
#
# Pre-commit secret scan: `gitleaks protect --staged` scans exactly the
# staged hunks against the repo's `.gitleaks.toml` before every commit,
# so a secret-shaped string never reaches the object store (where only
# a history rewrite can remove it again).
#
# Requirements: the `gitleaks` binary must be on PATH — the command
# below fails with install pointers when it is missing rather than
# silently skipping the scan.
#
# Opt-out (one commit): LEFTHOOK=0 git commit ...
# Opt-out (skip one job): LEFTHOOK_EXCLUDE=gitleaks git commit ...
# Opt-out (permanent): pnpm lefthook uninstall
# Per project policy (`--no-verify` is banned), prefer fixing the
# finding over bypassing the hook.
pre-commit:
commands:
gitleaks:
run: |
if ! command -v gitleaks >/dev/null 2>&1; then
echo "ERROR: gitleaks binary not found on PATH — the staged-secret scan cannot run." >&2
echo "Install it first, e.g.:" >&2
echo " brew install gitleaks # macOS" >&2
echo " apt/apk/pacman install gitleaks # Linux distros" >&2
echo " https://github.com/gitleaks/gitleaks#installing" >&2
echo "One-off bypass (use sparingly): LEFTHOOK=0 git commit ..." >&2
exit 1
fi
gitleaks protect --staged --redact --no-banner
+30 -3
View File
@@ -3,6 +3,7 @@
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"undo": "Rückgängig",
"edit": "Bearbeiten",
"moreOptions": "Weitere Optionen",
"create": "Erstellen",
@@ -11,6 +12,7 @@
"loadFailed": "Konnte nicht geladen werden",
"error": "Fehler",
"saved": "Gespeichert",
"pullToRefresh": "Wird aktualisiert …",
"no": "Nein",
"noData": "Keine Daten vorhanden",
"optional": "Optional",
@@ -305,7 +307,7 @@
"medicationPlaceholder": "Medikament auswählen",
"doseLabel": "Dosis",
"dosePlaceholder": "z. B. 5 mg",
"doseHint": "Aktuell wird die Standarddosis erfasst. Abweichende Dosen kommen in einer kommenden Version.",
"doseHint": "Vorbelegt mit der Standarddosis. Passe sie an, um eine abweichende Dosis nur für diese Einnahme zu erfassen.",
"timeLabel": "Zeitpunkt",
"saveError": "Einnahme konnte nicht gespeichert werden.",
"emptyTitle": "Noch keine Medikamente angelegt",
@@ -448,6 +450,9 @@
"saveError": "Fehler beim Speichern",
"duplicateTimestamp": "Zum gewählten Zeitpunkt existiert bereits ein Messwert dieses Typs.",
"deleteError": "Fehler beim Löschen",
"deletedToast": "Messwert gelöscht",
"restoredToast": "Messwert wiederhergestellt",
"restoreError": "Wiederherstellen fehlgeschlagen",
"invalidValue": "Bitte einen gültigen Wert eingeben.",
"invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.",
"loginRequired": "Bitte anmelden, um Messwerte zu sehen.",
@@ -463,7 +468,7 @@
"comment": "Kommentar",
"actions": "Aktionen",
"deleteConfirmTitle": "Messwert löschen?",
"deleteConfirmDescription": "Dieser Messwert wird unwiderruflich gelöscht.",
"deleteConfirmDescription": "Dieser Messwert wird gelöscht. Direkt danach kannst du das kurz rückgängig machen.",
"pageInfo": "Seite {page} von {total}",
"previousPage": "Vorherige Seite",
"nextPage": "Nächste Seite",
@@ -546,9 +551,12 @@
"formResetConfirmAction": "Zurücksetzen",
"saveError": "Fehler beim Speichern",
"deleteError": "Fehler beim Löschen",
"deletedToast": "Eintrag gelöscht",
"restoredToast": "Eintrag wiederhergestellt",
"restoreError": "Wiederherstellen fehlgeschlagen",
"invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.",
"deleteConfirmTitle": "Eintrag löschen?",
"deleteConfirmDescription": "Dieser Stimmungseintrag wird unwiderruflich gelöscht.",
"deleteConfirmDescription": "Dieser Stimmungseintrag wird gelöscht. Direkt danach kannst du das kurz rückgängig machen.",
"pageInfo": "Seite {page} von {total}",
"previousPage": "Vorherige Seite",
"nextPage": "Nächste Seite",
@@ -641,6 +649,8 @@
"medicationLabel": "Medikament",
"slotLabel": "Einnahmeslot",
"slotNone": "Kein bestimmter Slot",
"doseLabel": "Dosis",
"doseHint": "Vorbelegt mit der Dosis des Medikaments bzw. Slots. Passe sie an, um eine abweichende Dosis nur für diese Einnahme zu erfassen.",
"takenAtLabel": "Datum und Uhrzeit",
"takenAtHint": "Standard ist jetzt; wähle ein früheres Datum und eine frühere Uhrzeit, um eine vergangene Dosis zu erfassen.",
"skippedLabel": "Als übersprungen erfassen",
@@ -730,6 +740,7 @@
"nextIntake": "Nächste Einnahme:",
"nextIntakeOverdue": "Überfällig · {time} — noch nachholbar",
"dayStreak": "Tage Serie",
"dayStreakOne": "Tag Serie",
"cycleNextDoseToday": "Nächste Dosis heute",
"cycleNextDoseTomorrow": "Nächste Dosis morgen",
"cycleNextDoseInDays": "Nächste Dosis in {days} Tagen",
@@ -803,6 +814,7 @@
"loading": "Wird geladen…",
"deleteCta": "Löschen",
"deleteConfirm": "Eintrag löschen?",
"deleteError": "Eintrag konnte nicht gelöscht werden",
"errorSeverity": "Schweregrad muss zwischen 1 und 5 liegen",
"errorEntry": "Bitte ein Symptom auswählen",
"categories": {
@@ -1350,6 +1362,7 @@
"markAsSkipped": "Als übersprungen markieren",
"adHocTag": "außerplanmäßig",
"takenDetail": "geplant {planned} · genommen {taken}",
"doseTaken": "Dosis: {dose}",
"status": {
"taken_on_time": "pünktlich",
"taken_late": "verspätet",
@@ -1971,6 +1984,7 @@
"errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.",
"errorProvider": "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen. Bitte versuche es in einem Moment erneut.",
"errorNetwork": "Keine Internetverbindung — versuche es erneut, sobald du online bist.",
"prefsSaveError": "Coach-Einstellungen konnten nicht gespeichert werden. Bitte versuche es erneut.",
"errorCredentialExpired": "Deine Verbindung zum KI-Anbieter ist abgelaufen. Stelle sie in den Einstellungen wieder her, damit der Coach weiterläuft.",
"tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.",
"newChat": "Neuer Chat",
@@ -1982,7 +1996,16 @@
"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",
"dismiss": "Verwerfen",
"done": "In die Selbstauskunft übernommen",
"duplicate": "Bereits in der Selbstauskunft enthalten",
"failed": "Übernahme fehlgeschlagen. Bitte erneut versuchen."
},
"selfContextChipDismiss": "Frage verwerfen",
"selfContextChipDismissError": "Frage konnte nicht ausgeblendet werden",
"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",
@@ -2073,6 +2096,7 @@
"feedbackHelpful": "Hilfreich",
"feedbackUnhelpful": "Nicht ganz",
"feedbackThanks": "Danke für das Signal.",
"feedbackError": "Feedback konnte nicht gespeichert werden",
"dailyLimitTitle": "Heute-Limit erreicht",
"dailyLimitBody": "Heute-Limit erreicht; Reset um 00:00 UTC.",
"providerRateLimitTitle": "Provider rate-limited",
@@ -2410,6 +2434,7 @@
"correlations": "Korrelationen",
"medicationCompliance": "Therapietreue",
"dayStreak": "{count} Tage Serie",
"dayStreakOne": "{count} Tag Serie",
"compliance7d": "Therapietreue (7 Tage)",
"compliance30d": "Therapietreue (30 Tage)",
"taken": "genommen",
@@ -5629,6 +5654,8 @@
"EMERGENCY": "Notfall"
},
"loadError": "Zyklusdaten konnten nicht geladen werden.",
"saveError": "Zykluseintrag konnte nicht gespeichert werden.",
"deleteError": "Eintrag konnte nicht gelöscht werden.",
"bbt": {
"title": "Basaltemperatur",
"caption": "Deine Morgentemperatur über diesen Zyklus. Ein anhaltender Anstieg um etwa 0,2 °C folgt auf den Eisprung.",
+30 -3
View File
@@ -3,6 +3,7 @@
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"undo": "Undo",
"edit": "Edit",
"moreOptions": "More options",
"create": "Create",
@@ -11,6 +12,7 @@
"loadFailed": "Could not load",
"error": "Error",
"saved": "Saved",
"pullToRefresh": "Refreshing…",
"no": "No",
"noData": "No data available",
"optional": "Optional",
@@ -305,7 +307,7 @@
"medicationPlaceholder": "Select a medication",
"doseLabel": "Dose",
"dosePlaceholder": "e.g. 5 mg",
"doseHint": "Currently the standard dose is recorded. Variable doses are coming in a later version.",
"doseHint": "Pre-filled with the standard dose. Edit it to record a deviating dose for this intake only.",
"timeLabel": "Time taken",
"saveError": "Could not save medication intake.",
"emptyTitle": "No medications yet",
@@ -448,6 +450,9 @@
"saveError": "Error saving",
"duplicateTimestamp": "A measurement of this type already exists at the selected timestamp.",
"deleteError": "Error deleting",
"deletedToast": "Measurement deleted",
"restoredToast": "Measurement restored",
"restoreError": "Restore failed",
"invalidValue": "Please enter a valid value.",
"invalidTimestamp": "Please select a valid time.",
"loginRequired": "Please sign in to view measurements.",
@@ -463,7 +468,7 @@
"comment": "Comment",
"actions": "Actions",
"deleteConfirmTitle": "Delete measurement?",
"deleteConfirmDescription": "This measurement will be permanently deleted.",
"deleteConfirmDescription": "This measurement will be deleted. You can undo this for a short moment afterwards.",
"pageInfo": "Page {page} of {total}",
"previousPage": "Previous page",
"nextPage": "Next page",
@@ -546,9 +551,12 @@
"formResetConfirmAction": "Reset",
"saveError": "Error saving",
"deleteError": "Error deleting",
"deletedToast": "Entry deleted",
"restoredToast": "Entry restored",
"restoreError": "Restore failed",
"invalidTimestamp": "Please select a valid time.",
"deleteConfirmTitle": "Delete entry?",
"deleteConfirmDescription": "This mood entry will be permanently deleted.",
"deleteConfirmDescription": "This mood entry will be deleted. You can undo this for a short moment afterwards.",
"pageInfo": "Page {page} of {total}",
"previousPage": "Previous page",
"nextPage": "Next page",
@@ -641,6 +649,8 @@
"medicationLabel": "Medication",
"slotLabel": "Schedule slot",
"slotNone": "No specific slot",
"doseLabel": "Dose",
"doseHint": "Pre-filled with the medication or slot dose. Edit it to record a deviating dose for this intake only.",
"takenAtLabel": "Date and time",
"takenAtHint": "Defaults to now; pick an earlier date and time to log a past dose.",
"skippedLabel": "Logged as skipped",
@@ -730,6 +740,7 @@
"nextIntake": "Next intake:",
"nextIntakeOverdue": "Overdue · {time} — can still be taken",
"dayStreak": "day streak",
"dayStreakOne": "day streak",
"cycleNextDoseToday": "Next dose today",
"cycleNextDoseTomorrow": "Next dose tomorrow",
"cycleNextDoseInDays": "Next dose in {days} days",
@@ -803,6 +814,7 @@
"loading": "Loading…",
"deleteCta": "Delete",
"deleteConfirm": "Delete this entry?",
"deleteError": "Could not delete the entry",
"errorSeverity": "Severity must be between 1 and 5",
"errorEntry": "Pick a symptom",
"categories": {
@@ -1350,6 +1362,7 @@
"markAsSkipped": "Mark as skipped",
"adHocTag": "off-schedule",
"takenDetail": "planned {planned} · taken {taken}",
"doseTaken": "Dose: {dose}",
"status": {
"taken_on_time": "on time",
"taken_late": "late",
@@ -1971,6 +1984,7 @@
"errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight UTC.",
"errorProvider": "The Coach could not reach an Insights provider just now. Please try again in a moment.",
"errorNetwork": "No internet connection — try again once you're back online.",
"prefsSaveError": "Couldn't save your Coach preferences. Please try again.",
"errorCredentialExpired": "Your AI provider connection has expired. Reconnect it in Settings to keep the Coach running.",
"tagline": "Personal health coach, grounded in your data.",
"newChat": "New chat",
@@ -1982,7 +1996,16 @@
"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",
"dismiss": "Dismiss",
"done": "Added to your self-context",
"duplicate": "Already part of your self-context",
"failed": "Could not add. Please try again."
},
"selfContextChipDismiss": "Dismiss question",
"selfContextChipDismissError": "Could not dismiss the question",
"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",
@@ -2073,6 +2096,7 @@
"feedbackHelpful": "Helpful",
"feedbackUnhelpful": "Not quite",
"feedbackThanks": "Thanks for the signal.",
"feedbackError": "Could not save feedback",
"dailyLimitTitle": "Daily limit reached",
"dailyLimitBody": "Daily limit reached; resets at 00:00 UTC.",
"providerRateLimitTitle": "Provider rate-limited",
@@ -2410,6 +2434,7 @@
"correlations": "Correlations",
"medicationCompliance": "Medication adherence",
"dayStreak": "{count} day streak",
"dayStreakOne": "{count} day streak",
"compliance7d": "Adherence (7 days)",
"compliance30d": "Adherence (30 days)",
"taken": "taken",
@@ -5629,6 +5654,8 @@
"EMERGENCY": "Emergency"
},
"loadError": "Couldn't load your cycle data.",
"saveError": "Couldn't save your cycle entry.",
"deleteError": "Couldn't delete the entry.",
"bbt": {
"title": "Basal body temperature",
"caption": "Your morning temperature across this cycle. A sustained rise of about 0.2 °C follows ovulation.",
+30 -3
View File
@@ -3,6 +3,7 @@
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"undo": "Deshacer",
"edit": "Editar",
"moreOptions": "Más opciones",
"create": "Crear",
@@ -11,6 +12,7 @@
"loadFailed": "No se pudo cargar",
"error": "Error",
"saved": "Guardado",
"pullToRefresh": "Actualizando…",
"no": "No",
"noData": "Sin datos disponibles",
"optional": "Opcional",
@@ -305,7 +307,7 @@
"medicationPlaceholder": "Selecciona un medicamento",
"doseLabel": "Dosis",
"dosePlaceholder": "p. ej. 5 mg",
"doseHint": "Por ahora se registra la dosis estándar. Las dosis variables llegarán en una próxima versión.",
"doseHint": "Precargado con la dosis estándar. Edítala para registrar una dosis diferente solo para esta toma.",
"timeLabel": "Hora de la toma",
"saveError": "No se ha podido guardar la toma.",
"emptyTitle": "Aún no hay medicamentos",
@@ -448,6 +450,9 @@
"saveError": "Error al guardar",
"duplicateTimestamp": "Ya existe una medición de este tipo en la marca de tiempo seleccionada.",
"deleteError": "Error al eliminar",
"deletedToast": "Medición eliminada",
"restoredToast": "Medición restaurada",
"restoreError": "Error al restaurar",
"invalidValue": "Introduce un valor válido.",
"invalidTimestamp": "Selecciona una fecha y hora válidas.",
"loginRequired": "Inicia sesión para ver las mediciones.",
@@ -463,7 +468,7 @@
"comment": "Comentario",
"actions": "Acciones",
"deleteConfirmTitle": "¿Eliminar medición?",
"deleteConfirmDescription": "Esta medición se eliminará de forma permanente.",
"deleteConfirmDescription": "Esta medición se eliminará. Justo después podrás deshacerlo durante un breve momento.",
"pageInfo": "Página {page} de {total}",
"previousPage": "Página anterior",
"nextPage": "Página siguiente",
@@ -546,9 +551,12 @@
"formResetConfirmAction": "Restablecer",
"saveError": "Error al guardar",
"deleteError": "Error al eliminar",
"deletedToast": "Entrada eliminada",
"restoredToast": "Entrada restaurada",
"restoreError": "Error al restaurar",
"invalidTimestamp": "Selecciona una fecha y hora válidas.",
"deleteConfirmTitle": "¿Eliminar entrada?",
"deleteConfirmDescription": "Esta entrada de estado de ánimo se eliminará de forma permanente.",
"deleteConfirmDescription": "Esta entrada de estado de ánimo se eliminará. Justo después podrás deshacerlo durante un breve momento.",
"pageInfo": "Página {page} de {total}",
"previousPage": "Página anterior",
"nextPage": "Página siguiente",
@@ -641,6 +649,8 @@
"medicationLabel": "Medicamento",
"slotLabel": "Franja del horario",
"slotNone": "Sin franja concreta",
"doseLabel": "Dosis",
"doseHint": "Precargado con la dosis del medicamento o de la franja. Edítala para registrar una dosis diferente solo para esta toma.",
"takenAtLabel": "Fecha y hora",
"takenAtHint": "Por defecto es ahora; elige una fecha y hora anteriores para registrar una dosis pasada.",
"skippedLabel": "Registrar como omitida",
@@ -730,6 +740,7 @@
"nextIntake": "Próxima toma:",
"nextIntakeOverdue": "Atrasada · {time} — aún se puede tomar",
"dayStreak": "días de racha",
"dayStreakOne": "día de racha",
"cycleNextDoseToday": "Próxima dosis hoy",
"cycleNextDoseTomorrow": "Próxima dosis mañana",
"cycleNextDoseInDays": "Próxima dosis en {days} días",
@@ -803,6 +814,7 @@
"loading": "Cargando…",
"deleteCta": "Eliminar",
"deleteConfirm": "¿Eliminar esta entrada?",
"deleteError": "No se pudo eliminar la entrada",
"errorSeverity": "La gravedad debe estar entre 1 y 5",
"errorEntry": "Elige un síntoma",
"categories": {
@@ -1350,6 +1362,7 @@
"markAsSkipped": "Marcar como omitida",
"adHocTag": "fuera de horario",
"takenDetail": "prevista {planned} · tomada {taken}",
"doseTaken": "Dosis: {dose}",
"status": {
"taken_on_time": "a tiempo",
"taken_late": "con retraso",
@@ -1971,6 +1984,7 @@
"errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche (UTC).",
"errorProvider": "El coach no ha podido contactar con un proveedor de análisis ahora mismo. Inténtalo en un momento.",
"errorNetwork": "Sin conexión a Internet — inténtalo de nuevo cuando vuelvas a estar en línea.",
"prefsSaveError": "No se pudieron guardar tus preferencias del Coach. Inténtalo de nuevo.",
"errorCredentialExpired": "Tu conexión con el proveedor de IA ha caducado. Vuelve a conectarla en Ajustes para que el Coach siga funcionando.",
"tagline": "Coach personal de salud, apoyado en tus datos.",
"newChat": "Nueva conversación",
@@ -1982,7 +1996,16 @@
"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",
"dismiss": "Descartar",
"done": "Añadido a tu información personal",
"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",
"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",
@@ -2071,6 +2094,7 @@
"feedbackHelpful": "Útil",
"feedbackUnhelpful": "Poco útil",
"feedbackThanks": "Gracias por la indicación.",
"feedbackError": "No se pudo guardar el comentario",
"dailyLimitTitle": "Límite diario alcanzado",
"dailyLimitBody": "Límite diario alcanzado; reinicio a las 00:00 UTC.",
"providerRateLimitTitle": "Proveedor con límite de tasa",
@@ -2410,6 +2434,7 @@
"correlations": "Correlaciones",
"medicationCompliance": "Cumplimiento de medicación",
"dayStreak": "{count} días de racha",
"dayStreakOne": "{count} día de racha",
"compliance7d": "Cumplimiento 7 días",
"compliance30d": "Cumplimiento 30 días",
"taken": "Tomada",
@@ -5629,6 +5654,8 @@
"EMERGENCY": "Emergencia"
},
"loadError": "No se pudieron cargar tus datos del ciclo.",
"saveError": "No se pudo guardar tu registro del ciclo.",
"deleteError": "No se pudo eliminar la entrada.",
"bbt": {
"title": "Temperatura basal",
"caption": "Tu temperatura matutina a lo largo de este ciclo. Una subida sostenida de unos 0,2 °C sigue a la ovulación.",
+30 -3
View File
@@ -3,6 +3,7 @@
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"undo": "Annuler",
"edit": "Modifier",
"moreOptions": "Plus d'options",
"create": "Créer",
@@ -11,6 +12,7 @@
"loadFailed": "Impossible de charger",
"error": "Erreur",
"saved": "Enregistré",
"pullToRefresh": "Actualisation…",
"no": "Non",
"noData": "Aucune donnée disponible",
"optional": "Facultatif",
@@ -305,7 +307,7 @@
"medicationPlaceholder": "Sélectionner un médicament",
"doseLabel": "Dose",
"dosePlaceholder": "p. ex. 5 mg",
"doseHint": "Pour l’instant, la dose standard est enregistrée. Les doses variables arriveront dans une prochaine version.",
"doseHint": "Prérempli avec la dose standard. Modifiez-la pour enregistrer une dose différente pour cette prise uniquement.",
"timeLabel": "Heure de la prise",
"saveError": "Impossible d’enregistrer la prise.",
"emptyTitle": "Aucun médicament pour l’instant",
@@ -448,6 +450,9 @@
"saveError": "Erreur lors de l’enregistrement",
"duplicateTimestamp": "Une mesure de ce type existe déjà à cet horodatage.",
"deleteError": "Erreur lors de la suppression",
"deletedToast": "Mesure supprimée",
"restoredToast": "Mesure restaurée",
"restoreError": "Échec de la restauration",
"invalidValue": "Saisis une valeur valide.",
"invalidTimestamp": "Choisis une date et une heure valides.",
"loginRequired": "Connecte-toi pour voir tes mesures.",
@@ -463,7 +468,7 @@
"comment": "Commentaire",
"actions": "Actions",
"deleteConfirmTitle": "Supprimer la mesure ?",
"deleteConfirmDescription": "Cette mesure sera supprimée définitivement.",
"deleteConfirmDescription": "Cette mesure sera supprimée. Vous pourrez annuler cette action pendant un court instant juste après.",
"pageInfo": "Page {page} sur {total}",
"previousPage": "Page précédente",
"nextPage": "Page suivante",
@@ -546,9 +551,12 @@
"formResetConfirmAction": "Réinitialiser",
"saveError": "Erreur lors de l’enregistrement",
"deleteError": "Erreur lors de la suppression",
"deletedToast": "Entrée supprimée",
"restoredToast": "Entrée restaurée",
"restoreError": "Échec de la restauration",
"invalidTimestamp": "Choisis une date et une heure valides.",
"deleteConfirmTitle": "Supprimer l’entrée ?",
"deleteConfirmDescription": "Cette entrée d’humeur sera supprimée définitivement.",
"deleteConfirmDescription": "Cette entrée d’humeur sera supprimée. Vous pourrez annuler cette action pendant un court instant juste après.",
"pageInfo": "Page {page} sur {total}",
"previousPage": "Page précédente",
"nextPage": "Page suivante",
@@ -641,6 +649,8 @@
"medicationLabel": "Médicament",
"slotLabel": "Créneau du planning",
"slotNone": "Aucun créneau précis",
"doseLabel": "Dose",
"doseHint": "Prérempli avec la dose du médicament ou du créneau. Modifiez-la pour enregistrer une dose différente pour cette prise uniquement.",
"takenAtLabel": "Date et heure",
"takenAtHint": "Par défaut maintenant ; choisissez une date et une heure antérieures pour enregistrer une dose passée.",
"skippedLabel": "Enregistrer comme ignorée",
@@ -730,6 +740,7 @@
"nextIntake": "Prochaine prise :",
"nextIntakeOverdue": "En retard · {time} — encore rattrapable",
"dayStreak": "jours de série",
"dayStreakOne": "jour de série",
"cycleNextDoseToday": "Prochaine dose aujourd'hui",
"cycleNextDoseTomorrow": "Prochaine dose demain",
"cycleNextDoseInDays": "Prochaine dose dans {days} jours",
@@ -803,6 +814,7 @@
"loading": "Chargement…",
"deleteCta": "Supprimer",
"deleteConfirm": "Supprimer cette entrée ?",
"deleteError": "Impossible de supprimer l'entrée",
"errorSeverity": "La sévérité doit être entre 1 et 5",
"errorEntry": "Choisissez un symptôme",
"categories": {
@@ -1350,6 +1362,7 @@
"markAsSkipped": "Marquer comme sautée",
"adHocTag": "hors planning",
"takenDetail": "prévue {planned} · prise {taken}",
"doseTaken": "Dose : {dose}",
"status": {
"taken_on_time": "à l'heure",
"taken_late": "en retard",
@@ -1971,6 +1984,7 @@
"errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit (UTC).",
"errorProvider": "Le coach n’a pas pu joindre un fournisseur d’analyse maintenant. Réessaie dans un instant.",
"errorNetwork": "Pas de connexion Internet — réessaie une fois reconnecté.",
"prefsSaveError": "Impossible d’enregistrer vos préférences du Coach. Réessayez.",
"errorCredentialExpired": "Ta connexion au fournisseur d'IA a expiré. Reconnecte-la dans les Réglages pour que le Coach continue de fonctionner.",
"tagline": "Coach personnel de santé, ancré sur tes données.",
"newChat": "Nouvelle conversation",
@@ -1982,7 +1996,16 @@
"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",
"dismiss": "Ignorer",
"done": "Ajouté à vos informations personnelles",
"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",
"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",
@@ -2071,6 +2094,7 @@
"feedbackHelpful": "Utile",
"feedbackUnhelpful": "Peu utile",
"feedbackThanks": "Merci pour l’indication.",
"feedbackError": "Impossible d'enregistrer le retour",
"dailyLimitTitle": "Limite quotidienne atteinte",
"dailyLimitBody": "Limite quotidienne atteinte ; réinitialisation à 00:00 UTC.",
"providerRateLimitTitle": "Fournisseur limité en débit",
@@ -2410,6 +2434,7 @@
"correlations": "Corrélations",
"medicationCompliance": "Observance des médicaments",
"dayStreak": "{count} jours de série",
"dayStreakOne": "{count} jour de série",
"compliance7d": "Observance 7 jours",
"compliance30d": "Observance 30 jours",
"taken": "Pris",
@@ -5629,6 +5654,8 @@
"EMERGENCY": "Urgence"
},
"loadError": "Impossible de charger vos données de cycle.",
"saveError": "Impossible d’enregistrer votre saisie de cycle.",
"deleteError": "Impossible de supprimer l’entrée.",
"bbt": {
"title": "Température basale",
"caption": "Votre température matinale sur ce cycle. Une hausse durable d'environ 0,2 °C suit l'ovulation.",
+30 -3
View File
@@ -3,6 +3,7 @@
"save": "Salva",
"cancel": "Annulla",
"delete": "Elimina",
"undo": "Annulla",
"edit": "Modifica",
"moreOptions": "Altre opzioni",
"create": "Crea",
@@ -11,6 +12,7 @@
"loadFailed": "Impossibile caricare",
"error": "Errore",
"saved": "Salvato",
"pullToRefresh": "Aggiornamento…",
"no": "No",
"noData": "Nessun dato disponibile",
"optional": "Opzionale",
@@ -305,7 +307,7 @@
"medicationPlaceholder": "Seleziona un farmaco",
"doseLabel": "Dose",
"dosePlaceholder": "es. 5 mg",
"doseHint": "Al momento viene registrata la dose standard. Le dosi variabili arriveranno in una prossima versione.",
"doseHint": "Precompilato con la dose standard. Modificala per registrare una dose diversa solo per questa assunzione.",
"timeLabel": "Orario di assunzione",
"saveError": "Impossibile salvare l’assunzione.",
"emptyTitle": "Ancora nessun farmaco",
@@ -448,6 +450,9 @@
"saveError": "Errore durante il salvataggio",
"duplicateTimestamp": "Esiste già una misurazione di questo tipo a questa marca temporale.",
"deleteError": "Errore durante l’eliminazione",
"deletedToast": "Misurazione eliminata",
"restoredToast": "Misurazione ripristinata",
"restoreError": "Ripristino non riuscito",
"invalidValue": "Inserisci un valore valido.",
"invalidTimestamp": "Scegli una data e un’ora valide.",
"loginRequired": "Accedi per vedere le misurazioni.",
@@ -463,7 +468,7 @@
"comment": "Commento",
"actions": "Azioni",
"deleteConfirmTitle": "Eliminare la misurazione?",
"deleteConfirmDescription": "Questa misurazione sarà eliminata in modo definitivo.",
"deleteConfirmDescription": "Questa misurazione sarà eliminata. Subito dopo potrai annullare l’operazione per un breve momento.",
"pageInfo": "Pagina {page} di {total}",
"previousPage": "Pagina precedente",
"nextPage": "Pagina successiva",
@@ -546,9 +551,12 @@
"formResetConfirmAction": "Reimposta",
"saveError": "Errore durante il salvataggio",
"deleteError": "Errore durante l’eliminazione",
"deletedToast": "Voce eliminata",
"restoredToast": "Voce ripristinata",
"restoreError": "Ripristino non riuscito",
"invalidTimestamp": "Scegli una data e un’ora valide.",
"deleteConfirmTitle": "Eliminare la voce?",
"deleteConfirmDescription": "Questa voce dell’umore sarà eliminata in modo definitivo.",
"deleteConfirmDescription": "Questa voce dell’umore sarà eliminata. Subito dopo potrai annullare l’operazione per un breve momento.",
"pageInfo": "Pagina {page} di {total}",
"previousPage": "Pagina precedente",
"nextPage": "Pagina successiva",
@@ -641,6 +649,8 @@
"medicationLabel": "Farmaco",
"slotLabel": "Fascia della pianificazione",
"slotNone": "Nessuna fascia specifica",
"doseLabel": "Dose",
"doseHint": "Precompilato con la dose del farmaco o della fascia. Modificala per registrare una dose diversa solo per questa assunzione.",
"takenAtLabel": "Data e ora",
"takenAtHint": "Per impostazione predefinita è adesso; scegli una data e un'ora precedenti per registrare una dose passata.",
"skippedLabel": "Registra come saltata",
@@ -730,6 +740,7 @@
"nextIntake": "Prossima assunzione:",
"nextIntakeOverdue": "In ritardo · {time} — ancora recuperabile",
"dayStreak": "giorni di serie",
"dayStreakOne": "giorno di serie",
"cycleNextDoseToday": "Prossima dose oggi",
"cycleNextDoseTomorrow": "Prossima dose domani",
"cycleNextDoseInDays": "Prossima dose tra {days} giorni",
@@ -803,6 +814,7 @@
"loading": "Caricamento…",
"deleteCta": "Elimina",
"deleteConfirm": "Eliminare questa voce?",
"deleteError": "Impossibile eliminare la voce",
"errorSeverity": "La gravità deve essere tra 1 e 5",
"errorEntry": "Scegli un sintomo",
"categories": {
@@ -1350,6 +1362,7 @@
"markAsSkipped": "Segna come saltata",
"adHocTag": "fuori programma",
"takenDetail": "prevista {planned} · assunta {taken}",
"doseTaken": "Dose: {dose}",
"status": {
"taken_on_time": "puntuale",
"taken_late": "in ritardo",
@@ -1971,6 +1984,7 @@
"errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte (UTC).",
"errorProvider": "Il coach non è riuscito a contattare un provider di analisi ora. Riprova fra poco.",
"errorNetwork": "Nessuna connessione Internet — riprova quando sei di nuovo online.",
"prefsSaveError": "Impossibile salvare le preferenze del Coach. Riprova.",
"errorCredentialExpired": "La connessione al provider di IA è scaduta. Riconnettila nelle Impostazioni per mantenere attivo il Coach.",
"tagline": "Coach personale di salute, basato sui tuoi dati.",
"newChat": "Nuova chat",
@@ -1982,7 +1996,16 @@
"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",
"dismiss": "Ignora",
"done": "Aggiunto alle tue informazioni personali",
"duplicate": "Già presente nelle tue informazioni personali",
"failed": "Aggiunta non riuscita. Riprova."
},
"selfContextChipDismiss": "Ignora la domanda",
"selfContextChipDismissError": "Impossibile ignorare la domanda",
"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",
@@ -2071,6 +2094,7 @@
"feedbackHelpful": "Utile",
"feedbackUnhelpful": "Poco utile",
"feedbackThanks": "Grazie per il segnale.",
"feedbackError": "Impossibile salvare il feedback",
"dailyLimitTitle": "Limite giornaliero raggiunto",
"dailyLimitBody": "Limite giornaliero raggiunto; reset alle 00:00 UTC.",
"providerRateLimitTitle": "Provider con rate-limit",
@@ -2410,6 +2434,7 @@
"correlations": "Correlazioni",
"medicationCompliance": "Aderenza ai farmaci",
"dayStreak": "{count} giorni di serie",
"dayStreakOne": "{count} giorno di serie",
"compliance7d": "Aderenza 7 giorni",
"compliance30d": "Aderenza 30 giorni",
"taken": "Assunta",
@@ -5629,6 +5654,8 @@
"EMERGENCY": "Emergenza"
},
"loadError": "Impossibile caricare i dati del ciclo.",
"saveError": "Impossibile salvare la voce del ciclo.",
"deleteError": "Impossibile eliminare la voce.",
"bbt": {
"title": "Temperatura basale",
"caption": "La tua temperatura mattutina in questo ciclo. Un rialzo prolungato di circa 0,2 °C segue l'ovulazione.",
+30 -3
View File
@@ -3,6 +3,7 @@
"save": "Zapisz",
"cancel": "Anuluj",
"delete": "Usuń",
"undo": "Cofnij",
"edit": "Edytuj",
"moreOptions": "Więcej opcji",
"create": "Utwórz",
@@ -11,6 +12,7 @@
"loadFailed": "Nie udało się załadować",
"error": "Błąd",
"saved": "Zapisano",
"pullToRefresh": "Odświeżanie…",
"no": "Nie",
"noData": "Brak danych",
"optional": "Opcjonalne",
@@ -305,7 +307,7 @@
"medicationPlaceholder": "Wybierz lek",
"doseLabel": "Dawka",
"dosePlaceholder": "np. 5 mg",
"doseHint": "Obecnie zapisywana jest dawka standardowa. Dawki zmienne pojawią się w jednej z kolejnych wersji.",
"doseHint": "Wstępnie wypełnione standardową dawką. Zmień ją, aby zapisać inną dawkę tylko dla tego przyjęcia.",
"timeLabel": "Godzina przyjęcia",
"saveError": "Nie udało się zapisać przyjęcia.",
"emptyTitle": "Brak leków",
@@ -448,6 +450,9 @@
"saveError": "Błąd zapisu",
"duplicateTimestamp": "Pomiar tego typu już istnieje dla wybranej daty.",
"deleteError": "Błąd usuwania",
"deletedToast": "Pomiar usunięty",
"restoredToast": "Pomiar przywrócony",
"restoreError": "Przywracanie nie powiodło się",
"invalidValue": "Wprowadź poprawną wartość.",
"invalidTimestamp": "Wybierz poprawną datę i godzinę.",
"loginRequired": "Zaloguj się, aby zobaczyć pomiary.",
@@ -463,7 +468,7 @@
"comment": "Komentarz",
"actions": "Akcje",
"deleteConfirmTitle": "Usunąć pomiar?",
"deleteConfirmDescription": "Ten pomiar zostanie nieodwracalnie usunięty.",
"deleteConfirmDescription": "Ten pomiar zostanie usunięty. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
"pageInfo": "Strona {page} z {total}",
"previousPage": "Poprzednia strona",
"nextPage": "Następna strona",
@@ -546,9 +551,12 @@
"formResetConfirmAction": "Resetuj",
"saveError": "Błąd zapisu",
"deleteError": "Błąd usuwania",
"deletedToast": "Wpis usunięty",
"restoredToast": "Wpis przywrócony",
"restoreError": "Przywracanie nie powiodło się",
"invalidTimestamp": "Wybierz poprawną datę i godzinę.",
"deleteConfirmTitle": "Usunąć wpis?",
"deleteConfirmDescription": "Ten wpis nastroju zostanie nieodwracalnie usunięty.",
"deleteConfirmDescription": "Ten wpis nastroju zostanie usunięty. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
"pageInfo": "Strona {page} z {total}",
"previousPage": "Poprzednia strona",
"nextPage": "Następna strona",
@@ -641,6 +649,8 @@
"medicationLabel": "Lek",
"slotLabel": "Przedział harmonogramu",
"slotNone": "Bez konkretnego przedziału",
"doseLabel": "Dawka",
"doseHint": "Wstępnie wypełnione dawką leku lub przedziału. Zmień ją, aby zapisać inną dawkę tylko dla tego przyjęcia.",
"takenAtLabel": "Data i godzina",
"takenAtHint": "Domyślnie teraz; wybierz wcześniejszą datę i godzinę, aby zarejestrować przeszłą dawkę.",
"skippedLabel": "Zarejestruj jako pominięte",
@@ -730,6 +740,7 @@
"nextIntake": "Następne przyjęcie:",
"nextIntakeOverdue": "Zaległa · {time} — wciąż można przyjąć",
"dayStreak": "dni serii",
"dayStreakOne": "dzień serii",
"cycleNextDoseToday": "Następna dawka dzisiaj",
"cycleNextDoseTomorrow": "Następna dawka jutro",
"cycleNextDoseInDays": "Następna dawka za {days} dni",
@@ -803,6 +814,7 @@
"loading": "Ładowanie…",
"deleteCta": "Usuń",
"deleteConfirm": "Usunąć ten wpis?",
"deleteError": "Nie udało się usunąć wpisu",
"errorSeverity": "Nasilenie musi mieścić się w zakresie 1–5",
"errorEntry": "Wybierz objaw",
"categories": {
@@ -1350,6 +1362,7 @@
"markAsSkipped": "Oznacz jako pominiętą",
"adHocTag": "poza planem",
"takenDetail": "planowo {planned} · przyjęto {taken}",
"doseTaken": "Dawka: {dose}",
"status": {
"taken_on_time": "na czas",
"taken_late": "z opóźnieniem",
@@ -1971,6 +1984,7 @@
"errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy (UTC).",
"errorProvider": "Coach nie mógł teraz skontaktować się z dostawcą analizy. Spróbuj za chwilę.",
"errorNetwork": "Brak połączenia z Internetem — spróbuj ponownie, gdy wrócisz online.",
"prefsSaveError": "Nie udało się zapisać ustawień Coacha. Spróbuj ponownie.",
"errorCredentialExpired": "Połączenie z dostawcą AI wygasło. Połącz je ponownie w Ustawieniach, aby Coach działał dalej.",
"tagline": "Osobisty coach zdrowia, oparty na twoich danych.",
"newChat": "Nowa rozmowa",
@@ -1982,7 +1996,16 @@
"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",
"dismiss": "Odrzuć",
"done": "Dodano do informacji o sobie",
"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",
"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",
@@ -2071,6 +2094,7 @@
"feedbackHelpful": "Przydatne",
"feedbackUnhelpful": "Mało przydatne",
"feedbackThanks": "Dzięki za sygnał.",
"feedbackError": "Nie udało się zapisać opinii",
"dailyLimitTitle": "Osiągnięto dzienny limit",
"dailyLimitBody": "Osiągnięto dzienny limit; reset o 00:00 UTC.",
"providerRateLimitTitle": "Dostawca z ograniczeniem",
@@ -2410,6 +2434,7 @@
"correlations": "Korelacje",
"medicationCompliance": "Przestrzeganie leków",
"dayStreak": "{count} dni serii",
"dayStreakOne": "{count} dzień serii",
"compliance7d": "Przestrzeganie 7 dni",
"compliance30d": "Przestrzeganie 30 dni",
"taken": "Przyjęte",
@@ -5629,6 +5654,8 @@
"EMERGENCY": "Awaryjna"
},
"loadError": "Nie udało się wczytać danych cyklu.",
"saveError": "Nie udało się zapisać wpisu cyklu.",
"deleteError": "Nie udało się usunąć wpisu.",
"bbt": {
"title": "Podstawowa temperatura ciała",
"caption": "Twoja poranna temperatura w tym cyklu. Trwały wzrost o około 0,2 °C następuje po owulacji.",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.16.3",
"version": "1.16.4",
"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",
@@ -24,6 +24,7 @@
"private": true,
"packageManager": "pnpm@10.31.0",
"scripts": {
"prepare": "lefthook install || echo 'lefthook install skipped (no git repo or binary unavailable)'",
"dev": "next dev",
"prebuild": "node scripts/generate-sw-version.mjs",
"build": "next build",
@@ -109,6 +110,7 @@
"eslint": "^9",
"eslint-config-next": "16.2.6",
"knip": "^6.16.1",
"lefthook": "^2.1.9",
"pdf-parse": "^2.4.5",
"pg": "^8.21.0",
"prettier": "^3.8.3",
+100
View File
@@ -183,6 +183,9 @@ importers:
knip:
specifier: ^6.16.1
version: 6.16.1
lefthook:
specifier: ^2.1.9
version: 2.1.9
pdf-parse:
specifier: ^2.4.5
version: 2.4.5
@@ -4792,6 +4795,60 @@ packages:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
lefthook-darwin-arm64@2.1.9:
resolution: {integrity: sha512-119HryNcvr4nqn0wUIrNPgpMEPn9yMQzEcW/lezRsnb56PCJriJB92+MCySPVcWDxJnZef7o0T3jdnPNiSH7Qg==}
cpu: [arm64]
os: [darwin]
lefthook-darwin-x64@2.1.9:
resolution: {integrity: sha512-dwo5Tke2XcQCM56DGHgFKBfRbJIL6xs2wZ0zG1TUVZgl4t4mQUt6LiZ4V/ZQfYHTZF9qywvXoIlR5N35qOaiVQ==}
cpu: [x64]
os: [darwin]
lefthook-freebsd-arm64@2.1.9:
resolution: {integrity: sha512-+09PVap6nl6xsaHch5JLtq7WvIR++U1Q2MzA2ai0M4uB/VP3AqrvKqHw6+9hjyKnIH+HHL83uqi77EAY+LaxLA==}
cpu: [arm64]
os: [freebsd]
lefthook-freebsd-x64@2.1.9:
resolution: {integrity: sha512-8XresjKIYpkE9ARgCtBEZgJZxAU3T4MIqzj4zNy15XRT59I1Us+QdqXTNm+pkZ41Yd2X/nxs2Pkvbq3NWWlIGw==}
cpu: [x64]
os: [freebsd]
lefthook-linux-arm64@2.1.9:
resolution: {integrity: sha512-1oNIQfwrPe6rgU2KcDM3aF6+hpZDCKx1TmawQKpXUY5gVsbZ7MqX0Sk/1lnnWxqPm+kQQ5f6J2dpFWd+4xH8jg==}
cpu: [arm64]
os: [linux]
lefthook-linux-x64@2.1.9:
resolution: {integrity: sha512-fT+7Q+BJyGp+CslFQkNXmdFRgyVXsPHPi9NAsDX0a6QOyNnoORByAsvx6zeAKuF5rL3BBgNfho1/v2RuGxGy9w==}
cpu: [x64]
os: [linux]
lefthook-openbsd-arm64@2.1.9:
resolution: {integrity: sha512-4bVuafBk3dddVNo0+3hMbjcJs4mqYAstxpPMmX2ufkudSTYFNIhWoqwuGVQV/SS/xdcOKJAldW4qayAzed2ysw==}
cpu: [arm64]
os: [openbsd]
lefthook-openbsd-x64@2.1.9:
resolution: {integrity: sha512-PmPoMmLP/wQQWcQ9u2YH86bTZ3UCfBsxuEmVTEyPU2U8R1qSTp5r/Gs3G8cN5Mxo91XB9oBERtF1n+xD3W6aVA==}
cpu: [x64]
os: [openbsd]
lefthook-windows-arm64@2.1.9:
resolution: {integrity: sha512-KphfkBKmwBnmolyrdhIl3lrBaOyTcCgXBT2AB/9OHnEXhOLvv5uTCUkrD4YRAxXPtFKq6UvnapIeoL3GZq0bdA==}
cpu: [arm64]
os: [win32]
lefthook-windows-x64@2.1.9:
resolution: {integrity: sha512-2qlUtkJHZ3MyUxgV5XTEmcrIoNZA07iwaquoswAcqv/1MeBFXlD+O+koFRfrzWng2O5WYEbpJnd8tvaYnV8fTA==}
cpu: [x64]
os: [win32]
lefthook@2.1.9:
resolution: {integrity: sha512-bwDaIOViTktE8kJLf9jP0p+H2/RDTlFFlc43Am2YgUsX22hI6Sq4RbzsrecwzY5y+MHTipOH7WsmWSEniePHWQ==}
hasBin: true
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -11428,6 +11485,49 @@ snapshots:
dependencies:
readable-stream: 2.3.8
lefthook-darwin-arm64@2.1.9:
optional: true
lefthook-darwin-x64@2.1.9:
optional: true
lefthook-freebsd-arm64@2.1.9:
optional: true
lefthook-freebsd-x64@2.1.9:
optional: true
lefthook-linux-arm64@2.1.9:
optional: true
lefthook-linux-x64@2.1.9:
optional: true
lefthook-openbsd-arm64@2.1.9:
optional: true
lefthook-openbsd-x64@2.1.9:
optional: true
lefthook-windows-arm64@2.1.9:
optional: true
lefthook-windows-x64@2.1.9:
optional: true
lefthook@2.1.9:
optionalDependencies:
lefthook-darwin-arm64: 2.1.9
lefthook-darwin-x64: 2.1.9
lefthook-freebsd-arm64: 2.1.9
lefthook-freebsd-x64: 2.1.9
lefthook-linux-arm64: 2.1.9
lefthook-linux-x64: 2.1.9
lefthook-openbsd-arm64: 2.1.9
lefthook-openbsd-x64: 2.1.9
lefthook-windows-arm64: 2.1.9
lefthook-windows-x64: 2.1.9
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -0,0 +1,10 @@
-- v1.16.4 — per-intake dose override.
--
-- A user who takes half a tablet (or a doubled dose on a titration day)
-- could only record the take as-is; the ledger then implied the scheduled
-- dose was consumed. `dose_taken` records the free-text dose actually
-- consumed for THIS event when it deviates from (or simply documents) the
-- medication / schedule dose. NULL = no override recorded — every legacy
-- row and every take logged without touching the dose field stays NULL,
-- and read paths fall back to the medication's configured dose.
ALTER TABLE "medication_intake_events" ADD COLUMN "dose_taken" TEXT;
+5
View File
@@ -1570,6 +1570,11 @@ model MedicationIntakeEvent {
/// is the only surface that writes this column). Existing rows stay
/// NULL forever and the read paths treat NULL as "no site recorded".
injectionSite InjectionSite? @map("injection_site")
/// v1.16.4 — per-intake dose override. Free text (mirrors
/// `Medication.dose`), populated only when the user edits the dose at
/// log time (half tablet, titration day). NULL = the configured
/// medication / schedule dose applies; read paths fall back to it.
doseTaken String? @map("dose_taken")
// v1.7.0 sync — server-set mutation timestamp. The model had no
// `updatedAt`; the offline-sync keyset feed orders on it, so it is
// added here. `@updatedAt` makes Prisma maintain it on every write.
+3 -3
View File
@@ -4,15 +4,15 @@
* table in `src/lib/openapi/registry.ts`.
*
* v1.4.23 baseline: only the iOS-touched routes are registered (see
* `src/lib/openapi/routes.ts`). The CI step that wraps this script runs
* `src/lib/openapi/routes/`). The CI step that wraps this script runs
* with `continue-on-error: true` until the registry catches up with the
* rest of the hand-maintained `docs/api/openapi.yaml` — once coverage is
* complete, the gate flips to hard-fail in v1.4.24+.
*
* The output is stable across re-runs because the upstream order is
* itself deterministic: Zod preserves schema property declaration
* order and the path table is a single object literal in
* `src/lib/openapi/routes.ts`. `yaml@2`'s `sortMapEntries` option is
* order and the path table is assembled with a fixed spread order in
* `src/lib/openapi/routes/index.ts`. `yaml@2`'s `sortMapEntries` option is
* intentionally NOT set — when enabled, the emitter reorders keys
* alphabetically and can place an `*alias` reference before its
* `&anchor` definition, which triggers `verifyAliasOrder` and throws
+2 -2
View File
@@ -18,7 +18,7 @@ import { PrismaPg } from "@prisma/adapter-pg";
import { decrypt } from "@/lib/crypto";
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/timezone";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
const DATABASE_URL = process.env.DATABASE_URL!;
@@ -33,7 +33,7 @@ function parseTimeToMinutes(value: string): number {
return h * 60 + m;
}
// getUserTodayBounds and getDayOfWeekInTz imported from @/lib/timezone
// getUserTodayBounds and getDayOfWeekInTz imported from @/lib/tz/local-day
const dayLabels = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
+1 -1
View File
@@ -23,4 +23,4 @@ client → src/proxy.ts → src/app/<route>/route.ts → src/lib/api-handler.ts
## Conventions
See [`../CLAUDE.md`](../CLAUDE.md) for the full set. The load-bearing ones: RSC by default (`"use client"` only when a hook/state/browser API needs it); every API route wraps `apiHandler` and parses its body with Zod `safeParse`; `userId` is always narrowed from session or Bearer, never accepted as a body field; TanStack Query keys come only from `src/lib/query-keys.ts`; outbound HTTP goes through `src/lib/safe-fetch.ts`.
See [`../CLAUDE.md`](../CLAUDE.md) for the full set. The load-bearing ones: RSC by default (`"use client"` only when a hook/state/browser API needs it); every API route wraps `apiHandler` and parses its body with Zod `safeParse`; `userId` is always narrowed from session or Bearer, never accepted as a body field; TanStack Query keys come only from `src/lib/query-keys/`; outbound HTTP goes through `src/lib/safe-fetch.ts`.
@@ -4,7 +4,7 @@
*
* The v1.4.38.7 perf audit (`.planning/round-v1438-perf-analysis.md`
* §1 A2) flagged the per-type `fetchMeasurementSeriesChunked` loop
* inside `/api/analytics` as the dominant cost of Marc's 74.6 s cold
* inside `/api/analytics` as the dominant cost of the maintainer's 74.6 s cold
* full-slice mount. v1.4.38.8 (commit 8a8150d2) relaxed the
* `isFullyCovered` gate inside the three downstream fast-paths so the
* walk became unreachable in the common case — but the A2 loop itself
@@ -129,7 +129,7 @@ const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-since-1",
username: "marc",
username: "testuser",
role: "USER" as const,
locale: "en",
timezone: "Europe/Berlin",
@@ -119,7 +119,7 @@ describe("POST /api/auth/check-user", () => {
passwordHash: null,
_count: { passkeys: 2 },
} as never);
const res = await POST(makeRequest({ identifier: "marc" }));
const res = await POST(makeRequest({ identifier: "testuser" }));
const body = (await res.json()) as CheckUserBody;
expect(body.data.branch).toBe("passkey_only");
expect(body.data.hasPasskey).toBe(true);
@@ -132,7 +132,7 @@ describe("POST /api/auth/check-user", () => {
passwordHash: "argon2id$…",
_count: { passkeys: 1 },
} as never);
const res = await POST(makeRequest({ identifier: "marc" }));
const res = await POST(makeRequest({ identifier: "testuser" }));
const body = (await res.json()) as CheckUserBody;
expect(body.data.branch).toBe("email_fallback");
expect(body.data.hasPasskey).toBe(true);
@@ -145,7 +145,7 @@ describe("POST /api/auth/check-user", () => {
passwordHash: null,
_count: { passkeys: 0 },
} as never);
const res = await POST(makeRequest({ identifier: "marc" }));
const res = await POST(makeRequest({ identifier: "testuser" }));
const body = (await res.json()) as CheckUserBody;
expect(body.data.branch).toBe("exists");
expect(body.data.hasPasskey).toBe(false);
@@ -1,7 +1,7 @@
/**
* v1.4.49.3 — call-site i18n coverage guard.
*
* Marc surfaced raw `insights.relativeHoursAgo`, `notifications.event
* The maintainer surfaced raw `insights.relativeHoursAgo`, `notifications.event
* MoodReminder`, and `notifications.eventMoodReminderDesc` strings
* leaking into the UI in v1.4.49. Audit found 28 keys called from real
* code that never existed in any locale bundle — silent regressions
@@ -9,7 +9,7 @@ import { join } from "node:path";
* thick `/api/analytics` envelope; every per-type tile waited on the
* heavy fan-out before paint. Mood and medication tiles arrived first
* (separate routes) and then every other tile arrived as one burst,
* which Marc reported as "etwas nervig" in the v1.4.39.1 post-deploy
* which the maintainer reported as "etwas nervig" in the v1.4.39.1 post-deploy
* trace.
*
* Post-fix the dashboard mounts TWO `useAnalyticsQuery` calls in
@@ -104,8 +104,11 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
it("derives `primaryLoading` from the snapshot vs slim query", () => {
const src = readFileSync(PAGE_PATH, "utf8");
// v1.16.4 — `!mounted` pins SSR + the hydration render to the
// skeleton branch so a late-hydrating boundary can't disagree with
// the server HTML (React #418); see `useMounted`.
expect(src).toMatch(
/const\s+primaryLoading\s*=\s*snapshotEnabled\s*\?\s*snapshotQuery\.isLoading\s*:\s*analyticsSlimQuery\.isLoading;/,
/const\s+primaryLoading\s*=\s*!mounted\s*\|\|\s*\(snapshotEnabled\s*\?\s*snapshotQuery\.isLoading\s*:\s*analyticsSlimQuery\.isLoading\);/,
);
});
+1 -1
View File
@@ -17,7 +17,7 @@
* convention (cf. the SSR-string tests elsewhere).
*/
import { afterEach, describe, expect, it } from "vitest";
import { sheetBodyHasUnsavedInput } from "../page";
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
const BODY_SELECTOR = '[data-slot="responsive-sheet-body"]';
const MOOD_RADIO_SELECTOR = '[role="radio"][aria-checked="true"]';
@@ -70,19 +70,19 @@ describe("GET /api/admin/audit-log — extended filters", () => {
});
it("filters by `actor` against userId OR user.username", async () => {
await GET(req("actor=marc"));
await GET(req("actor=testuser"));
const call = vi.mocked(prisma.auditLog.findMany).mock.calls[0][0]!;
const where = call.where as Record<string, unknown>;
// Expect an OR clause covering userId + user.username (contains)
expect(where.OR).toBeDefined();
const or = where.OR as Array<Record<string, unknown>>;
const matchesUserId = or.some((c) => c.userId === "marc");
const matchesUserId = or.some((c) => c.userId === "testuser");
const matchesUsername = or.some(
(c) =>
typeof c.user === "object" &&
c.user !== null &&
// user: { username: { contains: "marc", mode: "insensitive" } }
JSON.stringify(c).toLowerCase().includes("marc"),
// user: { username: { contains: "testuser", mode: "insensitive" } }
JSON.stringify(c).toLowerCase().includes("testuser"),
);
expect(matchesUserId).toBe(true);
expect(matchesUsername).toBe(true);
@@ -60,7 +60,7 @@ import { getSession } from "@/lib/auth/session";
const ADMIN_SESSION = {
session: { id: "sess-admin", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "marc", role: "ADMIN" as const },
user: { id: "admin-1", username: "testuser", role: "ADMIN" as const },
};
const USER_SESSION = {
session: { id: "sess-user", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -4,7 +4,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/timezone";
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
export const dynamic = "force-dynamic";
@@ -33,7 +33,7 @@ import { prisma } from "@/lib/db";
const ADMIN_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "marc", role: "ADMIN" as const },
user: { id: "admin-1", username: "testuser", role: "ADMIN" as const },
};
const USER_OK = {
session: { id: "sess-2", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -71,7 +71,7 @@ describe("PUT /api/admin/users/[id]", () => {
new HttpError(401, "Not authenticated"),
);
await expect(
PUT(jsonReq({ username: "marc" }), params("u1")),
PUT(jsonReq({ username: "testuser" }), params("u1")),
).rejects.toThrow("Not authenticated");
});
@@ -80,7 +80,7 @@ describe("PUT /api/admin/users/[id]", () => {
new HttpError(403, "Admin access required"),
);
await expect(
PUT(jsonReq({ username: "marc" }), params("u1")),
PUT(jsonReq({ username: "testuser" }), params("u1")),
).rejects.toThrow("Admin access required");
});
@@ -97,7 +97,7 @@ describe("PUT /api/admin/users/[id]", () => {
it("returns 404 when target user does not exist", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
const res = await PUT(jsonReq({ username: "marc" }), params("u-missing"));
const res = await PUT(jsonReq({ username: "testuser" }), params("u-missing"));
expect(res.status).toBe(404);
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
});
@@ -102,7 +102,7 @@ describe("POST /api/auth/check-user — audit-log row (M-1)", () => {
_count: { passkeys: 2 },
} as never);
const res = await POST(makeRequest({ identifier: "marc" }));
const res = await POST(makeRequest({ identifier: "testuser" }));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledTimes(1);
@@ -111,7 +111,7 @@ describe("POST /api/auth/check-user — audit-log row (M-1)", () => {
ipAddress: "203.0.113.1",
details: {
branch: "passkey_only",
identifier_hash: expectedHash("marc"),
identifier_hash: expectedHash("testuser"),
},
});
});
@@ -123,13 +123,13 @@ describe("POST /api/auth/check-user — audit-log row (M-1)", () => {
_count: { passkeys: 0 },
} as never);
const res = await POST(makeRequest({ identifier: "marc@example.com" }));
const res = await POST(makeRequest({ identifier: "user@example.com" }));
expect(res.status).toBe(200);
const args = vi.mocked(auditLog).mock.calls[0];
expect(args[1]?.details).toEqual({
branch: "email_fallback",
identifier_hash: expectedHash("marc@example.com"),
identifier_hash: expectedHash("user@example.com"),
});
expect(args[1]?.userId).toBe("user-2");
});
@@ -75,8 +75,8 @@ import { createSession } from "@/lib/auth/session";
const FAKE_USER = {
id: "user-1",
username: "marc",
email: "marc@example.com",
username: "testuser",
email: "user@example.com",
passwordHash: "$argon2id$...",
};
@@ -88,7 +88,7 @@ function makeRequest(headers: Record<string, string> = {}): NextRequest {
...headers,
},
body: JSON.stringify({
email: "marc@example.com",
email: "user@example.com",
password: "supersecret",
}),
});
@@ -48,7 +48,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(): NextRequest {
@@ -35,7 +35,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(deviceIdHeader?: string): NextRequest {
@@ -47,7 +47,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPatch(body: unknown): Request {
@@ -39,7 +39,7 @@ import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-pre
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPut(body: unknown): Request {
@@ -69,7 +69,7 @@ describe("GET /api/auth/me/doctor-report-prefs", () => {
data: typeof DEFAULT_DOCTOR_REPORT_PREFS;
};
expect(env.data).toEqual(DEFAULT_DOCTOR_REPORT_PREFS);
expect(env.data.mood).toBe(false); // privacy default per Marc
expect(env.data.mood).toBe(false); // privacy default per the maintainer
});
});
@@ -145,7 +145,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
});
// ── Case 4 — unauthenticated cross-user request ────────────────────
// Marc's spec mentions "cross-user 404" — the route layer returns 401
// the maintainer's spec mentions "cross-user 404" — the route layer returns 401
// when there is no session at all, which is the only "you are not the
// right user" surface available before requireAuth() resolves an
// identity. `prisma.user.update` must NOT fire in this case.
@@ -48,7 +48,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPatch(body: unknown): Request {
@@ -48,7 +48,7 @@ import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPost(body: unknown): Request {
@@ -37,7 +37,7 @@ import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPut(body: unknown): Request {
@@ -38,7 +38,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkReq(body: unknown): NextRequest {
@@ -46,7 +46,7 @@ import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPatch(body: unknown): Request {
@@ -60,8 +60,8 @@ import {
const FAKE_USER = {
id: "user-1",
username: "marc",
email: "marc@example.com",
username: "testuser",
email: "user@example.com",
};
function makeRequest(headers: Record<string, string> = {}): NextRequest {
+181
View File
@@ -0,0 +1,181 @@
/**
* POST /api/coach/about-me/adopt — fold a clarifying-question answer
* back into the stored self-context.
*
* v1.16.4 — closes the questions loop: the Coach composer's pending
* chips (`/api/coach/about-me/questions`) insert a question the user
* answers in their own words; this endpoint lets that answer flow back
* into the matching structured self-context field instead of living
* only in the chat transcript. Additive — the existing GET/PUT
* `/api/coach/about-me` contract is untouched.
*
* Semantics:
* - The target field is picked from the QUESTION wording (allergy /
* condition keywords across the six UI locales; everything else
* lands on `coachFocus`, the "what the Coach should know" slot).
* - Dedupe: an answer already contained in the target field (or in
* the free-text `aboutMe`) is a no-op (`adopted: false`) — tapping
* the offer twice or re-answering the same chip cannot stack
* duplicate prose.
* - Append, never replace: existing text stays; the answer joins on
* its own line, encrypted at rest like every self-context write.
* A structured field at its cap overflows into `aboutMe`.
* - Audit rows carry lengths only — the text is free-form health
* prose and must not land in plaintext audit details.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { getSelfContextForUser } from "@/lib/ai/coach/about-me";
import {
ABOUT_ME_FIELD_MAX_CHARS,
ABOUT_ME_MAX_CHARS,
aboutMeAdoptSchema,
} from "@/lib/validations/about-me";
const POST_RATE_LIMIT = 30;
const POST_WINDOW_MS = 60_000;
type StructuredField = "conditions" | "allergies" | "coachFocus";
/**
* Keyword match against the question wording, covering the six UI
* locales the question generator writes in (de / en / es / fr / it /
* pl). Stems, not words, so inflections match. Anything that is not
* clearly an allergy or condition question lands on `coachFocus` — the
* generic "worth knowing for the Coach" slot.
*/
function matchSelfContextField(question: string): StructuredField {
const q = question.toLowerCase();
const allergyStems = [
"allerg", // de/en/fr/it
"alergi", // es/pl
"uczulen", // pl
"intoleran", // de/en/es/it
"unverträglich", // de
];
if (allergyStems.some((s) => q.includes(s))) return "allergies";
const conditionStems = [
"erkrank", // de
"krankheit", // de
"diagnos", // de/en/es/fr/it/pl
"condition", // en
"chronic", // en
"chronisch", // de
"enfermedad", // es
"maladie", // fr
"malatt", // it
"chorob", // pl
];
if (conditionStems.some((s) => q.includes(s))) return "conditions";
return "coachFocus";
}
/** Whitespace-collapsed lowercase form for containment dedupe. */
function normalise(s: string): string {
return s.toLowerCase().replace(/\s+/g, " ").trim();
}
export const POST = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`coach-about-me:adopt:${user.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = aboutMeAdoptSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
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))
) {
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);
}
}
// 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),
details: { field, answerLength: answer.length },
});
annotate({
action: { name: "coach.about_me.adopted" },
meta: { field, answer_length: answer.length },
});
return apiSuccess({ adopted: true, field });
});
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@@ -41,7 +41,7 @@ import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(body: unknown): NextRequest {
@@ -41,7 +41,7 @@ import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
beforeEach(() => {
@@ -65,7 +65,7 @@ const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "marc",
username: "testuser",
role: "USER" as const,
displayName: null,
},
@@ -134,7 +134,7 @@ describe("GET /api/dashboard/summary", () => {
metrics: Array<{ id: string; kind: string; sparkline: number[] }>;
};
};
expect(body.data.greeting.salutation).toBe("Hi, marc");
expect(body.data.greeting.salutation).toBe("Hi, testuser");
expect(body.data.streak.currentDays).toBe(0);
expect(body.data.compliance.scheduledToday).toBe(0);
// REG-11 (v1.4.44): weight is the only always-emitted base metric.
@@ -48,7 +48,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(): NextRequest {
+1 -1
View File
@@ -59,7 +59,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(body: unknown): NextRequest {
@@ -72,7 +72,7 @@ function setEmptyDataMocks() {
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "marc",
username: "testuser",
dateOfBirth: null,
gender: null,
heightCm: null,
@@ -3,7 +3,7 @@
*
* Returns one boolean per toggleable section so the dialog can hide
* checkboxes for data types that have zero rows in the user-selected
* date range (Marc's "hide-when-empty" rule). Pure presence check —
* date range (the maintainer's "hide-when-empty" rule). Pure presence check —
* `count` queries on small indexed tables — so this is cheap enough to
* run on every date-range change in the dialog without driving cost.
*
@@ -36,7 +36,7 @@ import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(): NextRequest {
@@ -43,7 +43,7 @@ const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "marc",
username: "testuser",
role: "USER" as const,
createdAt: new Date("2026-03-01T00:00:00.000Z"),
heightCm: null,
@@ -43,7 +43,7 @@ const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "marc",
username: "testuser",
role: "USER" as const,
createdAt: new Date("2026-03-01T00:00:00.000Z"),
heightCm: null,
@@ -37,7 +37,7 @@ import { getSession } from "@/lib/auth/session";
const OWNER_SESSION = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const OTHER_SESSION = {
session: { id: "sess-2", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -45,7 +45,7 @@ const OTHER_SESSION = {
};
const ADMIN_SESSION = {
session: { id: "sess-a", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "marc-admin", role: "ADMIN" as const },
user: { id: "admin-1", username: "test-admin", role: "ADMIN" as const },
};
const NOW = new Date("2026-05-15T10:00:00.000Z");
@@ -58,7 +58,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
beforeEach(() => {
@@ -45,7 +45,7 @@ import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -41,7 +41,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
beforeEach(() => {
@@ -97,7 +97,7 @@ const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-comp-1",
username: "marc",
username: "testuser",
role: "USER" as const,
locale: "en",
},
@@ -45,7 +45,7 @@ import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
beforeEach(() => {
+2 -2
View File
@@ -21,7 +21,7 @@ import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { prisma } from "@/lib/db";
import { getLocalDateParts } from "@/lib/timezone";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import type { MeasurementType } from "@/generated/prisma/client";
import {
discoverCorrelations,
@@ -39,7 +39,7 @@ const WINDOW_DAYS = 180;
/** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */
function tzDayKey(at: Date, tz: string): string {
const { year, month, day } = getLocalDateParts(at, tz);
const { year, month, day } = wallClockInTz(at, tz);
const mm = String(month).padStart(2, "0");
const dd = String(day).padStart(2, "0");
return `${year}-${mm}-${dd}`;
@@ -64,7 +64,7 @@ import { computeDerivedMetric } from "@/lib/insights/derived";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -54,7 +54,7 @@ import { computeDerivedMetric } from "@/lib/insights/derived";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -268,7 +268,7 @@ describe("POST /api/insights/generate — provider error mapping", () => {
});
// v1.4.16 A7.1: rate limit raised from 2 → 10/h, env-configurable.
// the maintainer reported the previous 2/h was too aggressive when iterating on
// The maintainer reported the previous 2/h was too aggressive when iterating on
// settings. The 10/h ceiling is the new default; the env override lets
// operators on a tight LLM budget dial it back without a rebuild.
describe("POST /api/insights/generate — rate limit (v1.4.16 A7.1)", () => {
@@ -47,7 +47,7 @@ import { generateMetricStatus } from "@/lib/insights/metric-status";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -46,7 +46,7 @@ import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
};
const callPost = POST as unknown as (req: NextRequest) => Promise<Response>;
@@ -41,7 +41,7 @@ import { prisma } from "@/lib/db";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" },
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -85,7 +85,7 @@ import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_USER = {
id: "user-targets-1",
username: "marc",
username: "testuser",
role: "USER" as const,
timezone: "Europe/Berlin",
heightCm: 180,
@@ -37,7 +37,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
beforeEach(() => {
@@ -330,3 +330,84 @@ describe("GET /api/internal/deploy-webhook (reachability check)", () => {
expect(res.status).toBe(401);
});
});
describe("POST /api/internal/deploy-webhook (timestamp replay protection)", () => {
const ORIGINAL_REQUIRE = process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP;
afterEach(() => {
if (ORIGINAL_REQUIRE === undefined) {
delete process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP;
} else {
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = ORIGINAL_REQUIRE;
}
});
function timestamped(ts: string): NextRequest {
return jsonRequest(
{ status: "success" },
{
"x-deploy-webhook-secret": "test-secret",
"x-deploy-webhook-timestamp": ts,
},
);
}
it("accepts a fresh unix-seconds timestamp", async () => {
const res = await POST(timestamped(String(Math.floor(Date.now() / 1000))));
expect(res.status).toBe(200);
});
it("accepts a fresh ISO-8601 timestamp", async () => {
const res = await POST(timestamped(new Date().toISOString()));
expect(res.status).toBe(200);
});
it("rejects a timestamp older than the 5-minute window", async () => {
const stale = Math.floor((Date.now() - 6 * 60 * 1000) / 1000);
const res = await POST(timestamped(String(stale)));
expect(res.status).toBe(401);
expect(auditLog).not.toHaveBeenCalled();
});
it("rejects a timestamp too far in the future (clock-skew bound)", async () => {
const future = Math.floor((Date.now() + 6 * 60 * 1000) / 1000);
const res = await POST(timestamped(String(future)));
expect(res.status).toBe(401);
});
it("rejects an unparseable timestamp value", async () => {
const res = await POST(timestamped("not-a-timestamp"));
expect(res.status).toBe(401);
});
it("accepts a request without the header in tolerant default mode", async () => {
delete process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP;
const res = await POST(
jsonRequest(
{ status: "success" },
{ "x-deploy-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(200);
});
it("rejects a request without the header when DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true", async () => {
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = "true";
const res = await POST(
jsonRequest(
{ status: "success" },
{ "x-deploy-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(401);
expect(auditLog).not.toHaveBeenCalled();
});
it("still rejects a stale timestamp before any audit-log write", async () => {
const stale = Math.floor((Date.now() - 10 * 60 * 1000) / 1000);
const res = await POST(timestamped(String(stale)));
expect(res.status).toBe(401);
expect(auditLog).not.toHaveBeenCalled();
expect(dispatchLocalisedNotification).not.toHaveBeenCalled();
});
});
@@ -85,6 +85,58 @@ function normalizePayload(payload: CoolifyDeployPayload): NormalizedEvent {
};
}
/**
* Replay protection (v1.16.4). A captured webhook request carries a
* static shared secret, so without a freshness check it can be replayed
* indefinitely — re-paging admins with stale failure alerts and
* polluting the audit log with forged deploy outcomes.
*
* The sender attaches `X-Deploy-Webhook-Timestamp` (unix seconds or
* ISO-8601). Requests whose timestamp falls outside a ±5-minute window
* are rejected. Two modes:
*
* - Default (tolerant): a request WITHOUT the header passes — Coolify's
* stock notification sender does not attach one, and breaking the
* live integration would cost more than the residual replay window.
* A header that IS present but stale/invalid is always rejected, so
* a replayed capture of a timestamped request never lands.
* - `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true`: the header becomes
* mandatory. For operators whose sender (or reverse proxy) can
* attach the timestamp; closes the replay window completely.
*
* See docs/ops/deploy.md ("Deploy-status webhook") for the operator
* guidance.
*/
const TIMESTAMP_WINDOW_MS = 5 * 60 * 1000;
function parseTimestamp(raw: string): number | null {
// Unix seconds (10 digits covers 2001-2286) or milliseconds.
if (/^\d{10}$/.test(raw)) return Number(raw) * 1000;
if (/^\d{13}$/.test(raw)) return Number(raw);
const iso = Date.parse(raw);
return Number.isFinite(iso) ? iso : null;
}
function checkTimestampFreshness(
request: NextRequest,
now: number = Date.now(),
): { ok: boolean; reason?: string } {
const raw = request.headers.get("x-deploy-webhook-timestamp");
const required =
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "true" ||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "1";
if (raw === null || raw === "") {
if (required) return { ok: false, reason: "timestamp_missing" };
return { ok: true };
}
const ts = parseTimestamp(raw);
if (ts === null) return { ok: false, reason: "timestamp_invalid" };
if (Math.abs(now - ts) > TIMESTAMP_WINDOW_MS) {
return { ok: false, reason: "timestamp_outside_window" };
}
return { ok: true };
}
function hasValidSecret(request: NextRequest): boolean {
const expected = process.env.DEPLOY_WEBHOOK_SECRET;
if (!expected) {
@@ -173,6 +225,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
// Freshness AFTER the secret check: an unauthenticated caller learns
// nothing about the timestamp policy, and a replayed-but-stale request
// from a captured legitimate call is rejected here.
const freshness = checkTimestampFreshness(request);
if (!freshness.ok) {
annotate({
meta: { deploy_webhook_replay_reason: freshness.reason ?? "unknown" },
});
return NextResponse.json({ status: "stale" }, { status: 401 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
const { data: payload, error: jsonError } =
@@ -28,7 +28,7 @@ import { MEASUREMENT_CATEGORIES } from "@/lib/measurements/categories";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(): NextRequest {
@@ -36,7 +36,7 @@ import { Prisma } from "@/generated/prisma/client";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function putRequest(id: string, body: Record<string, unknown>): NextRequest {
@@ -350,7 +350,7 @@ describe("GET /api/measurements — all-time semantics (SD-H1)", () => {
});
it("source=rollup folds the partition inline and re-reads when the rollup is under-converged (v1.4.39.2)", async () => {
// Marc's pre-fix production trace: 30-day BP_SYS window returned
// The maintainer's pre-fix production trace: 30-day BP_SYS window returned
// `total: 2` from the rollup branch even though `measurements`
// carried ~30 distinct days. The chart tripped its `< 3 daily
// points` empty-state because the route short-circuited on
@@ -60,7 +60,7 @@ import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function makeRequest(body: unknown): NextRequest {
@@ -0,0 +1,177 @@
/**
* v1.16.4 — POST /api/measurements/restore.
*
* Asserts the ownership-scoped un-tombstone contract that backs the
* delete-Undo toast: only owned, currently-tombstoned rows are touched;
* a forged / foreign / never-deleted id is a silent no-op (no 404
* existence leak); the rollup recompute collapses to the unique
* `(type, day)` set; the >200-id cap returns 422.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
return {
// keep the real collapse so the test exercises the actual fan-out logic
collapseToTypeDayKeys: actual.collapseToTypeDayKeys,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
};
});
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 { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-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 },
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/measurements/restore", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(recomputeBucketsForMeasurement).mockResolvedValue(undefined);
vi.mocked(invalidateStatusInsightsForTypes).mockResolvedValue(undefined);
});
describe("POST /api/measurements/restore", () => {
it("un-tombstones only owned, tombstoned rows and returns that count", async () => {
// Caller asked for 3 ids but only 2 are owned + tombstoned.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") },
] as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
count: 2,
} as never);
const res = await POST(postReq({ ids: ["m1", "m2", "foreign"] }));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { restored: number } };
expect(body.data.restored).toBe(2);
// updateMany is ownership-scoped + only touches tombstones — a
// forged / live id never 404s, it's just excluded from the count.
const call = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
expect(call.where).toMatchObject({
id: { in: ["m1", "m2", "foreign"] },
userId: "user-1",
deletedAt: { not: null },
});
expect(call.data).toMatchObject({
deletedAt: null,
syncVersion: { increment: 1 },
});
expect(invalidateUserMeasurements).toHaveBeenCalledWith("user-1");
});
it("collapses the rollup recompute to the unique (type, day) set, not per-row", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-01-01T20:00:00Z") },
{ type: "WEIGHT", measuredAt: new Date("2026-01-02T09:00:00Z") },
{ type: "PULSE", measuredAt: new Date("2026-01-01T10:00:00Z") },
] as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
count: 4,
} as never);
await POST(postReq({ ids: ["a", "b", "c", "d"] }));
// 4 rows in, 3 distinct (type, day) recomputes out.
expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3);
});
it("is a no-op (restored: 0) when no id is owned + tombstoned — no rollup, no invalidate", async () => {
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({
count: 0,
} as never);
const res = await POST(postReq({ ids: ["foreign-1", "live-1"] }));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { restored: number } };
expect(body.data.restored).toBe(0);
expect(invalidateUserMeasurements).not.toHaveBeenCalled();
expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled();
});
it("rejects a batch over the 200-id cap with 422", async () => {
const ids = Array.from({ length: 201 }, (_, i) => `m${i}`);
const res = await POST(postReq({ ids }));
expect(res.status).toBe(422);
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
});
it("rejects an empty id array with 422", async () => {
const res = await POST(postReq({ ids: [] }));
expect(res.status).toBe(422);
});
it("429s when the rate limit is exceeded", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
const res = await POST(postReq({ ids: ["m1"] }));
expect(res.status).toBe(429);
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
});
});
+144
View File
@@ -0,0 +1,144 @@
/**
* `POST /api/measurements/restore` — un-tombstone for the management
* list's delete-Undo affordance (v1.16.4).
*
* Body:
* { ids: string[] } — 1..200 measurement ids, scoped to the caller.
*
* Clears `deletedAt` (+ bumps `syncVersion`) on every owned,
* currently-tombstoned row in one `updateMany`, so the row re-surfaces
* in normal reads and the `/api/sync/changes` delta feed carries it as
* an upsert to paired clients. A forged / foreign / not-deleted id is a
* silent no-op (never a 404 existence leak): the mutation `where` pins
* `userId` so it only ever touches the caller's rows.
*
* Response (200):
* { restored: <number of rows un-tombstoned> }
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
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 { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
} from "@/lib/rollups/measurement-rollups";
import type { MeasurementType } from "@/generated/prisma/client";
const MAX_IDS_PER_BATCH = 200;
const RATE_LIMIT_MAX = 60;
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
const restoreSchema = z.object({
ids: z.array(z.string().min(1)).min(1).max(MAX_IDS_PER_BATCH),
});
export const POST = apiHandler(withIdempotency<[NextRequest]>(postRestore));
async function postRestore(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`measurements:restore:${user.id}`,
RATE_LIMIT_MAX,
RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many restore requests, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 256 * 1024,
});
if (jsonError) return jsonError;
const parsed = restoreSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "measurement.restore.invalid",
});
}
const { ids } = parsed.data;
// Read the soon-to-be-restored rows first so we know which
// `(type, day)` buckets need a rollup recompute and which types need
// a status-insight cache drop. Ownership-scoped + `deletedAt != null`
// so a forged / foreign / never-deleted id is excluded here exactly as
// it is from the mutation below — no existence leak.
const affected = await prisma.measurement.findMany({
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
select: { type: true, measuredAt: true },
});
// Un-tombstone in one statement. The `where` pins `userId` so a forged
// id for another user is silently a no-op; `deletedAt: { not: null }`
// keeps the count honest (a live row doesn't re-count).
const { count } = await prisma.measurement.updateMany({
where: { id: { in: ids }, userId: user.id, deletedAt: { not: null } },
data: { deletedAt: null, syncVersion: { increment: 1 } },
});
await auditLog("measurement.restore", {
userId: user.id,
ipAddress: getClientIp(request),
details: { count },
});
annotate({
action: { name: "measurement.restore" },
meta: { count },
});
if (count > 0) {
// Bust per-user analytics + achievements + workouts caches so
// subsequent reads reflect the restored rows.
invalidateUserMeasurements(user.id);
// Collapse the restored rows to the unique `(type, day)` set BEFORE
// recomputing — mirrors the bulk-delete path. Best-effort: a
// populator hiccup never fails the user's restore.
const keys = collapseToTypeDayKeys(
affected.map((row) => ({
type: row.type as MeasurementType,
measuredAt: row.measuredAt,
})),
);
try {
for (const k of keys) {
await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt);
}
} catch (err) {
console.warn("[measurements] restore rollup recompute failed", err);
}
// Drop the cached per-metric assessment rows the restore dirties so
// the next mount / nightly warm pass regenerates against the
// restored history. Fire-and-forget: never blocks the restore.
const affectedTypes = Array.from(new Set(keys.map((k) => k.type)));
if (affectedTypes.length > 0) {
invalidateStatusInsightsForTypes(user.id, affectedTypes).catch((err) => {
console.warn(
"[measurements] restore status-insight invalidate failed",
err,
);
});
}
}
return apiSuccess({ restored: count });
}
+2 -2
View File
@@ -319,7 +319,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// `GET /api/measurements?type=…&aggregate=daily&limit=5000` requests
// (BP_SYS / BP_DIA / WEIGHT). The legacy `date_trunc('day', …)`
// path scanned the measurements table each time — a full year window
// on Marc's account hit 3 × ~3 s on the v1.4.35 HAR. Reading from
// on the maintainer's account hit 3 × ~3 s on the v1.4.35 HAR. Reading from
// the persistent `measurement_rollups` DAY buckets drops each call
// to a small indexed read against the ~5 k-row rollup table.
//
@@ -398,7 +398,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// window) and finishes well inside the request budget.
//
// The probe runs only on suspicious rollups so the covered-tenant
// happy path stays a single indexed read. Marc's tenant on a 30-day
// happy path stays a single indexed read. The maintainer's tenant on a 30-day
// BP_SYS window pre-fix returned 2 rollup rows and hit `total: 2`
// in the live trace; post-fix the probe finds ~30 distinct days in
// `measurements`, folds them, and the chart paints all 30.
@@ -55,7 +55,7 @@ import { annotate } from "@/lib/logging/context";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function req(query: string): NextRequest {
@@ -39,7 +39,7 @@ import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
beforeEach(() => {
@@ -73,7 +73,7 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { cached } from "@/lib/cache/server-cache";
import { checkRateLimit } from "@/lib/rate-limit";
import { getUserTodayBounds } from "@/lib/timezone";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import { userDayKey } from "@/lib/tz/format";
// UTC keeps the half-open day slice (`[dayStart, dayEnd)`) and the
@@ -84,7 +84,7 @@ const TZ = "UTC";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const, timezone: TZ },
user: { id: "user-1", username: "testuser", role: "USER" as const, timezone: TZ },
};
const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) };
@@ -58,14 +58,14 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { checkRateLimit } from "@/lib/rate-limit";
import { localHmAsUtc } from "@/lib/timezone";
import { localHmAsUtc } from "@/lib/tz/local-day";
const TZ = "Europe/Berlin";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "marc",
username: "testuser",
role: "USER" as const,
timezone: TZ,
},
@@ -89,6 +89,8 @@ interface SerializedDoseHistoryRow {
takenAt: string | null;
skipped: boolean;
autoMissed: boolean;
/** v1.16.4 — per-intake dose override; null = configured dose. */
doseTaken: string | null;
} | null;
}
@@ -161,6 +163,8 @@ export const GET = apiHandler(
autoMissed: true,
// v1.15.20 — USER_PIN rows bind by anchor in the read ledger.
attributionSource: true,
// v1.16.4 — per-intake dose override for the ledger's deviation hint.
doseTaken: true,
},
});
@@ -171,6 +175,7 @@ export const GET = apiHandler(
skipped: e.skipped,
autoMissed: e.autoMissed,
pinned: e.attributionSource === "USER_PIN",
doseTaken: e.doseTaken,
}));
const lastIntakeAt = lastNonSkippedTakenAt(mapped);
@@ -242,6 +247,7 @@ export const GET = apiHandler(
skipped: e.skipped,
autoMissed: e.autoMissed,
pinned: e.pinned,
doseTaken: e.doseTaken,
}));
const rows = reconstructDoseHistory(bands, historyIntakes, now);
@@ -266,6 +272,7 @@ export const GET = apiHandler(
takenAt: row.intake.takenAt?.toISOString() ?? null,
skipped: row.intake.skipped,
autoMissed: row.intake.autoMissed ?? false,
doseTaken: row.intake.doseTaken ?? null,
}
: null,
}));
@@ -62,7 +62,7 @@ import { assertMedicationOwnership } from "@/lib/medications/route-guards";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const MED_OK = { id: "med-1", userId: "user-1" };
Loaded 100 of 494 files, more files were not shown because too many files have changed in this diff. Show more