mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
494 files changed
+18177
-14147
No files matched your search
@@ -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"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+100
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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\);/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user