Compare commits

..
35 Commits
Author SHA1 Message Date
Marc-André Bombeck 7bb5e1947c Merge pull request #596 from MBombeck/sec/next-16.2.11
release/v1.32.2 — Next.js 16.2.11 security patch (4 high-severity App Router advisories)
2026-07-23 12:09:15 +02:00
Marc-André Bombeck 938e84a43a chore(release): v1.32.2 — Next.js 16.2.11, closing four high-severity App Router advisories 2026-07-23 11:41:19 +02:00
Marc-André Bombeck a9bb2f42ba Merge pull request #594 from MBombeck/release/v1.32.1
release/v1.32.1 — honest health score, Coach score questions, import & pulse fidelity, three data-loss races
2026-07-23 11:07:41 +02:00
Marc-André Bombeck f48f496c39 chore(release): v1.32.1 — honest health score, Coach score questions, import and pulse fidelity, three silent data-loss races 2026-07-23 10:37:26 +02:00
Marc-André Bombeck 0375fadd85 Merge remote-tracking branch 'origin/fix/apple-health-pulse-cluster' into release/v1.32.1 2026-07-23 10:27:20 +02:00
Marc-André Bombeck 0598c90af2 Merge remote-tracking branch 'origin/fix/silent-data-loss-batch' into release/v1.32.1 2026-07-23 10:27:10 +02:00
Marc-André Bombeck ecf2d1a7d5 style: run prettier over the import-worker reconcile addition 2026-07-23 10:01:09 +02:00
Marc-André Bombeck 6715c4a69a observability(intraday-pulse): surface per-shape row counts and HR-bucket write outcomes
Related to #585. Investigated the intraday pulse chart's read path
end to end: the ZIP-import raw-sample fold, the live-sync uploaded
10-min bucket fold, their merge, and the chart's render logic. All of
it treats the two ingestion shapes consistently — the chart has no
shape awareness at all, it paints whatever bucket series it receives.
No read-side or display-side defect explains a systematic density
difference between the two ingestion paths.

What was missing is visibility into the write and read sides that
would let a future report actually be diagnosed instead of
re-investigated from scratch. The batch endpoint's wide-event trace
never broke a skip down by reason, and never distinguished an
HR-bucket write from any other entry in the same request. The
intraday route's own trace surfaced only the final bucket count, with
no visibility into which shape (raw sample rows vs uploaded bucket
rows vs folded hourly rows) a day's data actually came from. Both
traces now carry that breakdown, so the next report of "the chart
looks sparser after a given date" is answerable by grep instead of by
re-deriving the whole read path again.
2026-07-23 09:55:23 +02:00
Marc-André Bombeck 32edb033e8 docs: note the three silent-data-loss fixes in the changelog 2026-07-23 09:39:46 +02:00
Marc-André Bombeck 9ef3207cb6 style: run prettier on the dashboard-layout-section test file 2026-07-23 09:38:24 +02:00
Marc-André Bombeck fb50335459 fix(insights): stop the pulse page from silently swapping to resting heart rate
Closes #584. `/insights/pulse` computed hasRestingHr from
RESTING_HEART_RATE presence and used it to swap the ENTIRE primary
chart and the Coach read strip to that series whenever any resting
row existed, while the title, stat strip, capture action, and
"show all values" link kept saying PULSE. A user could see a
resting-only chart under a page that claimed to be pulse throughout.

The chart now always includes PULSE as its first series; when resting
data exists, RESTING_HEART_RATE renders as a second, clearly-labelled
series (the same multi-series pattern the blood-pressure page uses for
systolic/diastolic) instead of replacing it. The Coach read strip
stays pinned to PULSE. The page also drops the resting-calibrated
target band it used to paint behind the chart — that band is
calibrated to steady-state resting readings and would misreport an
expected workout spike in raw PULSE data as "out of target"; the
personal target belongs on the dedicated resting-pulse page against
the clean resting series.
2026-07-23 09:38:03 +02:00
Marc-André Bombeck d750c1827e fix(settings): stop ntfy PUT from clearing the stored auth token on unrelated edits
PUT /api/settings/ntfy reconstructed the entire stored config from the
request body. GET never returns the write-only authToken (only
hasAuthToken), so clients correctly omit it on any edit that doesn't
touch the credential — but the route treated an omitted or empty token
as "clear it", silently destroying a working notification credential on
routine edits like toggling enabled.

An omitted or empty authToken now preserves the currently stored value;
only a non-empty value replaces it. Mirrors the identical headerValue
preserve-on-empty contract already shipped on the webhook channel route.
2026-07-23 09:28:39 +02:00
Marc-André Bombeck 1371bb1cf9 fix(measurement-reminders): recompute nextDueAt from the field actually set, not a nullish fallback
PATCH /api/measurement-reminders/{id} persisted an explicit null cadence
clear correctly, but the immediate nextDueAt recomputation merged fields
with `updateValue ?? existingValue` — a JSON null and an omitted field
are both nullish, so the merge silently fell back to the OLD cadence for
one cycle. This hit interval-to-RRULE and RRULE-to-interval transitions
and an explicit anchorDate clear alike.

The merge now keys off Object.hasOwn(updateData, field): updateData
already encodes exactly which fields are being persisted, including the
mutual-exclusivity auto-clear (setting one cadence nulls the other), so
recomputation can never diverge from what the row ends up holding.
2026-07-23 09:25:16 +02:00
Marc-André Bombeck 79843f8770 fix(import): stream the Apple Health ZIP extraction and sweep orphaned import jobs
Closes #588. The ZIP extractor decompressed export.xml synchronously
into one in-memory buffer, then wrote it with a blocking writeFileSync
— on the same event loop the web server runs in the default
single-container deployment. A real multi-year export could pin the
Node main thread for minutes, freezing every other request, while
briefly holding roughly 2x the decompressed size in heap. A process
that died mid-extraction never reached the failure path, so the
ImportJob row stayed stuck in "unpacking" with "0 rows imported"
forever and no error ever surfaced.

Extraction now streams through zlib's async inflate into a file write
stream, moving the decompression work off the event loop and bounding
memory to the chunk size. The zip-bomb ceiling moves from
inflateRawSync's maxOutputLength to an equivalent byte-counting
transform on the streamed output.

Orphan-ImportJob reconciliation used to run once, at worker boot.
docker-compose's restart:unless-stopped does bring a crashed worker
back, but a fast restart can land before the stuck row's heartbeat
goes stale or pg-boss stops reporting the job as live — and because
the boot-time pass never re-runs on a healthy worker, that row was
never revisited again. A 15-minute cron now re-runs the same reconcile
so a genuinely abandoned import converges to a visible failure instead
of staying silently stuck.
2026-07-23 09:23:28 +02:00
Marc-André Bombeck 8736f44442 fix(dashboard): stop the score-ring PUT from resending a stale layout
The Settings page's instant score-ring toggle PUT the full dashboard
layout, built from the client's cached `remote` snapshot. That snapshot
can go stale mid-flight: if a normal tile/chart Save committed a newer
layout first, the ring request's explicit stale `widgets` (and
`comparisonBaseline` / `chartOverlayPrefs`) still won on write and
silently reverted the just-saved layout whenever it resolved last.

The ring mutation now PUTs only the two ring fields
(`buildRingMutationPayload`). `widgets` joins the existing
preserve-when-absent merge (`LAYOUT_FIELD_MERGE_DISPOSITION`) so an
omitted field is carried forward from whatever is currently stored
instead of a client-held copy, closing the race regardless of request
order.
2026-07-23 09:16:13 +02:00
Marc-André Bombeck 58fddeb0d6 style: run prettier on the health-score fast-path test file 2026-07-23 09:15:41 +02:00
Marc-André Bombeck c256d945b3 fix(ai): stop the outbound risk guard from blocking this app's own scores
The named-risk-engine pattern in the outbound safety screen used
score2? (the 2 optional), so it matched bare "score" too — any mention
of one of this app's own computed, documented scores (Sleep Score,
Readiness Score, Health Score, Recovery Score, Stress Score, Strain
Score) tripped the bank meant only to catch a fabricated named
clinical-risk calculator (SCORE2, Framingham, ASCVD, QRISK).

Require the literal "SCORE2" instead. The other risk patterns —
percentage-shaped risk claims and the other three named engines — are
untouched, so a genuinely fabricated risk figure or a named
risk-calculator claim still blocks.

Closes #587.
2026-07-23 09:10:07 +02:00
Marc-André Bombeck 35a3842bba fix(analytics): return null health score when no pillar clears its own bar
The empty-path guard in the score fast-path checked raw record presence
(any row at all) rather than each pillar's own eligibility threshold —
weight needs 2 readings, mood needs 5 entries, BP needs a paired
reading, compliance needs an active scheduled medication. An account
with some data but no eligible pillar (a single mood entry, a single
weight reading) fell through to computeHealthScore, which summed no
components and returned a raw 0/red score indistinguishable from a
genuinely poor result.

Check the computed result's own components instead of re-deriving the
thresholds, so the guard can never drift from computeHealthScore's
actual eligibility rules. A pillar legitimately computed as 0 keeps its
0 value and is never swept into the null branch.

Closes #582.
2026-07-23 09:08:18 +02:00
Marc-André Bombeck 62eb431d66 release: v1.32.0 — hardening and scalability (#580)
* release: prepare v1.32.0

* test: stabilize water capture re-entry
2026-07-22 04:14:15 +02:00
Marc-André Bombeck b01822be48 Merge pull request #579 from MBombeck/release/v1.31.8
chore: release v1.31.8
2026-07-21 18:20:40 +02:00
Marc-André Bombeck 25a56983b3 fix(medications): preserve wizard state during URL cleanup 2026-07-21 18:09:23 +02:00
Marc-André Bombeck 7f55083c3c chore: release v1.31.8 2026-07-21 16:29:04 +02:00
Marc-André Bombeck 0d5d61bc36 fix(mcp): stabilize lab search pagination (#578) 2026-07-21 16:17:16 +02:00
Marc-André Bombeck b20f1b5c66 fix: harden imports and daily medication state (#577) 2026-07-21 13:08:28 +02:00
Marc-André Bombeck 403eabb888 fix: restore runtime Prisma migration loading (#574) 2026-07-21 06:36:13 +02:00
Marc-André Bombeck a30c31b25d chore: release v1.31.5 (#573)
* chore: prepare v1.31.5 release

* docs: sync OpenAPI version for v1.31.5
2026-07-21 05:22:45 +02:00
Marc-André Bombeck 085aa3f5c5 fix: harden pnpm supply chain and runtime image (#572) 2026-07-21 04:36:53 +02:00
Marc-André Bombeck e38179e854 Merge pull request #571 from MBombeck/release/v1.31.4
release: v1.31.4 operational hardening
2026-07-21 02:41:45 +02:00
Marc-André Bombeck 5fe952b63d fix(ci): harden release and e2e verification 2026-07-21 02:30:40 +02:00
Marc-André Bombeck b38f41b99d chore(release): prepare v1.31.4 2026-07-21 01:34:29 +02:00
Marc-André Bombeck 3ce6726646 fix(ci): harden test determinism and dependencies 2026-07-21 00:36:40 +02:00
Marc-André Bombeck 9adeb8fc19 fix(runtime): harden process startup and health 2026-07-21 00:05:03 +02:00
Marc-André Bombeck 914e7658db ci: harden image publishing 2026-07-20 23:47:19 +02:00
Marc-André Bombeck be1d0fb728 Merge pull request #570 from MBombeck/fix/document-summary-coach-mobile
fix: localize document summaries and refine mobile coach
2026-07-20 22:45:51 +02:00
Marc-André Bombeck 3aa8ccd541 fix: localize document summaries and refine mobile coach 2026-07-20 22:34:56 +02:00
479 changed files with 31944 additions and 10088 deletions

No files matched your search

+1
View File
@@ -49,6 +49,7 @@ public/sw-version.js
# Editor / agent worktrees + caches.
.claude
.worktrees
.cursor
.aider*
.vscode
+11
View File
@@ -98,6 +98,17 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
# Users bring their own API keys via Settings. No server-side key needed by
# default. The admin can configure a fallback key in the Admin Panel.
# Local-AI SSRF opt-in (optional). The local AI provider rejects private/
# internal hosts by default. Set to an exact host allowlist (comma-separated,
# e.g. "ollama.lan,10.0.0.5") or "true" for any private host — intended for a
# self-hosted Ollama / LM Studio endpoint. See docs/integrations/ai-providers.md.
# ALLOW_LOCAL_AI_PRIVATE_HOSTS=""
# Daily-briefing output-token ceiling (optional). Default 2500, clamped to
# 500-8000. Raise it when a verbose model's briefing JSON gets cut off
# ("AI response was cut off"). See docs/integrations/ai-providers.md.
# INSIGHTS_MAX_TOKENS=""
# Maximum number of comprehensive-insight generations a single user can
# trigger per hour (POST /api/insights/generate). Default 10. The 24h
# DB cache short-circuits this for read traffic; only force=true and
+115 -78
View File
@@ -37,6 +37,57 @@ concurrency:
cancel-in-progress: true
jobs:
release-policy:
name: Validate release policy
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
actions: read
contents: read
steps:
- name: Checkout full history
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Validate release policy
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
echo "::notice::Pull-request image validation does not publish release tags."
exit 0
fi
if [ "$GITHUB_REF_TYPE" != "tag" ] || [[ ! "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "::error::Release ref '$GITHUB_REF_NAME' is not a strict SemVer tag (expected vMAJOR.MINOR.PATCH with no leading zeroes or prerelease suffix)." >&2
exit 1
fi
package_version="$(node -p "require('./package.json').version")"
tag_version="${GITHUB_REF_NAME#v}"
if [ "$package_version" != "$tag_version" ]; then
echo "::error::package.json version '$package_version' does not match release tag '$GITHUB_REF_NAME'." >&2
exit 1
fi
git fetch --no-tags origin main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "::error::Release commit $GITHUB_SHA is not an ancestor of origin/main." >&2
exit 1
fi
successful_quality_runs="$(
gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$GITHUB_SHA&event=push&status=success&per_page=100" \
--jq '[.workflow_runs[] | select(.name == "Security & Quality" and .head_branch == "main")] | length'
)"
if [ "$successful_quality_runs" -lt 1 ]; then
echo "::error::Release commit $GITHUB_SHA has no successful prior Security & Quality push run on main." >&2
exit 1
fi
echo "Release policy passed for $GITHUB_REF_NAME at $GITHUB_SHA."
# ── Stage 1 ────────────────────────────────────────────────────────
# Build each architecture on its own native GitHub-hosted runner so
# Next.js's static-page workers run on real silicon instead of QEMU
@@ -54,6 +105,7 @@ jobs:
# manifest in a third step, so consumers see a single tag that
# auto-selects on `docker pull` per local platform.
build:
needs: [release-policy]
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.runner }}
# 25 min wall clock — native amd64 build finished inside 12 min
@@ -265,6 +317,11 @@ jobs:
if: github.event_name != 'pull_request'
needs: [build]
timeout-minutes: 10
outputs:
image_ref: ${{ steps.published.outputs.image_ref }}
image_tag: ${{ steps.published.outputs.image_tag }}
image_digest: ${{ steps.published.outputs.image_digest }}
build_sha: ${{ steps.published.outputs.build_sha }}
permissions:
contents: read
packages: write
@@ -335,9 +392,21 @@ jobs:
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE_REF }}@sha256:%s ' *)
- name: Inspect published manifest
- name: Resolve published image
id: published
env:
IMAGE_TAG: ${{ github.ref_name }}
run: |
docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}'
image_digest="$(docker buildx imagetools inspect "${IMAGE_REF}:${IMAGE_TAG}" --format '{{json .Manifest.Digest}}' | tr -d '"')"
if [[ ! "$image_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "::error::Could not resolve a valid manifest digest for ${IMAGE_REF}:${IMAGE_TAG}." >&2
exit 1
fi
echo "image_ref=$IMAGE_REF" >> "$GITHUB_OUTPUT"
echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
echo "image_digest=$image_digest" >> "$GITHUB_OUTPUT"
echo "build_sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
docker buildx imagetools inspect "${IMAGE_REF}@${image_digest}"
# v1.16.4 — sign the multi-arch manifest index (keyless, Sigstore).
# Signing the INDEX digest covers both per-platform manifests in
@@ -357,91 +426,59 @@ jobs:
- name: Sign multi-arch manifest (keyless)
env:
IMAGE_VERSION: ${{ steps.meta.outputs.version }}
IMAGE_DIGEST: ${{ steps.published.outputs.image_digest }}
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}"
echo "Signing ${IMAGE_REF}@${IMAGE_DIGEST}"
cosign sign --yes "${IMAGE_REF}@${IMAGE_DIGEST}"
echo "::notice::Image index signed (keyless): ${IMAGE_REF}@${IMAGE_DIGEST}. Verify with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/${{ github.repository }}/' ${IMAGE_REF}@${IMAGE_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.
#
# Runs only on the publish path (push to `main` or to a `v*` tag), never
# on PRs. `continue-on-error: true` because the image is *already*
# published to GHCR by the time we reach this step — a Coolify-side
# outage must not retroactively fail the build. The maintainer still
# has the manual recipe as fallback.
#
# Required GitHub repository secrets (set by the maintainer once):
# COOLIFY_WEBHOOK = full deploy URL incl. ?uuid=...&force=false
# COOLIFY_TOKEN = Bearer token from Coolify UI -> Keys & Tokens
# Both secrets are referenced exclusively here. If either is unset, the
# step short-circuits to a skip rather than calling Coolify with empty
# auth.
#
# v1.4.26 P6-3 — maintainer toggle.
# Three releases in a row shipped via the host-side SSH-retag fallback
# because the secrets above silently absent + the warn-only skip
# below let the workflow stay green. The `vars.COOLIFY_AUTO_DEPLOY`
# repository variable now distinguishes the two intended modes:
#
# COOLIFY_AUTO_DEPLOY=on → if secrets present, deploy; if absent,
# FAIL the step so the maintainer sees the
# missing-secret gap on the next release.
# COOLIFY_AUTO_DEPLOY=off → unconditional skip with a notice. Use
# this when the host-side SSH recipe is
# the intended deploy path.
# (unset / any other) → warn-and-skip (legacy behaviour).
#
# Set the variable under repo Settings → Secrets and variables →
# Actions → Variables (NOT Secrets — it is intentionally readable).
verify-published-image:
name: Verify exact published image
needs: merge
permissions:
contents: read
packages: read
uses: ./.github/workflows/post-publish-verify.yml
with:
image_ref: ${{ needs.merge.outputs.image_ref }}
image_tag: ${{ needs.merge.outputs.image_tag }}
image_digest: ${{ needs.merge.outputs.image_digest }}
build_sha: ${{ needs.merge.outputs.build_sha }}
secrets: inherit
promote:
name: Promote verified image to Coolify
needs: [merge, verify-published-image]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Trigger Coolify deploy
continue-on-error: true
env:
COOLIFY_WEBHOOK: ${{ secrets.COOLIFY_WEBHOOK }}
COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }}
COOLIFY_AUTO_DEPLOY: ${{ vars.COOLIFY_AUTO_DEPLOY }}
IMAGE_VERSION: ${{ steps.meta.outputs.version }}
IMAGE_TAG: ${{ needs.merge.outputs.image_tag }}
IMAGE_DIGEST: ${{ needs.merge.outputs.image_digest }}
BUILD_SHA: ${{ needs.merge.outputs.build_sha }}
run: |
mode="${COOLIFY_AUTO_DEPLOY:-}"
if [ "$mode" = "off" ]; then
echo "::notice::COOLIFY_AUTO_DEPLOY=off — skipping registry deploy hook. Maintainer uses the host-side SSH recipe instead."
echo "::notice::COOLIFY_AUTO_DEPLOY=off — skipping registry deploy hook."
exit 0
fi
if [ "$mode" != "on" ]; then
echo "::warning::COOLIFY_AUTO_DEPLOY is not 'on' or 'off' — skipping auto-deploy. Set an explicit mode in repository variables."
exit 0
fi
if [ -z "$COOLIFY_WEBHOOK" ] || [ -z "$COOLIFY_TOKEN" ]; then
if [ "$mode" = "on" ]; then
echo "::error::COOLIFY_AUTO_DEPLOY=on but COOLIFY_WEBHOOK or COOLIFY_TOKEN secret is missing. Set both under repo Settings → Secrets and variables → Actions, or set COOLIFY_AUTO_DEPLOY=off to silence."
exit 1
fi
echo "::warning::COOLIFY_WEBHOOK or COOLIFY_TOKEN secret missing — skipping auto-deploy. Configure in repo Settings → Secrets to enable, or set the COOLIFY_AUTO_DEPLOY variable to 'off' to silence this warning."
exit 0
echo "::error::COOLIFY_AUTO_DEPLOY=on but COOLIFY_WEBHOOK or COOLIFY_TOKEN secret is missing. Set both credentials or disable promotion explicitly." >&2
exit 1
fi
# v1.4.31 — wait for GHCR cache propagation before firing
# the Coolify webhook. Five releases in a row (v1.4.27
# through v1.4.30.1) the webhook reported "finished" but
# Coolify still pulled the prior `:latest` digest. The
# `docker buildx imagetools create` + `inspect` steps
# above confirm the manifest exists at registry-write time,
# but GHCR's CDN edges can take 30-90 s to propagate the
# new digest. The webhook fires inside that window, so
# Coolify pulls "latest" while the edge still serves the
# prior digest, gets "no change", and returns success.
# Waiting out the propagation window before firing the
# webhook closes that gap.
echo "Waiting 90 s for GHCR cache propagation before triggering Coolify (image: $IMAGE_VERSION)…"
echo "Waiting 90 s for registry propagation before promoting ${IMAGE_TAG}@${IMAGE_DIGEST}."
sleep 90
# v1.4.22 C3 — force=true tells Coolify to skip its image-cache
# and re-pull the registry digest. If the maintainer's secret
# URL already carries a `?force=` query, leave it alone;
# otherwise append `?force=true` (or `&force=true` if other
# query params are present) so doc-only deploys still trigger
# a registry-digest check.
deploy_url="$COOLIFY_WEBHOOK"
if ! echo "$deploy_url" | grep -q "force="; then
if echo "$deploy_url" | grep -q "?"; then
@@ -450,18 +487,18 @@ jobs:
deploy_url="${deploy_url}?force=true"
fi
fi
echo "Triggering Coolify deploy for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7})"
response=$(curl --silent --show-error --max-time 30 \
echo "Triggering Coolify deploy for ${IMAGE_TAG}@${IMAGE_DIGEST} (source ${BUILD_SHA})."
response="$(curl --silent --show-error --max-time 30 \
--write-out "\n__HTTP_STATUS__:%{http_code}" \
--request GET "$deploy_url" \
--header "Authorization: Bearer $COOLIFY_TOKEN")
--header "Authorization: Bearer $COOLIFY_TOKEN")"
status="${response##*__HTTP_STATUS__:}"
body="${response%__HTTP_STATUS__:*}"
echo "Coolify response (HTTP $status):"
echo "$body"
if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
echo "::error::Coolify deploy webhook returned HTTP $status — image is published, but auto-deploy did not fire. Maintainer: deploy manually on the deployment host (SSH force-pull recipe, see docs/ops/deploy.md)."
echo "::error::Coolify deploy webhook returned HTTP $status for verified image ${IMAGE_TAG}@${IMAGE_DIGEST}." >&2
exit 1
fi
echo "::notice::Coolify auto-deploy webhook fired at $(date -u +%FT%TZ) for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7}). If /api/version doesn't reach the new tag within ~60s, re-trigger the deploy from the Coolify UI or deploy manually on the deployment host (docs/ops/deploy.md)."
echo "Coolify deploy queued — awaiting outgoing notification webhook to /api/internal/deploy-webhook for final status."
echo "Coolify deploy queued for verified image ${IMAGE_TAG}@${IMAGE_DIGEST}."
+5 -3
View File
@@ -37,10 +37,11 @@ jobs:
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
ENCRYPTION_ACTIVE_KEY_ID: v1
API_TOKEN_HMAC_KEY: ci-hmac-key-32-bytes-min-padding-x
API_TOKEN_HMAC_KEY: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
AUTH_RP_NAME: HealthLog
AUTH_RP_ID: localhost
AUTH_RP_ORIGIN: http://localhost:3000
SESSION_COOKIE_SECURE: "false"
NODE_ENV: production
steps:
@@ -87,10 +88,11 @@ jobs:
env:
E2E_BASE_URL: http://localhost:3000
- name: Upload Playwright HTML report on failure
if: failure()
- name: Upload Playwright HTML report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: playwright-report/
retention-days: 7
if-no-files-found: ignore
+1 -1
View File
@@ -38,6 +38,6 @@ jobs:
# string "0" — failing crypto's hex/base64 length check and breaking
# the backups + integration-status suites for 50+ runs (regression
# since v1.4.13). Quoting forces a literal scalar.
API_TOKEN_HMAC_KEY: "integration-tests-do-not-use-in-prod"
API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
SESSION_SECRET: "integration-tests"
+108 -79
View File
@@ -1,33 +1,42 @@
name: Post-publish verify
# Informational reachability gate that runs AFTER `Build & Publish Docker
# Image` finishes a `main` (or tag) build. Pulls the just-published image
# from GHCR and probes:
# - the image manifest is fetchable (i.e. the push actually completed)
# - the container starts and `/api/version` returns the expected version
# - the container's `/api/health` endpoint reports OK
#
# Does NOT block deploys — `continue-on-error` is set on every probe step
# so a transient registry blip doesn't go red. This is a smoke gate the
# v1.4.14 release would have caught: the main push appeared to "succeed"
# from the deploy host's POV, but the host had to pull the tag image
# because main's manifest never finished pushing.
#
# Triggered by `workflow_run` so it only runs after the build workflow
# actually completes — chaining via `needs:` would force the build into
# the same workflow file.
on:
workflow_run:
workflows: ["Build & Publish Docker Image"]
types: [completed]
branches: [main]
workflow_call:
inputs:
image_ref:
description: Exact registry repository to verify
required: true
type: string
image_tag:
description: Exact published release tag
required: true
type: string
image_digest:
description: Exact published multi-arch manifest digest
required: true
type: string
build_sha:
description: Expected short source commit SHA
required: true
type: string
workflow_dispatch:
inputs:
image_ref:
description: Exact registry repository to verify
required: true
type: string
image_tag:
description: "GHCR image tag to verify (defaults to `main`)"
required: false
default: "main"
description: Exact published release tag
required: true
type: string
image_digest:
description: Exact published multi-arch manifest digest
required: true
type: string
build_sha:
description: Expected short source commit SHA
required: true
type: string
permissions:
contents: read
@@ -35,14 +44,15 @@ permissions:
jobs:
verify:
name: Pull + smoke-test published image
name: Pull and smoke-test exact published image
runs-on: ubuntu-latest
timeout-minutes: 10
# Skip if the upstream build itself failed — there's nothing to verify.
# Manual dispatch always runs.
if: >-
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
env:
IMAGE_REF: ${{ inputs.image_ref }}
IMAGE_TAG: ${{ inputs.image_tag }}
IMAGE_DIGEST: ${{ inputs.image_digest }}
BUILD_SHA: ${{ inputs.build_sha }}
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
services:
postgres:
@@ -60,15 +70,6 @@ jobs:
--health-retries 10
steps:
- name: Resolve image tag
id: tag
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "tag=${{ github.event.inputs.image_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=main" >> "$GITHUB_OUTPUT"
fi
- name: Log in to GHCR
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
@@ -76,69 +77,97 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull image
id: pull
continue-on-error: true
- name: Pull exact image
run: |
IMAGE="ghcr.io/${{ github.repository }}:${{ steps.tag.outputs.tag }}"
echo "Pulling $IMAGE"
docker pull "$IMAGE"
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
TAG_REF="${IMAGE_REF}:${IMAGE_TAG}"
DIGEST_REF="${IMAGE_REF}@${IMAGE_DIGEST}"
- name: Start container
if: steps.pull.outcome == 'success'
id: run
continue-on-error: true
echo "Pulling published tag $TAG_REF"
docker pull "$TAG_REF"
resolved_digest="$(docker buildx imagetools inspect "$TAG_REF" --format '{{json .Manifest.Digest}}' | tr -d '"')"
if [ "$resolved_digest" != "$IMAGE_DIGEST" ]; then
echo "::error::Published tag digest mismatch: $TAG_REF resolved to '$resolved_digest', expected '$IMAGE_DIGEST'." >&2
exit 1
fi
echo "Pulling immutable image $DIGEST_REF"
docker pull "$DIGEST_REF"
- name: Run migrations
run: |
# Minimal env to satisfy the strict-env validators on boot.
DIGEST_REF="${IMAGE_REF}@${IMAGE_DIGEST}"
docker run --rm \
--network host \
--entrypoint node \
-e DATABASE_URL="$DATABASE_URL" \
"$DIGEST_REF" \
/opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy
- name: Start exact image
run: |
DIGEST_REF="${IMAGE_REF}@${IMAGE_DIGEST}"
docker run -d --name healthlog-verify \
--network host \
-e DATABASE_URL='postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public' \
-e DATABASE_URL="$DATABASE_URL" \
-e ENCRYPTION_KEYS='{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' \
-e ENCRYPTION_ACTIVE_KEY_ID=v1 \
-e API_TOKEN_HMAC_KEY=verify-hmac-key-32-bytes-padding-x \
-e API_TOKEN_HMAC_KEY=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc \
-e AUTH_RP_NAME=HealthLog \
-e AUTH_RP_ID=localhost \
-e AUTH_RP_ORIGIN=http://localhost:3000 \
-e NODE_ENV=production \
-e HEALTHLOG_PROCESS_TYPE=web \
-e PORT=3000 \
"${{ steps.pull.outputs.image }}"
"$DIGEST_REF"
# Wait up to 60 s for the server to start answering.
- name: Verify exact version
run: |
body=""
for i in $(seq 1 30); do
if curl -sf -o /dev/null http://localhost:3000/api/version; then
echo "ready after ${i}*2s"
if body="$(curl -sf http://localhost:3000/api/version)"; then
echo "Version endpoint ready after $((i * 2))s"
break
fi
sleep 2
done
if [ -z "$body" ]; then
echo "::error::Exact image did not expose /api/version within 60 seconds." >&2
docker logs healthlog-verify || true
exit 1
fi
EXPECTED_VERSION="${IMAGE_TAG#v}"
echo "$body" | jq .
if ! jq -e --arg expected "$EXPECTED_VERSION" '.data.version == $expected' <<< "$body" >/dev/null; then
actual="$(jq -r '.data.version // "<missing>"' <<< "$body")"
echo "::error::Version mismatch: exact image reports '$actual', expected '$EXPECTED_VERSION' (release tag '$IMAGE_TAG')." >&2
exit 1
fi
if ! jq -e --arg expected "$BUILD_SHA" '.data.buildSha == $expected' <<< "$body" >/dev/null; then
actual="$(jq -r '.data.buildSha // "<missing>"' <<< "$body")"
echo "::error::Build SHA mismatch: exact image reports '$actual', expected '$BUILD_SHA'." >&2
exit 1
fi
- name: Verify health
run: |
for i in $(seq 1 30); do
if body="$(curl -sf http://localhost:3000/api/health)" && jq -e '.status == "ok"' <<< "$body" >/dev/null; then
echo "$body" | jq .
echo "Health endpoint ready after $((i * 2))s"
exit 0
fi
sleep 2
done
echo "::warning::Container did not become ready within 60 s"
echo "::error::Exact image did not report healthy within 60 seconds." >&2
docker logs healthlog-verify || true
exit 1
- name: Probe /api/version
if: steps.run.outcome == 'success'
- name: Container logs
if: always()
continue-on-error: true
run: |
set -e
BODY=$(curl -sf http://localhost:3000/api/version)
echo "$BODY" | jq .
VER=$(echo "$BODY" | jq -r '.data.version // .version')
echo "::notice::published image ${{ steps.tag.outputs.tag }} reports version=$VER"
- name: Probe /api/health
if: steps.run.outcome == 'success'
continue-on-error: true
run: |
curl -sf http://localhost:3000/api/health | jq .
- name: Container logs (always)
if: always() && steps.run.outcome == 'success'
continue-on-error: true
run: docker logs healthlog-verify | tail -100
run: docker logs healthlog-verify
- name: Tear down
if: always()
continue-on-error: true
run: docker rm -f healthlog-verify || true
run: docker rm -f healthlog-verify
+176
View File
@@ -2,6 +2,182 @@
## [Unreleased]
## [1.32.2] — 2026-07-23
Security release. Ships on its own and ahead of everything queued, because it
closes a set of freshly disclosed framework vulnerabilities that affect the
running instance today.
- **Next.js updated to 16.2.11**, closing four high-severity advisories in the
App Router: a middleware/proxy bypass, two server-side request-forgery
paths (rewrites and Server Actions), and a denial of service. The
middleware/proxy advisory is the most relevant here, since the request edge
enforces the public-path allowlist and the authentication redirect. No
application code changed; this is the dependency patch alone.
Carries the fixes already merged for 1.32.1 (honest health-score absence,
Coach score questions, Apple Health import and Pulse fidelity, three
silent data-loss races), since those reached the trunk first. No migrations
beyond what 1.32.1 declared (none). No breaking changes.
## [1.32.1] — 2026-07-23
- **The Personal Health Score reads "not enough data yet" instead of zero.**
An account with records that do not yet meet any score pillar's minimum
(for example a single mood entry, no paired blood pressure, one weight
reading) showed a red 0 rather than an honest absence. The score is now
withheld until at least one pillar is computable; a genuinely computed 0 is
still shown.
- **The Coach stops refusing ordinary questions about your own scores.** A
question about a Sleep Score or Health Score no longer trips the
fabricated-clinical-risk refusal; the guard now matches the specific risk
calculator it was meant to catch, not the plain word "score", and still
blocks a genuinely invented risk figure.
- **The Apple Health import no longer stalls at "unpacking".** A large export
is now unpacked as a stream rather than decompressed whole into memory on
the shared event loop, and a job left mid-unpack by a restart is picked up
by a periodic reconcile instead of sitting stuck with no error. A byte
ceiling still guards against a decompression bomb.
- **The Pulse insights page shows pulse, not resting heart rate.** It no
longer silently swaps its main chart to resting heart rate when any resting
data exists; resting heart rate appears as a clearly labelled second series
when present, and the resting-calibrated target band no longer paints
behind raw pulse readings.
- **Dashboard layout changes can no longer be reverted by a concurrent
score-ring save.** The instant score-ring toggle now sends only the ring
fields instead of resending a cached snapshot of the whole layout, closing
a race where an in-flight ring update could land after a tile or chart Save
and silently restore the older layout.
- **Measurement-reminder cadence edits recompute against the cadence that
was actually saved.** Clearing an interval or a recurrence rule to switch a
reminder's schedule no longer leaves the next-due date computed from the
just-replaced value for one cycle.
- **ntfy notification settings keep a saved auth token across unrelated
edits.** Toggling the channel, or editing the server URL or topic, no
longer silently clears a previously configured token.
No migrations. No breaking changes.
## [1.32.0] — 2026-07-22
- **Provider ingestion and daily reactions now preserve the correct identity.**
Arrival reactions are bound to the exact marker revision, workout selection
matches field donors to the correct surviving session, long-range buckets
honor the user's timezone, MCP pages stay stable, and one Withings
subscription repair can no longer block fallback polling for other users.
- **Large reads and writes are bounded end to end.** Measurement exports stream
in pages without splitting sleep sessions, oversized projections cannot evict
the shared cache, dense reports and metric series use database aggregation,
upload reads have byte, time, and concurrency limits, full-history backfills
share admission control, and medication imports use durable chunks with
resumable, bounded final rollups.
- **Capture and history flows are complete on mobile and desktop.** Workout
history loads beyond the first page, metric pages open preselected capture
forms and return to their origin, failed water submissions retain their draft,
and confirmed discards clear it.
- **Interactive forms share one safe dismissal contract.** Dirty forms require
confirmation across tracked Back and Forward traversals, Apple Health setup
exposes successful native syncs, document selection is consumed on Coach
dismissal but preserved across full-page Coach navigation, conversation
titles can be renamed with rollback, and hidden tabs refresh meaningful
server state.
- **Accessibility coverage now exercises representative authenticated routes,
themes, dialogs, and sheets.** Small text keeps compliant contrast, OCR uses a
visible labeled control, workout sections use real headings, and compact
actions retain 44-pixel touch targets.
- **Client and API boundaries are explicit.** Localized stable error codes cover
measurement, mood, lab, and onboarding failures; client fetches and query
keys are lint-enforced; AI overrides are documented; and large Coach,
report, target, and provider modules now use one-way focused builders.
- **Production image processing uses the patched Sharp 0.35 runtime.** This
closes the current high-severity libvips advisory chain inherited through
Next.js while retaining verified production-build compatibility.
Migrations `0264_whoop_owner_identity`, `0265_withings_subscription_state`, and
`0266_medication_intake_import_jobs`. No breaking changes.
## [1.31.8] — 2026-07-21
- **MCP search cursors keep lab results stable across pages.** Distinct analyte
names are ordered before offset pagination, preventing repeated or skipped
lab entries when a client follows `nextCursor`.
- **Opening the medication wizard no longer risks clearing entered fields.**
The one-shot URL flag is removed without starting a second page navigation,
so slower browsers keep the active wizard instance and its form state.
No migrations. No breaking changes.
## [1.31.7] — 2026-07-20
- **Current OpenAI reasoning models use their supported request contract.**
Official GPT-5, o1, o3, and o4 models receive `max_completion_tokens`
without unsupported `temperature` or `seed` fields; compatible custom
gateways retain the legacy parameters they advertise.
- **Apple Health XML cumulative totals no longer add overlapping sources.**
Imports aggregate each source/day independently and select the highest
source subtotal, while explicit provenance prevents XML estimates or late
legacy samples from overwriting authoritative native HealthKit statistics.
Existing legacy aggregates can be repaired by re-uploading their archive.
- **Every currently actionable medication reaches Today.** Equal-time doses no
longer collapse to one row, overdue doses win deterministic ties, and weekly
or custom intake windows use the scheduling engine's real availability
boundary. The shared digest fixes both the web start page and iOS Home.
Migration `0263_apple_health_aggregate_authority`. No breaking changes.
## [1.31.6] — 2026-07-21
- **The hardened runtime image can run database migrations again.** The
isolated Prisma CLI now exposes its pinned `dotenv` and `prisma/config`
dependencies to the copied production configuration while package managers
remain absent from the final image.
No migrations. No breaking changes.
## [1.31.5] — 2026-07-21
- **The production image no longer ships build-time package managers.** pnpm 11
verifies the lockfile under explicit build-script policy, npm and Corepack
are removed after migrations are prepared, and linked worktrees stay out of
local Docker build contexts.
No migrations. No breaking changes.
## [1.31.4] — 2026-07-21
- **Web and background processes now start and recover predictably.** Invalid
encryption-key configuration fails before the service accepts traffic,
worker-only processes report their real health, connection limits are
divided safely across replicas, and provider backfills keep their bounded
queues during reconnects.
- **Published images are reproducible and verified before promotion.** The
release pipeline builds one multi-architecture image, validates its digest,
startup, migrations, health, and reported version, and only then promotes
that exact artifact to production.
- **CI now uses isolated, deterministic test environments.** Integration tests
receive their own database and cryptographic settings before application
code loads, worktrees install hooks consistently, and browser tests use the
intended application process on every platform.
- **Concurrent iOS registrations no longer fail intermittently.** Device and
APNs notification-channel reconciliation now share one database lock and
transaction, so simultaneous first registration remains idempotent.
No migrations. No breaking changes.
## [1.31.3] — 2026-07-20
- **Document summaries now stay in the language you chose.** The same account
language is used whether a summary is created in the background or requested
from the document sheet. A saved summary remains visible and can be replaced
with **Generate again** after you switch languages.
- **The Coach composer has more room on phones.** The writing field now gets
its own line, with document and conversation actions below it and Send on the
right. The unreliable browser dictation button and its dead-end permission
states are gone.
No migrations. No breaking changes.
## [1.31.2] — 2026-07-20
- **Provider imports now commit identity and progress atomically.** Withings,
+21 -19
View File
@@ -1,14 +1,15 @@
# ── Stage 1: Dependencies ──────────────────────────────────
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS deps
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY scripts/prepare.mjs scripts/prepare.mjs
RUN pnpm install --frozen-lockfile --prod=false
# ── Stage 2: Build ─────────────────────────────────────────
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS builder
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
WORKDIR /app
# v1.4.43 B11 — version build-arg threaded into the layer cache key.
@@ -45,17 +46,15 @@ ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# ── Stage 3: Production runner ─────────────────────────────
FROM node:22-alpine AS runner
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner
# `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware
# timestamp formatting) resolve to the actual offset instead of silently
# falling back to UTC on Alpine images that ship without it.
RUN apk add --no-cache tzdata && \
corepack enable && corepack prepare pnpm@latest --activate
RUN apk add --no-cache tzdata
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_PATH="/opt/pg-boss/node_modules"
ENV TZ=Europe/Berlin
# v1.4.43 B11 — forward NEXT_PUBLIC_APP_VERSION from the builder stage
@@ -80,6 +79,10 @@ RUN addgroup --system --gid 1001 nodejs && \
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Next's standalone output must carry the externalized worker dependencies.
# Prisma's pure-JS adapter stays bundled; only modules loaded through native
# Node resolution belong in this runtime assertion.
RUN node -e "require.resolve('pg-boss'); require.resolve('pg')"
# @napi-rs/canvas (document PDF rasterization): Next's file tracer copies the
# native binary's package into the standalone tree but NOT the pnpm symlinks that
@@ -123,20 +126,19 @@ COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/src/generated ./src/generated
# Install all worker runtime dependencies in a shared prefix.
# NODE_PATH makes them available to Node.js module resolution.
# These packages are listed in next.config.ts serverExternalPackages
# and are NOT bundled by Next.js — they must exist at runtime.
RUN mkdir -p /opt/pg-boss && \
cd /opt/pg-boss && \
npm init -y && \
npm install --omit=dev pg-boss@12.18 @prisma/adapter-pg@7.8 pg@8.20
# Install Prisma CLI + engines for runtime migrations (isolated from Next standalone tree)
# Install the migration CLI, its config dependency, and a pinned launcher for
# the maintenance scripts already shipped in the standalone tree. The Prisma
# config loads dotenv from /app, so expose the isolated copy there before
# stripping npm/Corepack and their caches from the runtime surface.
RUN mkdir -p /opt/prisma-cli && \
cd /opt/prisma-cli && \
npm init -y && \
npm install --omit=dev prisma@7.8 @prisma/engines@7.8
npm install --omit=dev prisma@7.8 @prisma/engines@7.8 tsx@4.23.1 dotenv@17.4.2 && \
ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \
ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack /root/.npm && \
rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx
# v1.4.27 B3 — offline GeoLite2 databases for IP→location and IP→ASN
# lookups. The MMDB files live in `/opt/geolite2/` and are read by
+2 -2
View File
@@ -1,11 +1,11 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Measured at v1.27.15 + the mobile/PWA fix wave: / 417, /insights 402, /measurements 405, /insights/mood 419, total 2683, baseline 129 — budgets carry ~10-15% headroom so the gate catches step changes (a statically imported ~100 KB catalog, a ~90 KB duplicated library chunk), not noise. Raise a number consciously, in the PR that pays the cost, with the reason in the PR body.",
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Measured at v1.32.0 after the correctness/UX architecture wave: / 425, /insights 411, /measurements 412, /insights/mood 428, total 2982, baseline 129. Route caps retain focused headroom; the aggregate cap allows normal chunk noise while still catching a new ~90 KB duplicated library. Message catalogs and duplicate Recharts chunks have dedicated structural guards.",
"routesKbGz": {
"/page": 460,
"/insights/page": 445,
"/measurements/page": 445,
"/insights/mood/page": 460
},
"totalClientKbGz": 2950,
"totalClientKbGz": 3050,
"maxRechartsChunks": 1
}
+2 -2
View File
@@ -227,7 +227,7 @@ services:
test:
[
"CMD-SHELL",
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/version || exit 1",
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1",
]
interval: 30s
timeout: 5s
@@ -245,7 +245,7 @@ services:
# dockerfile: Dockerfile
# container_name: healthlog-worker
# environment:
# DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
# DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public&connection_limit=${DB_CONNECTION_LIMIT:-20}&pool_timeout=${DB_POOL_TIMEOUT:-20}"
# NODE_ENV: production
# ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
# ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
+43 -1
View File
@@ -9,7 +9,7 @@ set -e
# Validating here guarantees a clear, deploy-platform-agnostic
# failure message before we touch the database.
missing=""
for var in DATABASE_URL ENCRYPTION_KEY API_TOKEN_HMAC_KEY; do
for var in DATABASE_URL API_TOKEN_HMAC_KEY; do
eval "value=\$$var"
if [ -z "$value" ]; then
missing="$missing $var"
@@ -21,6 +21,48 @@ if [ -n "$missing" ]; then
exit 1
fi
# ENCRYPTION_KEYS supersedes the legacy single key, so validate the same
# fail-closed shape the runtime crypto loader accepts before touching the DB.
if ! node -e '
const validKey = (value) => {
if (typeof value !== "string") return false;
if (/^[0-9a-fA-F]{64}$/.test(value)) return true;
if (!/^[A-Za-z0-9+/=]+$/.test(value)) return false;
try { return Buffer.from(value, "base64").length === 32; }
catch { return false; }
};
const rawKeyring = (process.env.ENCRYPTION_KEYS || "").trim();
if (rawKeyring) {
let keyring;
try { keyring = JSON.parse(rawKeyring); }
catch { throw new Error("ENCRYPTION_KEYS is invalid JSON"); }
if (!keyring || typeof keyring !== "object" || Array.isArray(keyring)) {
throw new Error("ENCRYPTION_KEYS is invalid: expected an object map");
}
const entries = Object.entries(keyring);
if (
entries.length === 0 ||
entries.some(([id, value]) =>
!/^[A-Za-z0-9_-]{1,32}$/.test(id) || !validKey(value)
)
) {
throw new Error("ENCRYPTION_KEYS is invalid: every entry must be a valid 32-byte key");
}
const activeId =
(process.env.ENCRYPTION_ACTIVE_KEY_ID || "").trim() ||
(entries.length === 1 ? entries[0][0] : "");
if (!activeId || !Object.prototype.hasOwnProperty.call(keyring, activeId)) {
throw new Error("ENCRYPTION_ACTIVE_KEY_ID must name an entry in ENCRYPTION_KEYS");
}
} else if (!validKey((process.env.ENCRYPTION_KEY || "").trim())) {
throw new Error("ENCRYPTION_KEY is missing or invalid");
}
'; then
echo "HealthLog: ERROR — encryption configuration is invalid." >&2
echo "HealthLog: set a valid ENCRYPTION_KEY or ENCRYPTION_KEYS with ENCRYPTION_ACTIVE_KEY_ID." >&2
exit 1
fi
# ── Wait for PostgreSQL ──────────────────────────────────────
MAX_RETRIES=30
RETRY=0
+56 -15
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.31.2
version: 1.32.2
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -10219,13 +10219,13 @@ paths:
post:
tags:
- Documents
summary: Summarise or transcribe a document (session-only)
description: 'On-demand, SESSION-ONLY description of a stored document. `?mode=summary` (default) returns a short
plain-language summary of WHAT the document is; `?mode=text` returns its raw transcribed text. The result is
transient — nothing is persisted except the AI-budget ledger + audit log; it never reaches coach memory,
snapshots, the structured stores, or the search index. The summary is descriptive only and never a diagnosis.
Same VISION (empty body) / TEXT (`application/json` `{ mode: "text", text }`, opt-in local OCR) dispatch as
extract. AI-consent / rate / budget gated. 422 with no provider.'
summary: Summarise or transcribe a document
description: 'On-demand description of a stored document. `?mode=summary` (default) returns a short plain-language
summary of WHAT the document is; it remains transient unless `persist=true` fills the empty stored-summary slot,
while `persist=true&replace=true` explicitly replaces an existing summary. `?mode=text` returns transient raw
transcribed text. Neither result reaches coach memory, snapshots, structured stores, or the search index. The
summary is descriptive only and never a diagnosis. Same VISION (empty body) / TEXT (`application/json` `{ mode:
"text", text }`, opt-in local OCR) dispatch as extract. AI-consent / rate / budget gated. 422 with no provider.'
parameters:
- name: id
in: path
@@ -10242,6 +10242,20 @@ paths:
- text
default: summary
description: "`summary` (plain-language description) or `text` (raw)."
- name: persist
in: query
required: false
schema:
type: boolean
default: false
description: "For summary mode only: persist into an empty stored-summary slot."
- name: replace
in: query
required: false
schema:
type: boolean
default: false
description: With `persist=true`, explicitly replace an existing stored summary.
requestBody:
required: false
content:
@@ -10271,7 +10285,7 @@ paths:
- text
responses:
"200":
description: The session-only summary (`{ summary }`) or extracted text (`{ text }`).
description: The summary and its persistence outcome, or transient extracted text.
content:
application/json:
schema:
@@ -25010,6 +25024,28 @@ components:
anyOf:
- type: string
- type: "null"
dueCandidates:
type: array
items:
type: object
properties:
medicationId:
type: string
medicationName:
type: string
dueAt:
type: string
overdue:
type: boolean
availableFrom:
type: string
required:
- medicationId
- medicationName
- dueAt
- overdue
- availableFrom
additionalProperties: false
required:
- activeCount
- scheduledToday
@@ -25020,12 +25056,11 @@ components:
- nextDueMedicationName
- nextDueMedicationId
additionalProperties: false
description: "Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), and
the earliest next-due slot across active medications. `nextDueOverdue: true` marks an OPEN overdue slot
(anchor passed, still inside its catch-up band). `nextDueMedicationId` deep-links a consumer straight to the
overdue medication's card. Staleness contract: the snapshot is cache-served, so a `nextDueAt` in the past
with `nextDueOverdue: false` means the anchor passed after the snapshot was built — render the plain day
summary, never an overdue state."
description: "Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), every
active medication's current display-due candidate, and legacy scalar fields projected from candidate zero.
`overdue: true` marks an OPEN overdue slot (anchor passed, still inside its catch-up band); `availableFrom`
is the canonical cadence- and dose-window-derived attribution-band start. `dueCandidates` is optional so
older cached snapshots remain valid. Medication ids let consumers deep-link straight to the relevant card."
healthScore:
anyOf:
- type: object
@@ -30389,6 +30424,12 @@ components:
type: string
text:
type: string
persistence:
type: string
enum:
- stored
- withheld
- failed
additionalProperties: false
DocumentIndexEnvelope:
type: object
+59 -18
View File
@@ -112,29 +112,50 @@ mapped set (workout routes, ECG voltage traces) is counted in
### Cumulative HK type → daily aggregate
Cumulative quantity types — steps, active energy, walking/running
distance, flights climbed — get **collapsed into one row per
user-local day** to match the iOS daily-aggregation convention.
distance, flights climbed, time in daylight, and fall count — get
**collapsed into one estimated row per user-local day**. `export.xml`
contains source-specific records, not HealthKit's canonical daily
statistics, so the importer does not sum different devices or apps
into a source-blind total.
- Records are grouped by type, local day, and a SHA-256 hash of the
available source name, source version, and device attributes.
Values sum within each identified source-day; the largest
source-day subtotal is selected deterministically. If every source
attribute is absent, records remain separate rather than being
trusted as one source. Raw source and device labels are not
persisted.
- The row records `EXPORT_XML_SOURCE_MAX` provenance, its distinct
contributor count, and the selected non-identifying source hash.
- External ID format: `stats:<HKType>:<YYYY-MM-DD>` (e.g.
`stats:HKQuantityTypeIdentifierStepCount:2026-05-15`).
- The day boundary respects the user's timezone preference. The
worker defaults to `Europe/Berlin` when no preference is set
(`src/lib/jobs/apple-health-import-worker.ts:132-133`).
- A second import of the same archive re-folds the same days onto
the same external IDs — the values update in place rather than
duplicating.
(`src/lib/jobs/apple-health-import-worker.ts`).
- XML intervals are not reconstructed or split. A record crossing
midnight is assigned using its mapped timestamp and remains covered
by the explicit estimate contract.
- A second import re-folds the same days onto the same external IDs.
Equal-authority estimates update idempotently rather than duplicate.
The terminal job result exposes
`cumulativeEstimates: { days, rows }`; the web import card warns when
one or more cumulative days used this estimate path.
### Re-import idempotency
The two-axis idempotency story:
1. **File-level** — re-uploading the same `export.zip` (same SHA-256
of bytes) short-circuits to the previous job before the parser
even starts.
of bytes) short-circuits to the previous job only when that job
used the current parser revision. Existing jobs are revision 1;
new jobs use revision 2, so a pre-fix successful archive can be
deliberately processed again under the corrected aggregation.
2. **Record-level** — when an older `export.zip` is re-exported from
iOS (same history, slightly newer device timestamps), every
record's external ID stays stable. The upsert path matches on
`(userId, externalId)` and updates instead of inserting.
record's external ID stays stable. The serialized reconciliation
path matches the canonical identity and updates instead of
inserting.
Practically: a user who exports monthly and re-imports each archive
sees fresh data merged in without duplicates. The job result line
@@ -150,14 +171,34 @@ is identical to the user-facing flow.
The `ImportJob` row carries `triggeredByAdminId = admin.id` so the
status endpoint admits both the target user and the triggering
admin. Idempotency is scoped by target user — an admin re-uploading
the same archive for the same user resolves to the previous job.
admin. Idempotency is scoped by target user and parser revision — an
admin re-uploading the same archive for the same user resolves to the
previous current-revision job.
Typical use: migrating a friend's history into their HealthLog
account when they cannot run the import themselves, or backfilling
a household member's data after the admin received the `export.zip`
out of band.
## Aggregate authority and recovery
Native HealthKit daily statistics submitted by the iOS batch endpoint
are canonical and carry `HEALTHKIT_STATISTICS` provenance. XML
source-maximum estimates carry `EXPORT_XML_SOURCE_MAX`. Both use the
same serialized reconciler with this ordering:
`LEGACY_UNKNOWN < EXPORT_XML_SOURCE_MAX < HEALTHKIT_STATISTICS`
A later XML import cannot overwrite native statistics. Native
statistics repair an XML estimate or legacy row in either arrival
order, and every material reconciled update or resurrection increments
`syncVersion`. Existing Apple Health `stats:*` rows are migrated to
`LEGACY_UNKNOWN`; they are never guessed or heuristically promoted.
To recover an already-corrupted legacy total, the user must re-upload
the original archive under parser revision 2 or sync native HealthKit
statistics. Without either input, the correct per-source total cannot
be reconstructed from the stored aggregate.
## Source-priority interaction
HealthLog's source ladder is **APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻
@@ -169,12 +210,12 @@ max). The defaults live in `src/lib/validations/source-priority.ts:205-220`.
Concrete consequences:
- If you already sync steps from a Withings ScanWatch and also import
Apple Health, the Apple-Health-aggregated stream wins for the day —
iOS HealthKit folds the watch sensor + iPhone motion data into a
single canonical stream, so it is the most complete cumulative
source. The Withings rows stay in the database as an audit trail
but drop out of the per-day aggregation.
- Native HealthKit cumulative statistics are the canonical Apple
Health stream for a day and outrank both XML estimates and Withings
cumulative rows. An XML upload chooses the largest source subtotal
only; it never claims to reproduce HealthKit's device de-duplication.
Lower-priority rows stay in the database as an audit trail but drop
out of the displayed per-day aggregation.
- If you weigh yourself on a Withings scale and also import Apple
Health (which received the same reading via the Health Mate iOS
app), the Withings row wins for display. Apple Health is
+3 -3
View File
@@ -84,9 +84,9 @@ pnpm dlx tsx scripts/restore-backup.ts \
/tmp/restored.json
```
The production standalone image strips `tsx`, so a bare
`pnpm tsx scripts/...` fails inside the container — always invoke
one-shot scripts via `pnpm dlx tsx`.
Run this command from a source checkout with the production backup variables
exported. The script imports the full application dependency graph, which the
minimal production image does not expose as an operator scripting environment.
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
+14 -15
View File
@@ -46,24 +46,23 @@ metadata (`medication_schedules`):
Always. The default mode only reports and never mutates:
```bash
# inside the app container (or any checkout) — DATABASE_URL must point
# at the target database
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
# inside the production app container — DATABASE_URL is inherited
healthlog-tsx scripts/repair-intake-anomalies.ts
# scoped to one account
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user <userId>
healthlog-tsx scripts/repair-intake-anomalies.ts --user <userId>
# source-checkout equivalent
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
```
The script is self-contained: its only import is `pg` and it reads
`DATABASE_URL` straight from the environment (no dotenv, no `@/` path
alias, no `src/` imports). That matters in the production container —
the standalone image ships no project `node_modules`, so the previous
revision died there with `Cannot find module 'dotenv/config'`. Inside
the image `pg` resolves via `NODE_PATH=/opt/pg-boss/node_modules`; in a
checkout it resolves from the project `node_modules`; the
`--package pg --package tsx` pins cover any environment with neither.
Use `pnpm dlx`, not bare `pnpm tsx` — the standalone image also strips
`tsx`.
alias, no `src/` imports). The production image provides the pinned
`healthlog-tsx` launcher and resolves `pg` from its standalone runtime tree
without retaining a package manager. In a source checkout, `pg` resolves from
the project `node_modules`; `pnpm dlx --package pg --package tsx ...` also
works in an environment where neither dependency is installed.
The dry-run prints every duplicate group (which row would be kept, which
would be tombstoned), a table of implausible rows (id, medication,
@@ -74,7 +73,7 @@ would write), and a table of stale-anchor pending rows.
## Applying the fix
```bash
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
healthlog-tsx scripts/repair-intake-anomalies.ts --fix
```
`--fix` does five things:
@@ -92,7 +91,7 @@ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
in explicitly:
```bash
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
healthlog-tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
```
`--tombstone-implausible` is refused without `--fix`.
@@ -139,7 +138,7 @@ slot anchors:
The default run only **reports** the proposals. Apply them explicitly:
```bash
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras
healthlog-tsx scripts/repair-intake-anomalies.ts --backfill-eras
```
Review the dry-run table first — the inference is a heuristic. A wrongly
+846 -114
View File
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -195,7 +195,11 @@ test.describe("achievements page renders all four card states", () => {
// Page heading present
await expect(
page.getByRole("heading", { name: "Achievements" }),
page.getByRole("main").getByRole("heading", {
name: "Achievements",
level: 1,
exact: true,
}),
).toBeVisible({ timeout: 5000 });
// Unlocked public — title visible
+218
View File
@@ -121,4 +121,222 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => {
await expect(page.locator(`[data-chain-row="${slug}"]`)).toBeVisible();
}
});
test("provider editors submit once by Enter or click and isolate connection tests", async ({
page,
}) => {
const patchBodies: Array<Record<string, unknown>> = [];
let releasePendingPatch: () => void = () => undefined;
let holdNextPatch = false;
let testConnectionRequests = 0;
let providerState: Record<string, unknown> = {
provider: null,
model: null,
baseUrl: null,
responseTimeoutSeconds: null,
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
};
await page.route("**/api/insights/settings", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
codexStatus: "disconnected",
codexConnectedAt: null,
hasAdminKey: false,
codexOauthConfigured: true,
privacyMode: "aggregated",
lastInsightAt: null,
},
error: null,
}),
}),
);
await page.route("**/api/user/ai-provider", async (route) => {
if (route.request().method() === "PATCH") {
const body = route.request().postDataJSON() as Record<string, unknown>;
patchBodies.push(body);
providerState = { ...providerState, ...body };
if (holdNextPatch) {
holdNextPatch = false;
await new Promise<void>((resolve) => {
releasePendingPatch = resolve;
});
}
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: providerState, error: null }),
});
});
await page.route("**/api/insights/provider-chain", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
activeProvider: "codex",
cachedActiveProvider: null,
configuredChain: [
{ providerType: "codex", available: true },
{ providerType: "openai", available: true },
{ providerType: "anthropic", available: true },
{ providerType: "local", available: true },
],
},
error: null,
}),
}),
);
await page.route("**/api/ai/test", (route) => {
testConnectionRequests += 1;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { ok: true, providerType: "local", model: "test-model" },
error: null,
}),
});
});
const providerHydrated = page.waitForResponse("**/api/user/ai-provider");
await page.goto("/settings/ai?provider=openai", {
waitUntil: "domcontentloaded",
});
await providerHydrated;
const select = page.getByTestId("ai-active-provider-select");
async function assertEnterAndClickSubmitOnce({
provider,
formTestId,
inputId,
enterValue,
clickValue,
expectedField,
}: {
provider: "openai" | "anthropic" | "local";
formTestId: string;
inputId: string;
enterValue: string;
clickValue: string;
expectedField: string;
}) {
await select.selectOption(provider);
const form = page.getByTestId(formTestId);
await expect(form).toHaveJSProperty("tagName", "FORM");
const input = page.locator(`#${inputId}`);
const save = form.locator('button[type="submit"]');
await expect(save).toHaveCount(1);
await input.fill(enterValue);
const enterBaseline = patchBodies.length;
await input.press("Enter");
await expect.poll(() => patchBodies.length).toBe(enterBaseline + 1);
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(enterBaseline + 1);
expect(patchBodies.at(-1)?.[expectedField]).toBe(enterValue);
await input.fill(clickValue);
const clickBaseline = patchBodies.length;
await save.click();
await expect.poll(() => patchBodies.length).toBe(clickBaseline + 1);
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(clickBaseline + 1);
expect(patchBodies.at(-1)?.[expectedField]).toBe(clickValue);
}
await assertEnterAndClickSubmitOnce({
provider: "openai",
formTestId: "ai-provider-config-openai",
inputId: "ai-openai-key",
enterValue: "sk-enter-openai",
clickValue: "sk-click-openai",
expectedField: "openaiKey",
});
await assertEnterAndClickSubmitOnce({
provider: "anthropic",
formTestId: "ai-provider-config-anthropic",
inputId: "ai-anthropic-key",
enterValue: "sk-ant-enter",
clickValue: "sk-ant-click",
expectedField: "anthropicKey",
});
await assertEnterAndClickSubmitOnce({
provider: "local",
formTestId: "ai-provider-config-local",
inputId: "ai-local-base-url",
enterValue: "http://localhost:11434/v1",
clickValue: "http://localhost:11435/v1",
expectedField: "baseUrl",
});
const timeoutForm = page.locator("form").filter({
has: page.locator("#ai-response-timeout"),
});
const timeoutInput = page.locator("#ai-response-timeout");
const timeoutSave = timeoutForm.locator('button[type="submit"]');
await expect(timeoutForm).toHaveCount(1);
await expect(timeoutSave).toHaveCount(1);
await timeoutInput.fill("9");
const invalidTimeoutBaseline = patchBodies.length;
await timeoutInput.press("Enter");
await expect(timeoutForm.locator("p.text-destructive")).toBeVisible();
expect(patchBodies).toHaveLength(invalidTimeoutBaseline);
await timeoutInput.fill("180");
const timeoutEnterBaseline = patchBodies.length;
await timeoutInput.press("Enter");
await expect.poll(() => patchBodies.length).toBe(timeoutEnterBaseline + 1);
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(timeoutEnterBaseline + 1);
expect(patchBodies.at(-1)?.responseTimeoutSeconds).toBe(180);
await timeoutInput.fill("240");
const timeoutClickBaseline = patchBodies.length;
await timeoutSave.click();
await expect.poll(() => patchBodies.length).toBe(timeoutClickBaseline + 1);
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(timeoutClickBaseline + 1);
expect(patchBodies.at(-1)?.responseTimeoutSeconds).toBe(240);
holdNextPatch = true;
await page.locator("#ai-local-base-url").fill("http://localhost:11436/v1");
const pendingBaseline = patchBodies.length;
await page
.getByTestId("ai-provider-config-local")
.evaluate((form: HTMLFormElement) => {
form.requestSubmit();
form.requestSubmit();
});
await expect.poll(() => patchBodies.length).toBe(pendingBaseline + 1);
await expect(
page
.getByTestId("ai-provider-config-local")
.locator('button[type="submit"]'),
).toBeDisabled();
await page.waitForTimeout(50);
expect(patchBodies).toHaveLength(pendingBaseline + 1);
const testConnection = page.getByTestId("ai-test-active-provider");
await expect(testConnection).toHaveAttribute("type", "button");
await testConnection.click();
await expect.poll(() => testConnectionRequests).toBe(1);
expect(patchBodies).toHaveLength(pendingBaseline + 1);
releasePendingPatch();
await expect(
page
.getByTestId("ai-provider-config-local")
.locator('button[type="submit"]'),
).toBeEnabled();
});
});
+115
View File
@@ -0,0 +1,115 @@
import { expect, test } from "@playwright/test";
import { setTimeout as delay } from "node:timers/promises";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
const CONVERSATION_ID = "conversation-rename-e2e";
const INITIAL_TITLE = "Morning check-in";
const RENAMED_TITLE = "Weekly health review";
test.describe("Coach conversation rename", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("renames by keyboard once and survives reload on desktop and mobile", async ({
page,
}) => {
let persistedTitle = INITIAL_TITLE;
let patchCount = 0;
await page.route(
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?|$)/,
async (route) => {
const request = route.request();
const url = new URL(request.url());
if (
request.method() === "PATCH" &&
url.pathname === `/api/insights/chat/${CONVERSATION_ID}`
) {
patchCount += 1;
const payload = JSON.parse(request.postData() ?? "{}") as {
title: string;
};
persistedTitle = payload.title;
await delay(150);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { id: CONVERSATION_ID, title: persistedTitle },
error: null,
}),
});
return;
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
conversations: [
{
id: CONVERSATION_ID,
title: persistedTitle,
createdAt: "2026-07-20T10:00:00.000Z",
updatedAt: "2026-07-20T10:00:00.000Z",
messageCount: 2,
fenced: false,
attachments: [],
documentTitle: null,
},
],
nextCursor: null,
},
error: null,
}),
});
},
);
await page.goto("/coach/conversations", {
waitUntil: "domcontentloaded",
});
const item = page.locator('[data-slot="coach-conversations-item"]');
await expect(item).toContainText(INITIAL_TITLE, { timeout: 10_000 });
const renameButton = item.locator(
'[data-slot="coach-conversation-rename"]',
);
await renameButton.click();
const input = item.locator('[data-slot="coach-conversation-rename-input"]');
await expect(input).toBeFocused();
await input.fill("Discard this draft");
await input.press("Escape");
await expect(input).toHaveCount(0);
await expect(item).toContainText(INITIAL_TITLE);
expect(patchCount).toBe(0);
await renameButton.click();
const activeInput = item.locator(
'[data-slot="coach-conversation-rename-input"]',
);
await activeInput.fill(` ${RENAMED_TITLE} `);
await activeInput.evaluate((element) => {
element.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
element.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
);
});
await expect(activeInput).toBeDisabled();
await expect(item).toContainText(RENAMED_TITLE);
await expect(activeInput).toHaveCount(0, { timeout: 10_000 });
expect(patchCount).toBe(1);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(
page.locator('[data-slot="coach-conversations-item"]'),
).toContainText(RENAMED_TITLE, { timeout: 10_000 });
expect(patchCount).toBe(1);
});
});
+7
View File
@@ -93,7 +93,14 @@ test.describe("settings AI codex device flow (mocked)", () => {
}),
);
// Hydration gate — the settings query is fired by the same client
// boundary that renders the Codex form, so its response proves React
// has attached the CTA's click handler. On mobile the SSR-painted button
// can otherwise be clicked before hydration and the event is lost.
const codexFormHydrated = page.waitForResponse("**/api/insights/settings");
await page.goto("/settings/ai", { waitUntil: "domcontentloaded" });
await codexFormHydrated;
// Step 1 — CTA visible
const cta = page.getByRole("button", {
+12 -5
View File
@@ -283,11 +283,18 @@ test.describe("document vault — AI assist + content search", () => {
await expect(hint).toBeVisible();
const indexAll = page.locator('[data-slot="content-index-all"]');
await expect(indexAll).toBeVisible();
await indexAll.click();
await expect(
page.getByText("Indexing 5 document(s) in the background."),
).toBeVisible();
const reindexRequest = page.waitForRequest(
(request) =>
request.method() === "POST" &&
new URL(request.url()).pathname === "/api/documents/inbound/reindex",
);
await Promise.all([
reindexRequest,
expect(
page.getByText("Indexing 5 document(s) in the background."),
).toBeVisible(),
indexAll.click(),
]);
expect(reindexCalls).toBe(1);
});
+158 -1
View File
@@ -99,6 +99,42 @@ async function runAxe(page: Page) {
return blocking;
}
async function mockDocumentCoach(page: Page): Promise<void> {
const fulfil = (
route: Parameters<Parameters<Page["route"]>[1]>[0],
data: unknown,
) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data, error: null }),
});
await page.route("**/api/documents/inbound/usage", (route) =>
fulfil(route, {
usedBytes: 4096,
quotaBytes: 1_073_741_824,
maxFileBytes: DEFAULT_MAX_FILE_BYTES,
acceptedExtensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"],
linkedEpisodes: [],
assistAvailable: true,
contentIndex: { enabled: true, indexedCount: 1, totalCount: 1 },
}),
);
await page.route("**/api/documents/inbound/capability", (route) =>
fulfil(route, {
available: true,
mode: "vision",
reason: null,
pdfSupported: true,
egress: "external",
}),
);
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
fulfil(route, { documentsAutoAiRead: false }),
);
}
test.describe("document vault", () => {
test.use({ storageState: STORAGE_STATE_PATH });
@@ -191,6 +227,121 @@ test.describe("document vault", () => {
await expect(page).not.toHaveURL(/doc=/);
});
test("ordinary card selection owns doc history and preserves unrelated query state", async ({
page,
}) => {
await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`);
await expect(openButton(page, "MRT Knie")).toBeVisible();
await openButton(page, "MRT Knie").click();
const detail = page.getByRole("dialog", { name: "MRT Knie" });
await expect
.poll(() => new URL(page.url()).searchParams.get("doc"))
.toBe(MRT_DOC_ID);
expect(new URL(page.url()).searchParams.get("episode")).toBe(
KNIE_EPISODE_ID,
);
expect(new URL(page.url()).searchParams.get("view")).toBe("compact");
expect(new URL(page.url()).searchParams.get("q")).toBe("MRT");
await page.goBack();
await expect(detail).not.toBeVisible();
expect(new URL(page.url()).searchParams.get("doc")).toBeNull();
expect(new URL(page.url()).searchParams.get("view")).toBe("compact");
await page.goForward();
await expect(detail).toBeVisible();
await page.keyboard.press("Escape");
await expect(detail).not.toBeVisible();
await expect
.poll(() => new URL(page.url()).searchParams.get("doc"))
.toBeNull();
expect(new URL(page.url()).searchParams.get("episode")).toBe(
KNIE_EPISODE_ID,
);
expect(new URL(page.url()).searchParams.get("view")).toBe("compact");
expect(new URL(page.url()).searchParams.get("q")).toBe("MRT");
});
test("Coach close and overlay dismissal consume the document handoff", async ({
page,
}) => {
await mockDocumentCoach(page);
await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`);
await expect(openButton(page, "MRT Knie")).toBeVisible();
await openButton(page, "MRT Knie").click();
await expect
.poll(() => new URL(page.url()).searchParams.get("doc"))
.toBe(MRT_DOC_ID);
const detail = page.getByRole("dialog", { name: "MRT Knie" });
await detail.locator('[data-slot="document-chat-open"]').click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible();
await expect(detail).not.toBeVisible();
await drawer.locator('[data-slot="coach-drawer-close"]').click();
await expect(drawer).not.toBeVisible();
await expect
.poll(() => new URL(page.url()).searchParams.get("doc"))
.toBeNull();
expect(new URL(page.url()).searchParams.get("episode")).toBe(
KNIE_EPISODE_ID,
);
expect(new URL(page.url()).searchParams.get("view")).toBe("compact");
expect(new URL(page.url()).searchParams.get("q")).toBe("MRT");
await expect(detail).not.toBeVisible();
await page.reload();
await expect(detail).not.toBeVisible();
expect(new URL(page.url()).searchParams.get("doc")).toBeNull();
await expect(openButton(page, "MRT Knie")).toBeVisible();
await openButton(page, "MRT Knie").click();
await expect(detail).toBeVisible();
await detail.locator('[data-slot="document-chat-open"]').click();
await expect(drawer).toBeVisible();
await page
.locator('[data-slot="sheet-overlay"]')
.last()
.click({ position: { x: 2, y: 2 } });
await expect(drawer).not.toBeVisible();
await expect
.poll(() => new URL(page.url()).searchParams.get("doc"))
.toBeNull();
await expect(detail).not.toBeVisible();
});
test("Ask Coach maximize then browser back restores the selected document", async ({
page,
}) => {
await mockDocumentCoach(page);
await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`);
await expect(openButton(page, "MRT Knie")).toBeVisible();
await openButton(page, "MRT Knie").click();
await expect
.poll(() => new URL(page.url()).searchParams.get("doc"))
.toBe(MRT_DOC_ID);
const detail = page.getByRole("dialog", { name: "MRT Knie" });
await detail.locator('[data-slot="document-chat-open"]').click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible();
await drawer.locator('[data-slot="coach-drawer-maximize"]').click();
await expect.poll(() => new URL(page.url()).pathname).toBe("/coach");
expect(new URL(page.url()).searchParams.get("doc")).toBe(MRT_DOC_ID);
await page.goBack();
await expect.poll(() => new URL(page.url()).pathname).toBe("/documents");
await expect(detail).toBeVisible();
const restoredUrl = new URL(page.url());
expect(restoredUrl.searchParams.get("doc")).toBe(MRT_DOC_ID);
expect(restoredUrl.searchParams.get("episode")).toBe(KNIE_EPISODE_ID);
expect(restoredUrl.searchParams.get("view")).toBe("compact");
expect(restoredUrl.searchParams.get("q")).toBe("MRT");
});
test("the condition page links into the vault and back", async ({ page }) => {
await page.goto(`/illness/${KNIE_EPISODE_ID}`);
const card = page.locator('[data-slot="episode-documents-card"]');
@@ -210,10 +361,16 @@ test.describe("document vault", () => {
await page.goto("/documents");
await uploadViaPicker(page, `${title}.pdf`, uniquePdf(title));
await page.getByRole("searchbox", { name: "Search documents" }).fill(title);
// The uploaded card is optimistic and can paint before the 200 ms search
// draft is committed to the URL. Opening it earlier races that pending
// replace against the detail's `?doc=` history entry and closes the sheet.
await expect
.poll(() => new URL(page.url()).searchParams.get("q"))
.toBe(title);
await expect(openButton(page, `${title}.pdf`)).toBeVisible();
await openButton(page, `${title}.pdf`).click();
const sheet = page.getByRole("dialog");
const sheet = page.getByRole("dialog", { name: `${title}.pdf` });
await expect(sheet).toBeVisible();
await sheet.getByRole("button", { name: "Delete" }).click();
+26 -9
View File
@@ -29,6 +29,12 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
}) => {
await page.goto("/insights", { waitUntil: "domcontentloaded" });
// A visible server-rendered link is not yet an interactive React link.
// Wait for this client component's hydration boundary before scrolling or
// clicking so the pointer gesture cannot race React replacing the anchor.
const tabStrip = page.locator("[data-slot='insights-tab-strip']");
await expect(tabStrip).toHaveAttribute("data-hydrated", "true");
// v1.25.1 — the app-wide scroll-to-top resets the `#main-content`
// scroll container on every route change (auth-shell), with a
// `window.scrollTo(0,0)` fallback for the body-scrolled shells. The
@@ -53,18 +59,24 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
window.scrollTo({ top: 600, behavior: "auto" });
}
});
await expect.poll(readScrollTop).toBeGreaterThan(100);
// Click the first available sub-page pill.
const subPagePill = page
.locator("[data-slot='insights-tab-strip-pill']")
.nth(1);
// Recovery is always present and remains a direct link across responsive
// pill grouping. Target its route contract instead of a mutable list index.
const subPagePill = tabStrip.locator(
"[data-slot='insights-tab-strip-pill'][href='/insights/recovery']",
);
await expect(subPagePill).toBeVisible();
await subPagePill.click();
await page.waitForURL(/\/insights\//);
await expect(page).toHaveURL(/\/insights\/recovery\/?$/);
// Back to the mother page via the overview pill (pill index 0).
await page.locator("[data-slot='insights-tab-strip-pill']").first().click();
await page.waitForURL(/\/insights\/?$/);
// Back to the mother page through the overview pill.
const overviewPill = tabStrip.locator(
"[data-slot='insights-tab-strip-pill'][href='/insights']",
);
await expect(overviewPill).toBeVisible();
await overviewPill.click();
await expect(page).toHaveURL(/\/insights\/?$/);
// The shell's route-change effect resets the scroll on the next tick;
// `useScrollResetOnRoute` defers to `requestAnimationFrame`. Wait for it
@@ -91,6 +103,12 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
}) => {
await page.goto("/insights", { waitUntil: "domcontentloaded" });
const nav = page.locator("[data-slot='insights-tab-strip']");
const innerScroller = nav.locator(
"[data-slot='insights-tab-strip-scroller']",
);
await expect(nav).toHaveAttribute("data-hydrated", "true");
await expect(innerScroller).toBeVisible();
const navOverflowX = await nav.evaluate(
(el) => getComputedStyle(el).overflowX,
);
@@ -100,7 +118,6 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
expect(navOverflowX).not.toBe("auto");
expect(navOverflowX).not.toBe("scroll");
const innerScroller = nav.locator("> div > div").first();
const innerOverflowX = await innerScroller.evaluate(
(el) => getComputedStyle(el).overflowX,
);
+3 -2
View File
@@ -20,8 +20,9 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.5.4 — Medication wizard end-to-end — daily cadence.
*
* Walks the 7-step daily path through the modal dialog. The dialog
* mounts when `/medications/new` redirects to `/medications?new=1`.
* Walks the 7-step daily path through the modal dialog. The shared helper
* opens `/medications?new=1` directly so cadence coverage is independent of
* the legacy `/medications/new` redirect.
* The DE label surface (the maintainer's primary locale) is the assertion
* surface — "Schritt N von 7" tracks the visible counter.
*/
+9 -6
View File
@@ -120,8 +120,9 @@ export async function stubDashboardAnalytics(page: Page): Promise<void> {
}
/**
* Open the wizard dialog by hitting `/medications/new` (which
* redirects to `/medications?new=1` and opens the create dialog).
* Open the wizard directly on `/medications?new=1`. The redirect from the
* legacy `/medications/new` route is a separate concern; these cadence specs
* exercise the wizard without racing that server navigation.
*/
export async function openCreateWizard(page: Page): Promise<void> {
// Pin the German locale for the wizard flow. The app default locale is
@@ -136,12 +137,14 @@ export async function openCreateWizard(page: Page): Promise<void> {
{
name: "healthlog-locale",
value: "de",
url: page.url().startsWith("http")
? new URL(page.url()).origin
: "http://localhost:3000",
url: new URL(process.env.E2E_BASE_URL ?? "http://localhost:3000").origin,
},
]);
await page.goto("/medications/new", { waitUntil: "domcontentloaded" });
await page.goto("/medications?new=1", { waitUntil: "load" });
await page.waitForURL(
(url) => url.pathname === "/medications" && !url.searchParams.has("new"),
{ timeout: 10_000 },
);
await expect(
page.locator('[data-slot="medication-wizard-dialog"]'),
).toBeVisible({ timeout: 10_000 });
+23
View File
@@ -33,6 +33,29 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
test.describe("medication wizard — one-shot", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("does not replay the one-shot URL flag after history traversal", async ({
page,
}) => {
await stubDashboardAnalytics(page);
await openCreateWizard(page);
await page.keyboard.press("Escape");
await expect(
page.locator('[data-slot="medication-wizard-dialog"]'),
).toHaveCount(0);
await page.locator('a[href="/"]:visible').first().click();
await page.waitForURL((url) => url.pathname === "/");
await page.goBack();
await page.waitForURL(
(url) => url.pathname === "/medications" && !url.searchParams.has("new"),
);
await expect(
page.locator('[data-slot="medication-wizard-dialog"]'),
).toHaveCount(0);
});
test("creates a single-dose medication end-to-end", async ({ page }) => {
test.slow();
await stubDashboardAnalytics(page);
+1 -1
View File
@@ -206,7 +206,7 @@ test.describe("mobile-viewport smoke", () => {
// 1) The center capture action opens the capture picker.
await page.getByTestId("bottom-nav-capture").click();
await expect(page.getByTestId("capture-picker-options")).toBeVisible();
for (const kind of ["measurement", "medication", "mood"]) {
for (const kind of ["measurement", "medication", "mood", "water"]) {
await expect(page.getByTestId(`capture-picker-${kind}`)).toBeVisible();
}
// Dismiss the picker before opening the hub.
+62
View File
@@ -0,0 +1,62 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
test.describe("mood timestamp capture", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("keeps Time visible while details are collapsed and submits the selected instant", async ({
page,
}) => {
let submitted: Record<string, unknown> | null = null;
await page.route("**/api/mood-entries*", async (route) => {
if (route.request().method() === "POST") {
submitted = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({ data: { id: "created-mood" }, error: null }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], meta: { total: 0, limit: 50, offset: 0 } },
error: null,
}),
});
});
await page.goto("/mood", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "Add", exact: true }).click();
await page.getByRole("radio", { name: "Amazing" }).click();
const details = page.getByRole("button", { name: "Note & details" });
await expect(details).toHaveAttribute("aria-expanded", "false");
const timestamp = page.locator('[data-slot="date-time-field"]');
await expect(timestamp).toBeVisible();
const dateInput = timestamp.locator(
'[data-slot="date-field"] input[type="text"]',
);
const timeInput = timestamp.locator(
'[data-slot="time-field"] input[type="text"]',
);
await dateInput.fill("2026-01-15");
await dateInput.blur();
await timeInput.fill("09:45");
await timeInput.blur();
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect.poll(() => submitted).not.toBeNull();
expect(submitted).toMatchObject({
mood: "SUPER_GUT",
moodLoggedAt: "2026-01-15T08:45:00.000Z",
});
});
});
+18
View File
@@ -94,9 +94,27 @@ test.describe("Settings → Export & Import", () => {
}) => {
// v1.18.0 (S5) — the panel (and its disclosure) live on the
// dedicated Gesundheitsakte section now.
await page.route("**/api/share-links", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: [], error: null, meta: null }),
});
});
const sharingDataReady = page.waitForResponse(
(response) =>
response.status() === 200 &&
response.request().method() === "GET" &&
new URL(response.url()).pathname === "/api/share-links",
);
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
await sharingDataReady;
const toggle = page.getByTestId("health-record-included-data-toggle");
await expect(toggle).toBeVisible({ timeout: 10_000 });
// Collapsed on first render — the checklist panel is absent.
+41
View File
@@ -198,3 +198,44 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
}
});
});
test.describe("Apple Health guidance", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("opens on Integrations and reaches the one-shot import fallback", async ({
page,
}) => {
await page.route("**/api/integrations/healthkit", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], lastSyncedAt: null },
error: null,
}),
}),
);
await page.goto("/settings/integrations");
const card = page.getByTestId("apple-health-card");
await expect(card).toBeVisible();
await expect(card).toContainText("HealthLog iOS app");
await expect(card).toContainText("not a web or OAuth connection");
await expect(page.getByTestId("apple-health-status")).toHaveAttribute(
"data-state",
"setup",
);
const importLink = page.getByTestId("apple-health-import-link");
await expect(importLink).toHaveAttribute(
"href",
"/settings/export#settings-section-import-title",
);
await page.goto("/settings/export#settings-section-import-title");
await expect(page).toHaveURL(
/\/settings\/export#settings-section-import-title$/,
);
await expect(page.getByTestId("import-card-apple-health")).toBeVisible();
});
});
+21 -3
View File
@@ -97,6 +97,8 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// that tries to click a header / sidebar / quick-add button times
// out (50+ failed CI runs since v1.4.13). Specs that need the
// tour can opt back in by mocking `/api/auth/me`.
// Nutrients is intentionally opt-in in production. The shared fixture
// enables it because the water-capture specs exercise that real gate.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role,
@@ -105,14 +107,16 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
codex_connection_status,
insights_privacy_mode,
onboarding_completed_at,
onboarding_tour_completed)
onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, $5,
$6, $6,
180, $7, 'MALE',
'disconnected',
'aggregated',
$6,
true)
true,
'{"nutrients":true}'::jsonb)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
@@ -129,7 +133,8 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
insights_cached_at = NULL,
insights_cached_text = NULL,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = EXCLUDED.module_preferences_json`,
[
cuid(),
E2E_USER.username,
@@ -141,6 +146,19 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
],
);
// Repeated local E2E runs reuse the same seeded account and database.
// Its owner-scoped share-link bucket lasts an hour, so otherwise the third
// run can start above the 20-operation ceiling and fail before exercising
// the share flow. Reset only this fixture user's bucket; never touch
// unrelated rate-limit state in a developer database.
await pool.query(
`DELETE FROM rate_limits
WHERE key = 'share-link:' || (
SELECT id::text FROM users WHERE username = $1
)`,
[E2E_USER.username],
);
// Opt the seed account into cycle tracking. The user is seeded as
// `MALE`, and v1.18.0 resolves the `cycle` module through
// `isCycleEnabled(gender, CycleProfile)` — NULL `cycleTrackingEnabled`
+21 -4
View File
@@ -57,7 +57,14 @@ interface CreatedShare {
async function createShareWithDocs(page: Page): Promise<CreatedShare> {
const label = `${SHARE_DOC_PREFIX} ${Date.now()}`;
const sharingDataReady = page.waitForResponse(
(response) =>
response.status() === 200 &&
response.request().method() === "GET" &&
new URL(response.url()).pathname === "/api/share-links",
);
await page.goto("/settings/sharing");
await sharingDataReady;
await page.locator("#share-label").fill(label);
// Open the document picker and isolate the seeded trio.
@@ -69,13 +76,18 @@ async function createShareWithDocs(page: Page): Promise<CreatedShare> {
// Exactly the three share-fixture documents match the prefix.
const rows = list.getByRole("button");
await expect(rows).toHaveCount(3);
for (let i = 0; i < 3; i++) await rows.nth(i).click();
for (let i = 0; i < 3; i++) {
const row = rows.nth(i);
await row.click();
await expect(row).toHaveAttribute("aria-pressed", "true");
}
await page.getByRole("button", { name: "Done" }).click();
// Three chips staged before create.
await expect(
page.getByTestId("share-attached-chips").getByRole("listitem"),
).toHaveCount(3);
await expect(page.getByRole("button", { name: "Create link" })).toBeEnabled();
await page.getByRole("button", { name: "Create link" }).click();
@@ -150,9 +162,14 @@ test.describe("clinician document sharing", () => {
clinician.getByRole("heading", { name: "Documents" }),
).toBeVisible();
// Class A image → inline <img> at the token-scoped serve route.
await expect(
clinician.locator(`img[src*="/d/${SHARE_JPEG_DOC_ID}"]`),
).toBeVisible();
const inlineImage = clinician.getByRole("img", {
name: "Share e2e scan",
});
await expect(inlineImage).toBeVisible();
await expect(inlineImage).toHaveAttribute(
"src",
new RegExp(`/d/${SHARE_JPEG_DOC_ID}$`),
);
// Class A PDF → inline <iframe> at the same route family.
await expect(
clinician.locator(`iframe[src*="/d/${SHARE_PDF_DOC_ID}"]`),
+169 -14
View File
@@ -5,21 +5,14 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.27 MB6 — `/measurements?add=<TYPE>` consumer.
*
* The insights empty-state CTAs link here with a `?add=<TYPE>` query
* param. The page reads the param during render, opens the
* Add-Measurement primitive, pre-selects the matching type, then
* `router.replace("/measurements")` strips the query so the back
* button drops the user on the bare list instead of re-opening the
* dialog.
* Insight metric actions link here with `?add=<TYPE>` and an optional,
* allowlisted `returnTo`. The page opens the responsive capture primitive,
* pre-selects the matching type, and strips the query without losing the
* one-shot return context.
*
* Three contracts under test:
* 1. The primitive opens on first paint.
* 2. The form's type combobox shows the requested type.
* 3. The URL settles on `/measurements` (no `?add=`).
*
* Pixel 5 is the load-bearing project — every CTA hop originates from
* the mobile-first empty-state surfaces. Desktop Chrome covers the
* regression path through the dashboard quick-entry.
* The browser contract covers the populated-page action, legacy preselection,
* failure retention, success return, and rejection of an external destination.
* Playwright runs every case in both the desktop Chromium and Pixel 5 projects.
*/
test.describe("v1.4.27 — /measurements?add= consumer", () => {
test.use({ storageState: STORAGE_STATE_PATH });
@@ -49,6 +42,57 @@ test.describe("v1.4.27 — /measurements?add= consumer", () => {
);
});
test("a populated metric action opens the matching preselected capture", async ({
page,
}) => {
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
summaries: {
WEIGHT: {
latest: 78.4,
count: 4,
min: 77.9,
max: 78.8,
mean: 78.3,
median: 78.25,
},
},
bpInTargetPct: null,
glucoseByContext: {},
},
error: null,
}),
}),
);
await page.goto("/insights/weight", { waitUntil: "domcontentloaded" });
// The action is server-rendered before React can handle client navigation.
// Pin the click to the Insights surface's existing hydration contract so
// it cannot race hydration and get replayed as a no-op.
await expect(
page.locator('[data-slot="insights-tab-strip"]'),
).toHaveAttribute("data-hydrated", "true");
const capture = page.locator('[data-slot="metric-add-reading"]');
await expect(capture).toBeVisible();
await expect(capture).toHaveAttribute(
"href",
"/measurements?add=WEIGHT&returnTo=%2Finsights%2Fweight",
);
await capture.click();
await expect(page).toHaveURL(/\/measurements(?:\?|$)/);
const content = page.locator('[data-slot="responsive-sheet-content"]');
await expect(content).toBeVisible();
await expect(content.getByRole("combobox").first()).toContainText(
/weight|gewicht/i,
);
});
for (const { param, label } of [
{ param: "WEIGHT", label: /weight|gewicht/i },
{ param: "BLOOD_PRESSURE", label: /blood pressure|blutdruck/i },
@@ -76,6 +120,117 @@ test.describe("v1.4.27 — /measurements?add= consumer", () => {
});
}
test("keeps the one-shot sheet and entered values when the write fails", async ({
page,
}) => {
await page.route("**/api/measurements*", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ data: null, error: "Write failed" }),
});
return;
}
await route.fallback();
});
await page.goto("/measurements?add=WEIGHT&returnTo=%2Finsights%2Fweight", {
waitUntil: "domcontentloaded",
});
const content = page.locator('[data-slot="responsive-sheet-content"]');
await expect(content).toBeVisible();
await expect(content.getByRole("combobox").first()).toContainText(
/weight|gewicht/i,
);
await content.locator("#value").fill("78.4");
await content.locator("#notes").fill("Keep this note");
await page
.getByRole("button", { name: /save|speichern/i })
.last()
.click();
await expect(content.getByRole("alert")).toBeVisible();
await expect(content.locator("#value")).toHaveValue("78.4");
await expect(content.locator("#notes")).toHaveValue("Keep this note");
await expect(content).toBeVisible();
expect(new URL(page.url()).pathname).toBe("/measurements");
});
test("returns to the validated metric route only after a successful write", async ({
page,
}) => {
await page.route("**/api/measurements*", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { id: "contextual-e2e", success: true },
error: null,
}),
});
return;
}
await route.fallback();
});
await page.goto("/measurements?add=WEIGHT&returnTo=%2Finsights%2Fweight", {
waitUntil: "domcontentloaded",
});
const content = page.locator('[data-slot="responsive-sheet-content"]');
await expect(content.getByRole("combobox").first()).toContainText(
/weight|gewicht/i,
);
await content.locator("#value").fill("78.4");
await Promise.all([
page.waitForURL((url) => url.pathname === "/insights/weight"),
page
.getByRole("button", { name: /save|speichern/i })
.last()
.click(),
]);
});
test("rejects an external return target after a successful write", async ({
page,
}) => {
await page.route("**/api/measurements*", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { id: "contextual-e2e", success: true },
error: null,
}),
});
return;
}
await route.fallback();
});
await page.goto(
"/measurements?add=WEIGHT&returnTo=https%3A%2F%2Fevil.example%2Fsteal",
{ waitUntil: "domcontentloaded" },
);
const appOrigin = new URL(page.url()).origin;
const content = page.locator('[data-slot="responsive-sheet-content"]');
await content.locator("#value").fill("78.4");
await page
.getByRole("button", { name: /save|speichern/i })
.last()
.click();
await expect(content).toHaveCount(0);
const settled = new URL(page.url());
expect(settled.origin).toBe(appOrigin);
expect(settled.pathname).toBe("/measurements");
});
test("unknown ?add value is dropped silently and the page renders empty", async ({
page,
}) => {
+144
View File
@@ -0,0 +1,144 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
mockDashboardSnapshot,
WEIGHT_ONLY_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
test.describe("water capture", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("opens globally, waits for success, and retains failed input", async ({
page,
}, testInfo) => {
await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES });
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} },
error: null,
}),
}),
);
await page.route("**/api/mood/analytics", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], summary: { count: 0 } },
error: null,
}),
}),
);
let writeCount = 0;
let confirmSuccess: (() => void) | undefined;
const successConfirmed = new Promise<void>((resolve) => {
confirmSuccess = resolve;
});
await page.route("**/api/nutrients/water", async (route) => {
writeCount += 1;
if (writeCount === 1) {
await successConfirmed;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: { amountMl: 250 }, error: null }),
});
return;
}
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ data: null, error: "write failed" }),
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
const openWater = async (toastMayOverlap = false) => {
if (testInfo.project.name === "chromium-mobile") {
const capture = page.getByTestId("bottom-nav-capture");
if (toastMayOverlap) {
await capture.dispatchEvent("click");
} else {
await capture.click();
}
const option = page.getByTestId("capture-picker-water");
await expect(option).toBeVisible();
const box = await option.boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
if (toastMayOverlap) {
await option.dispatchEvent("click");
} else {
await option.click();
}
} else {
await page.locator('[data-tour-id="dashboard-quick-add"]').click();
await page.getByRole("menuitem", { name: "Log water" }).click();
}
await expect(
page.getByRole("heading", { name: "Add water" }),
).toBeVisible();
if (testInfo.project.name === "chromium-mobile") {
const actions = page
.getByRole("dialog")
.locator(
'[data-slot="water-quick-add-chips"] button, form button[type="submit"]',
);
await expect(actions).toHaveCount(4);
for (const action of await actions.all()) {
await expect(action).toHaveAccessibleName(/.+/);
const actionBox = await action.boundingBox();
expect(actionBox?.height ?? 0).toBeGreaterThanOrEqual(44);
}
}
};
await openWater();
const amount = page.getByRole("spinbutton", {
name: "Custom amount (mL)",
});
await amount.fill("250");
const submit = page
.getByRole("dialog")
.getByRole("button", { name: "Add", exact: true });
await amount.press("Enter");
await expect(submit).toBeDisabled();
await expect(amount).toHaveValue("250");
await expect(page.getByText("250 mL water added.")).toHaveCount(0);
confirmSuccess?.();
await expect(page.getByText("250 mL water added.")).toBeVisible();
await expect(page.getByRole("heading", { name: "Add water" })).toHaveCount(
0,
);
expect(writeCount).toBe(1);
await openWater(true);
const failedAmount = page.getByRole("spinbutton", {
name: "Custom amount (mL)",
});
await expect(failedAmount).toHaveValue("");
await failedAmount.fill("375");
await page
.getByRole("dialog")
.getByRole("button", { name: "Add", exact: true })
.click();
await expect(
page.getByText("Couldn't save that — try again."),
).toBeVisible();
await expect(failedAmount).toHaveValue("375");
await expect(
page.getByRole("heading", { name: "Add water" }),
).toBeVisible();
await expect(page.getByText("375 mL water added.")).toHaveCount(0);
expect(writeCount).toBe(2);
});
});
+140
View File
@@ -0,0 +1,140 @@
import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
interface MockWorkout {
id: string;
sportType: string;
startedAt: string;
endedAt: string;
durationSec: number;
distanceM: null;
activeEnergyKcal: null;
avgHr: null;
maxHr: null;
source: string;
externalId: string;
}
function workout(id: number): MockWorkout {
return {
id: `workout-${id}`,
sportType: "running",
startedAt: "2026-07-20T08:00:00.000Z",
endedAt: "2026-07-20T08:30:00.000Z",
durationSec: 1800,
distanceM: null,
activeEnergyKcal: null,
avgHr: null,
maxHr: null,
source: "APPLE_HEALTH",
externalId: `external-${id}`,
};
}
test.describe("workout history pagination", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("loads canonical rows beyond 100 once without duplicate ids", async ({
page,
}) => {
const firstPage = Array.from({ length: 100 }, (_, index) =>
workout(index + 1),
);
const secondPage = [
workout(100),
...Array.from({ length: 99 }, (_, index) => workout(index + 101)),
];
const thirdPage = [workout(200), workout(201)];
const observedOffsets: number[] = [];
let releaseFirstPage!: () => void;
const firstPageGate = new Promise<void>((resolve) => {
releaseFirstPage = resolve;
});
let releaseSecondPage!: () => void;
const secondPageGate = new Promise<void>((resolve) => {
releaseSecondPage = resolve;
});
await page.route(/\/api\/workouts(?:\?|$)/, async (route) => {
const url = new URL(route.request().url());
const limit = Number(url.searchParams.get("limit") ?? "50");
const offset = Number(url.searchParams.get("offset") ?? "0");
if (limit !== 100) {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
data: {
workouts: firstPage.slice(0, limit),
meta: {
total: 201,
limit,
offset,
droppedDuplicates: 0,
},
},
error: null,
}),
});
return;
}
observedOffsets.push(offset);
if (offset === 0) await firstPageGate;
if (offset === 100) await secondPageGate;
const workouts =
offset === 0 ? firstPage : offset === 100 ? secondPage : thirdPage;
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
data: {
workouts,
meta: {
total: 201,
limit,
offset,
droppedDuplicates: 0,
},
},
error: null,
}),
});
});
await page.goto("/insights/workouts");
await expect(page.locator('[data-slot="workouts-loading"]')).toBeVisible();
await expect(page.locator('[data-slot="workout-list-row"]')).toHaveCount(0);
releaseFirstPage();
const rows = page.locator('[data-slot="workout-list-row"]');
await expect(rows).toHaveCount(100);
await expect(page.locator('[data-slot="workouts-loading"]')).toHaveCount(0);
const loadMore = page.getByRole("button", { name: "Load more workouts" });
await loadMore.click();
await expect(
page.getByRole("button", { name: "Loading more workouts" }),
).toBeDisabled();
releaseSecondPage();
await expect(
page.locator('a[href="/insights/workouts/workout-101"]'),
).toHaveCount(1);
await expect(
page.locator('a[href="/insights/workouts/workout-100"]'),
).toHaveCount(1);
await expect(rows).toHaveCount(199);
await page.getByRole("button", { name: "Load more workouts" }).click();
await expect(
page.locator('a[href="/insights/workouts/workout-201"]'),
).toHaveCount(1);
await expect(rows).toHaveCount(201);
await expect(
page.getByRole("button", { name: "Load more workouts" }),
).toHaveCount(0);
expect(observedOffsets).toEqual([0, 100, 200]);
});
});
@@ -0,0 +1,72 @@
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import rule from "../api-fetch-required.js";
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2023,
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
ruleTester.run("api-fetch-required", rule, {
valid: [
{
code: "apiGet(endpoint); apiFetchRaw(externalUrl);",
filename: "/repo/src/components/example.tsx",
},
{
code: 'fetch("/api/server-prefetch");',
filename: "/repo/src/app/server-page.tsx",
},
{
code: 'fetch("https://example.test/data");',
filename: "/repo/src/lib/server-only.ts",
},
{
code: "fetch(path, init);",
filename: "/repo/src/lib/api/api-fetch.ts",
},
{
code: 'fetch("/api/mock");',
filename: "/repo/src/components/__tests__/example.test.tsx",
},
],
invalid: [
{
code: "fetch(endpoint);",
filename: "/repo/src/components/example.tsx",
errors: [{ messageId: "rawApiFetch" }],
},
{
code: "window.fetch(`/api/${id}`);",
filename: "/repo/src/components/example.tsx",
errors: [{ messageId: "rawApiFetch" }],
},
{
code: 'globalThis.fetch("https://example.test/data");',
filename: "/repo/src/hooks/use-example.ts",
errors: [{ messageId: "rawApiFetch" }],
},
{
code: '"use client"; self.fetch(url);',
filename: "/repo/src/app/client-page.tsx",
errors: [{ messageId: "rawApiFetch" }],
},
{
code: '"use client"; fetch(endpoint);',
filename: "/repo/src/lib/i18n/context.tsx",
errors: [{ messageId: "rawApiFetch" }],
},
{
code: 'window["fetch"](endpoint);',
filename: "/repo/src/components/example.tsx",
errors: [{ messageId: "rawApiFetch" }],
},
],
});
@@ -0,0 +1,64 @@
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import plugin from "../queryKey-factory.js";
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2023,
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
const rule = plugin.rules["queryKey-factory"];
ruleTester.run("queryKey-factory", rule, {
valid: [
{
code: "useQuery({ queryKey: queryKeys.example(id) });",
filename: "/repo/src/components/example.tsx",
},
{
code: "const key = queryKeys.example(id); useQuery({ queryKey: key });",
filename: "/repo/src/hooks/use-example.ts",
},
{
code: 'export const example = () => ["example"];',
filename: "/repo/src/lib/query-keys/example.ts",
},
{
code: 'useQuery({ queryKey: ["fixture"] });',
filename: "/repo/src/components/__tests__/example.test.tsx",
},
{
code: 'useQuery({ queryKey: ["server-prefetch"] });',
filename: "/repo/src/app/server-page.tsx",
},
],
invalid: [
{
code: 'useQuery({ queryKey: ["unguarded-component"] });',
filename: "/repo/src/components/custom-metrics/example.tsx",
errors: [{ messageId: "bareArray" }],
},
{
code: 'useMutation({ mutationKey: ["unguarded-hook"] });',
filename: "/repo/src/hooks/use-example.ts",
errors: [{ messageId: "bareArray" }],
},
{
code: '"use client"; useQuery({ queryKey: ["client-app"] });',
filename: "/repo/src/app/previously-unguarded/page.tsx",
errors: [{ messageId: "bareArray" }],
},
{
code: '"use client"; useQuery({ queryKey: ["client-lib"] });',
filename: "/repo/src/lib/client-state.tsx",
errors: [{ messageId: "bareArray" }],
},
],
});
+39 -66
View File
@@ -1,35 +1,24 @@
/**
* @fileoverview ESLint rule — client-side `/api/...` calls must route
* through the typed `apiFetch` wrapper.
* @fileoverview ESLint rule — client-side requests must route through the
* typed API-fetch entry points.
*
* `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.
* A raw `fetch(...)` in a client module can hide a same-origin API request
* behind a variable, endpoint map, or conditional. Checking only literal
* `/api/...` arguments therefore leaves the envelope and error contract open
* to bypass. This rule rejects every bare Fetch API call in the client surface,
* regardless of argument shape.
*
* 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.
* Use `apiGet` / `apiPost` / `apiPut` / `apiPatch` / `apiDelete` for envelope
* JSON. Use `apiFetchEnvelope` when success metadata is required, and
* `apiFetchRaw` for deliberate Response-level work such as external requests,
* downloads, streams, beacons, or manual status branching.
*
* 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.
* Components and hooks are client-facing by convention. Other `src/` modules
* are guarded when they declare top-level `"use client"`; `src/lib/queries/`
* remains an explicit client-transitive root. Server-only modules, tests,
* mocks, and the wrapper implementation are exempt.
*
* @see src/lib/api/api-fetch.ts
* @see eslint-plugins/healthlog/safe-fetch-required.js
*/
"use strict";
@@ -39,20 +28,9 @@
// `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. `src/lib/queries/` is the
// one `src/lib/` exception: it holds client-only TanStack-Query hooks
// (every file is `"use client"`), so its `/api/` reads route through the
// wrapper too.
const ENFORCED_ROOTS = [
"src/components/",
"src/app/",
"src/hooks/",
"src/lib/queries/",
];
// Components and hooks are client-facing by convention. Query modules are
// client-transitive even if a future refactor moves the directive to a barrel.
const CLIENT_ROOTS = ["src/components/", "src/hooks/", "src/lib/queries/"];
function toPosix(filename) {
return filename.replace(/\\/g, "/");
@@ -67,12 +45,17 @@ function isTestFile(posix) {
);
}
function isEnforced(filename) {
function isEnforced(filename, sourceCode) {
const posix = toPosix(filename);
if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false;
if (!posix.startsWith("src/") && !posix.includes("/src/")) return false;
if (EXEMPT_FILES.some((f) => posix.includes(f))) return false;
if (isTestFile(posix)) return false;
return true;
if (CLIENT_ROOTS.some((root) => posix.includes(root))) return true;
return sourceCode.ast.body.some(
(statement) =>
statement.type === "ExpressionStatement" &&
statement.directive === "use client",
);
}
/** @type {import("eslint").Rule.RuleModule} */
@@ -81,49 +64,40 @@ const apiFetchRequiredRule = {
type: "problem",
docs: {
description:
"Same-origin /api/ calls must route through the apiFetch wrapper (src/lib/api/api-fetch.ts).",
"Client-side requests must route through the apiFetch wrapper family (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.',
"Raw fetch() bypasses the client request contract. Use an apiFetch envelope helper, or apiFetchRaw for deliberate Response-level/external requests.",
},
},
create(context) {
const filename = context.filename ?? context.getFilename?.();
if (!filename || !isEnforced(filename)) {
const sourceCode = context.sourceCode ?? context.getSourceCode();
if (!filename || !isEnforced(filename, sourceCode)) {
return {};
}
function isFetchCallee(callee) {
// Bare `fetch(...)`.
if (callee.type === "Identifier" && callee.name === "fetch") return true;
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`.
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`,
// including their static computed forms (`window["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 ?? "");
const propertyName =
!callee.computed && callee.property.type === "Identifier"
? callee.property.name
: callee.computed && callee.property.type === "Literal"
? callee.property.value
: null;
return propertyName === "fetch";
}
return false;
}
@@ -131,7 +105,6 @@ const apiFetchRequiredRule = {
return {
CallExpression(node) {
if (!isFetchCallee(node.callee)) return;
if (!isApiPath(node.arguments[0])) return;
context.report({ node, messageId: "rawApiFetch" });
},
};
+43 -81
View File
@@ -1,96 +1,57 @@
/**
* @fileoverview ESLint rule — `queryKey` / `mutationKey` literal-array
* factory enforcement.
* factory enforcement across the complete client tree.
*
* v1.4.41 W-PROCESS-DOCS — promoted from the test-guard substitute
* (`src/lib/__tests__/query-keys.test.ts`, factory-bypass guard added
* in v1.4.40 W-RSC) to a real ESLint rule. The test-guard substitute
* stays in place as a belt-and-braces safety net for repo-walk
* coverage of files the ESLint config might miss; this rule fires at
* authoring time inside the IDE and at lint CI time.
* Every TanStack key must come from `src/lib/query-keys/`. The rule flags
* object-property `queryKey: [ … ]` and `mutationKey: [ … ]` values in
* components, hooks, client query modules, and any source module declaring
* top-level `"use client"`.
*
* The rule flags any object-property `queryKey: [ … ]` or
* `mutationKey: [ … ]` whose value is a bare ArrayExpression — i.e.
* the bypass shape that drove audit-H1 in `.planning/round-v1439-
* arch-qa-frontend.md`. Acceptable shapes:
* Accepted call-site shapes include direct factory calls, identifiers already
* built from the factory, and conditionals selecting factory calls. Tests and
* the factory definition directory are exempt because they intentionally
* construct literal fixtures and tuples.
*
* - `queryKey: queryKeys.something(arg)` — factory call
* - `queryKey: someLocal` — indirect identifier
* - `queryKey: cond ? queryKeys.a() : queryKeys.b()` — conditional via factory
*
* Files outside `WHITELISTED_FILES` are exempt entirely — the
* 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/ — 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
* @see src/lib/query-keys/
* @see src/lib/__tests__/query-keys.test.ts
*/
"use strict";
// Files that are ALLOWED to declare a literal-array queryKey. Mirrors
// the test-guard substitute's `guardedRoots` allowlist inverted: every
// file under those roots MUST use the factory, every file outside is
// 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'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 = [
"src/components/charts",
"src/components/comparison",
// v1.4.41 W-FRONTEND-FACTORY — auth + notifications migrated to the
// factory. Mirror the test-guard's guardedRoots so IDE/lint CI fail
// at the same boundary the unit-test walker does.
"src/app/auth",
"src/app/notifications",
// v1.4.42 W3-QUERYKEY-LONGTAIL — the settings / medications /
// admin / hooks surface is now factory-only. Every long-tail
// bare-literal flagged by audit-H1 routes through `queryKeys.<entry>()`.
"src/components/settings",
"src/components/medications",
"src/components/admin",
"src/hooks",
];
// Components and hooks are client-facing by convention. Query modules are
// client-transitive even if a future refactor moves the directive to a barrel.
const CLIENT_ROOTS = ["src/components/", "src/hooks/", "src/lib/queries/"];
// Individual guarded files outside the directories above.
const GUARDED_FILES = [
"src/app/page.tsx",
"src/hooks/use-auth.ts",
// v1.4.41 W-FRONTEND-FACTORY — about-section migrated.
"src/components/settings/about-section.tsx",
// v1.4.42 W3-QUERYKEY-LONGTAIL — medications + targets pages now
// route every read through the factory.
"src/app/medications/page.tsx",
"src/app/medications/[id]/history/page.tsx",
"src/app/targets/page.tsx",
];
function toPosix(filename) {
return filename.replace(/\\/g, "/");
}
function isGuarded(filename) {
// Factory home is never an offender even if it shows up under a
// guarded directory by accident.
for (const home of FACTORY_HOME_FILES) {
if (filename.endsWith(home)) return false;
function isTestFile(posix) {
return (
/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(posix) ||
posix.includes("/__tests__/") ||
posix.includes("/__mocks__/")
);
}
function isGuarded(filename, sourceCode) {
const posix = toPosix(filename);
if (!posix.startsWith("src/") && !posix.includes("/src/")) return false;
if (
posix.startsWith(`${FACTORY_HOME_DIRECTORY}/`) ||
posix.includes(`/${FACTORY_HOME_DIRECTORY}/`)
) {
return false;
}
if (filename.includes(`/${FACTORY_HOME_DIRECTORY}/`)) return false;
for (const dir of GUARDED_DIRECTORIES) {
if (filename.includes(`/${dir}/`)) return true;
}
for (const file of GUARDED_FILES) {
if (filename.endsWith(file)) return true;
}
return false;
if (isTestFile(posix)) return false;
if (CLIENT_ROOTS.some((root) => posix.includes(root))) return true;
return sourceCode.ast.body.some(
(statement) =>
statement.type === "ExpressionStatement" &&
statement.directive === "use client",
);
}
/** @type {import('eslint').Rule.RuleModule} */
@@ -99,7 +60,7 @@ const queryKeyFactoryRule = {
type: "problem",
docs: {
description:
"Disallow literal-array `queryKey` / `mutationKey` declarations in guarded files. Use `queryKeys.<entry>()` from `src/lib/query-keys/` instead.",
"Disallow literal-array `queryKey` / `mutationKey` declarations in client modules. Use `queryKeys.<entry>()` from `src/lib/query-keys/` instead.",
recommended: false,
},
messages: {
@@ -110,7 +71,8 @@ const queryKeyFactoryRule = {
},
create(context) {
const filename = context.filename ?? context.getFilename?.();
if (!filename || !isGuarded(filename)) {
const sourceCode = context.sourceCode ?? context.getSourceCode();
if (!filename || !isGuarded(filename, sourceCode)) {
return {};
}
+15 -15
View File
@@ -13,22 +13,22 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
// v1.4.41 — ignore local tool worktrees so a worktree on an older
// commit can't poison `pnpm lint` locally.
// CI never sees this path; the rule is for the dev environment.
// v1.31.4 — ignore local tool worktrees so a worktree on another
// commit cannot poison `pnpm lint` locally.
// CI never sees these paths; the rules protect the dev environment.
".claude/**",
".worktrees/**",
// The project-local ESLint rule plugin is CommonJS (require/module
// .exports) by ESLint convention; it is not application source and
// is not linted by the app's TypeScript ruleset.
"eslint-plugins/**",
"coverage/**",
"playwright-report/**",
"test-results/**",
]),
// v1.4.41 — custom rule that flags any bare-array
// `queryKey: [ … ]` / `mutationKey: [ … ]` declaration inside the
// files the queryKeys factory currently guards. Promotes the earlier
// v1.4.40 test-guard substitute to a real ESLint rule so IDE + CI
// both fail fast on a factory bypass. See
// `eslint-plugins/healthlog/queryKey-factory.js` for the
// whitelist + rationale.
// Factory enforcement for every client module: all components/hooks plus
// `"use client"` app/lib modules. Tests and `src/lib/query-keys/**` own
// literal fixtures/tuples and are exempt inside the rule.
{
plugins: { healthlog: healthlogPlugin },
rules: {
@@ -38,11 +38,11 @@ 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.
// Every raw fetch in the complete client surface is rejected regardless
// of whether the endpoint is a literal, template, variable, or map
// lookup. Envelope JSON uses the verb helpers; deliberate Response-level
// work and non-API external calls use the explicit `apiFetchRaw` escape
// hatch. Server-only modules, wrapper internals, and tests are exempt.
"healthlog/api-fetch-required": "error",
// v1.26.0 — the design-consistency wave locked the app's colour
// vocabulary onto semantic tokens. Raw Tailwind palette utilities
+95 -6
View File
@@ -46,6 +46,67 @@
"durationHoursMinutes": "{hours} Std. {minutes} Min.",
"durationMinutes": "{minutes} Min."
},
"apiErrors": {
"measurement": {
"create": { "invalid": "Prüfe die Messdaten und versuche es erneut." },
"duplicate_timestamp": "Für diesen Zeitpunkt ist bereits eine Messung vorhanden."
},
"mood": {
"list": { "invalid": "Die Filter der Stimmungsliste sind ungültig." },
"create": {
"invalid": "Prüfe den Stimmungseintrag und versuche es erneut."
},
"update": {
"invalid": "Prüfe den Stimmungseintrag und versuche es erneut."
},
"duplicate_timestamp": "Für diesen Zeitpunkt ist bereits ein Stimmungseintrag vorhanden.",
"not_found": "Dieser Stimmungseintrag ist nicht mehr verfügbar.",
"ratedFactor": {
"out_of_range": "Eine Faktorbewertung liegt außerhalb des zulässigen Bereichs."
}
},
"labs": {
"create": { "invalid": "Prüfe den Laborwert und versuche es erneut." },
"result": { "notFound": "Dieser Laborwert ist nicht mehr verfügbar." },
"biomarker": {
"notFound": "Dieser Biomarker ist nicht mehr verfügbar.",
"duplicate": "Ein Biomarker mit diesem Namen ist bereits vorhanden.",
"create": {
"invalid": "Prüfe die Biomarker-Angaben und versuche es erneut."
},
"update": {
"invalid": "Prüfe die Biomarker-Angaben und versuche es erneut.",
"referenceRangeInvalid": "Der untere Referenzwert darf den oberen Wert nicht überschreiten."
}
},
"update": {
"invalid": "Prüfe den Laborwert und versuche es erneut.",
"linkedFieldsImmutable": "Bearbeite den Biomarker, um Name, Einheit, Panel oder Referenzbereich zu ändern.",
"qualitativeExpected": "Gib für diesen qualitativen Laborwert ein Textergebnis ein.",
"numericExpected": "Gib für diesen numerischen Laborwert eine Zahl ein.",
"referenceRangeInvalid": "Der untere Referenzwert darf den oberen Wert nicht überschreiten."
}
},
"onboarding": {
"disclaimer": {
"invalid": "Bestätige den medizinischen Hinweis bitte erneut."
},
"tour": {
"invalid": "Der Tour-Fortschritt konnte nicht gespeichert werden."
},
"complete": {
"invalid": "Prüfe die Profildaten und versuche es erneut."
},
"user": { "notFound": "Dein Konto wurde nicht gefunden." },
"step": {
"rateLimited": "Zu viele Versuche. Warte kurz und versuche es erneut.",
"invalid": "Dieser Onboarding-Schritt konnte nicht gespeichert werden.",
"completed": "Das Onboarding ist bereits abgeschlossen.",
"outOfOrder": "Dein Onboarding-Fortschritt hat sich geändert. Lade die Seite neu und fahre fort.",
"concurrent": "Dein Onboarding-Fortschritt hat sich geändert. Lade die Seite neu und fahre fort."
}
}
},
"doctorReport": {
"title": "Gesundheitsbericht",
"subtitle": "HealthLog — Persönlicher Gesundheitsbericht",
@@ -255,7 +316,9 @@
"medication": "Medikament",
"medicationDescription": "Eine Dosis als genommen markieren",
"mood": "Stimmung",
"moodDescription": "Stimmung und Tags erfassen"
"moodDescription": "Stimmung und Tags erfassen",
"water": "Wasser",
"waterDescription": "Wasseraufnahme schnell hinzufügen"
},
"cycle": "Zyklus",
"labs": "Laborwerte",
@@ -358,6 +421,7 @@
"quickAddMeasurement": "Messung erfassen",
"quickAddMood": "Stimmung erfassen",
"quickAddMedicationIntake": "Einnahme erfassen",
"quickAddWater": "Wasser erfassen",
"customizeDashboard": "Dashboard anpassen",
"medicationIntakeQuickAdd": {
"sheetTitle": "Medikamenteneinnahme erfassen",
@@ -2331,6 +2395,13 @@
"historyEmpty": "Deine Unterhaltungen erscheinen hier. Stell eine Frage, um zu starten.",
"historyDeleteAria": "Unterhaltung löschen",
"historyDeleted": "Unterhaltung gelöscht",
"rename": {
"action": "Unterhaltung umbenennen",
"input": "Titel der Unterhaltung",
"save": "Titel speichern",
"cancel": "Umbenennen abbrechen",
"error": "Der Titel der Unterhaltung konnte nicht gespeichert werden."
},
"historySearchEmpty": "Keine Unterhaltungen entsprechen deiner Suche.",
"historyLoadingMore": "Mehr wird geladen…",
"sourcesTitle": "Worauf ich zugreife",
@@ -2469,10 +2540,6 @@
"settingsClustersHint": "Wähle, welche Datencluster der Coach lesen darf. Mehr Daten bedeuten reichhaltigere Antworten und einen größeren Prompt.",
"settingsClusterNoData": "Noch keine Daten",
"sourcesMemberCount": "{count} Werte",
"dictate": "Diktieren",
"dictateStop": "Diktat beenden",
"voiceUnsupported": "Spracheingabe wird in diesem Browser nicht unterstützt",
"dictateError": "Spracheingabe konnte nicht gestartet werden. Prüfe die Mikrofonberechtigung und versuche es erneut.",
"showConversations": "Unterhaltungen anzeigen",
"hideConversations": "Unterhaltungen ausblenden",
"heroGreeting": "Frage mich etwas zu deinen Daten",
@@ -3046,7 +3113,10 @@
"mixedCardio": "Mixed Cardio",
"other": "Sonstiges"
},
"loadError": "Deine Workouts konnten nicht geladen werden"
"loadError": "Deine Workouts konnten nicht geladen werden",
"loadMore": "Weitere Workouts laden",
"loadingMore": "Weitere Workouts werden geladen",
"loadMoreError": "Weitere Workouts konnten nicht geladen werden"
},
"bloodGlucose": {
"title": "Blutzucker",
@@ -4523,6 +4593,7 @@
"failed": "Der Import ist fehlgeschlagen.",
"rowsImported": "Bisher {count} Einträge importiert",
"doneSummary": "Fertig — {imported} von {read} Datensätzen importiert, {skipped} klinische Datensätze übersprungen.",
"estimateWarning": "Kumulative Summen für {count} Tag(e) wurden anhand der größten Exportquelle geschätzt. Native Apple-Health-Statistiken bleiben maßgeblich.",
"phase": {
"queued": "In Warteschlange…",
"unpacking": "Archiv wird entpackt…",
@@ -5751,6 +5822,22 @@
"rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
},
"appleHealth": {
"title": "Apple Health",
"description": "Die Live-Synchronisierung erfolgt über die HealthLog-iOS-App – nicht über eine Web- oder OAuth-Verbindung.",
"status": {
"checking": "Apple-Health-Daten werden geprüft…",
"dataReceived": "Apple-Health-Daten empfangen",
"setup": "In der iOS-App einrichten",
"unavailable": "Apple-Health-Status nicht verfügbar"
},
"lastDataLabel": "Letzte Apple-Health-Daten:",
"setupTitle": "Live-Synchronisierung aktivieren",
"permissionStep": "Öffne in der HealthLog-iOS-App Einstellungen → Apple Health und erlaube die Gesundheitskategorien, die du teilen möchtest.",
"backgroundStep": "Lass die Hintergrundaktualisierung aktiviert. Die App synchronisiert neue Daten automatisch im Vordergrund und wenn iOS Hintergrundzeit gewährt.",
"importNote": "Du brauchst ältere Daten oder kannst die iOS-App nicht nutzen? Der Apple-Health-XML/ZIP-Upload ist ein einmaliger Ersatzweg und aktiviert keine Live-Synchronisierung.",
"importAction": "Einmaligen Import öffnen"
},
"garminInfo": {
"title": "Garmin",
"body": "Garmin hat hier keine direkte Verbindung. Verbinde über Apple Health (iOS) oder Google Health Connect (Android). Garmins proprietäre Werte — Body Battery, Training Load, HRV-Status, VO₂max — werden von Garmin an keine der beiden Plattformen weitergegeben.",
@@ -8536,6 +8623,7 @@
"withheld": "Eine Zusammenfassung wurde erstellt, hat die Sicherheitsprüfung aber nicht bestanden und wird deshalb nicht angezeigt. Du kannst es erneut versuchen oder das Dokument selbst lesen.",
"unavailable": "Für dieses Dokument konnte keine Zusammenfassung erstellt werden.",
"generate": "Zusammenfassung erstellen",
"regenerate": "Neu erstellen",
"generating": "Wird erstellt…"
},
"loadError": "Dieses Dokument konnte nicht geladen werden.",
@@ -8773,6 +8861,7 @@
"quickAddCustomPlaceholder": "250",
"quickAddSubmit": "Hinzufügen",
"quickAddError": "Konnte nicht gespeichert werden — versuch's noch mal.",
"quickAddSuccess": "{amount} ml Wasser hinzugefügt.",
"editTotal": "Heutige Summe bearbeiten"
},
"caffeine": {
+85 -6
View File
@@ -46,6 +46,57 @@
"durationHoursMinutes": "{hours}h {minutes}m",
"durationMinutes": "{minutes}m"
},
"apiErrors": {
"measurement": {
"create": { "invalid": "Check the measurement details and try again." },
"duplicate_timestamp": "A measurement already exists for this time."
},
"mood": {
"list": { "invalid": "The mood list filters are invalid." },
"create": { "invalid": "Check the mood entry and try again." },
"update": { "invalid": "Check the mood entry and try again." },
"duplicate_timestamp": "A mood entry already exists for this time.",
"not_found": "This mood entry is no longer available.",
"ratedFactor": {
"out_of_range": "A factor rating is outside its allowed range."
}
},
"labs": {
"create": { "invalid": "Check the lab result and try again." },
"result": { "notFound": "This lab result is no longer available." },
"biomarker": {
"notFound": "This biomarker is no longer available.",
"duplicate": "A biomarker with this name already exists.",
"create": { "invalid": "Check the biomarker details and try again." },
"update": {
"invalid": "Check the biomarker details and try again.",
"referenceRangeInvalid": "The lower reference value must not exceed the upper value."
}
},
"update": {
"invalid": "Check the lab result and try again.",
"linkedFieldsImmutable": "Edit the biomarker to change its name, unit, panel, or reference range.",
"qualitativeExpected": "Enter a text result for this qualitative reading.",
"numericExpected": "Enter a number for this numeric reading.",
"referenceRangeInvalid": "The lower reference value must not exceed the upper value."
}
},
"onboarding": {
"disclaimer": {
"invalid": "Please confirm the medical disclaimer again."
},
"tour": { "invalid": "The tour progress could not be saved." },
"complete": { "invalid": "Check the profile details and try again." },
"user": { "notFound": "Your account could not be found." },
"step": {
"rateLimited": "Too many attempts. Please wait and try again.",
"invalid": "This onboarding step could not be saved.",
"completed": "Onboarding is already complete.",
"outOfOrder": "Your onboarding progress changed. Refresh and continue.",
"concurrent": "Your onboarding progress changed. Refresh and continue."
}
}
},
"doctorReport": {
"title": "Health Report",
"subtitle": "HealthLog — Personal Health Report",
@@ -255,7 +306,9 @@
"medication": "Medication",
"medicationDescription": "Mark a dose as taken",
"mood": "Mood",
"moodDescription": "Log your mood and tags"
"moodDescription": "Log your mood and tags",
"water": "Water",
"waterDescription": "Quickly add your water intake"
},
"cycle": "Cycle",
"labs": "Labs",
@@ -358,6 +411,7 @@
"quickAddMeasurement": "Log measurement",
"quickAddMood": "Log mood",
"quickAddMedicationIntake": "Log medication intake",
"quickAddWater": "Log water",
"customizeDashboard": "Customize dashboard",
"medicationIntakeQuickAdd": {
"sheetTitle": "Log medication intake",
@@ -2331,6 +2385,13 @@
"historyEmpty": "Your conversations will appear here. Ask anything to start.",
"historyDeleteAria": "Delete conversation",
"historyDeleted": "Conversation deleted",
"rename": {
"action": "Rename conversation",
"input": "Conversation title",
"save": "Save title",
"cancel": "Cancel rename",
"error": "Conversation title could not be saved."
},
"historySearchEmpty": "No conversations match your search.",
"historyLoadingMore": "Loading more…",
"sourcesTitle": "What I can see",
@@ -2469,10 +2530,6 @@
"settingsClustersHint": "Pick which clusters of your data the Coach can read. More data means richer answers and a larger prompt.",
"settingsClusterNoData": "No data yet",
"sourcesMemberCount": "{count} metrics",
"dictate": "Dictate",
"dictateStop": "Stop dictation",
"voiceUnsupported": "Voice input is not supported in this browser",
"dictateError": "Could not start voice input. Check microphone permission and try again.",
"showConversations": "Show conversations",
"hideConversations": "Hide conversations",
"heroGreeting": "Ask me anything about your data",
@@ -3046,7 +3103,10 @@
"mixedCardio": "Mixed cardio",
"other": "Other"
},
"loadError": "Your workouts could not be loaded"
"loadError": "Your workouts could not be loaded",
"loadMore": "Load more workouts",
"loadingMore": "Loading more workouts",
"loadMoreError": "More workouts could not be loaded"
},
"bloodGlucose": {
"title": "Blood Glucose",
@@ -4523,6 +4583,7 @@
"failed": "The import failed.",
"rowsImported": "{count} rows imported so far",
"doneSummary": "Done — {imported} of {read} records imported, {skipped} clinical records skipped.",
"estimateWarning": "Cumulative totals for {count} day(s) were estimated from the largest export source. Native Apple Health statistics remain authoritative.",
"phase": {
"queued": "Queued…",
"unpacking": "Unpacking the archive…",
@@ -5751,6 +5812,22 @@
"rate_limited": "Too many connection attempts. Wait a moment and try again.",
"generic": "Unknown error while connecting. Please start the connection again."
},
"appleHealth": {
"title": "Apple Health",
"description": "Live sync comes from the HealthLog iOS app. It is not a web or OAuth connection.",
"status": {
"checking": "Checking Apple Health data…",
"dataReceived": "Apple Health data received",
"setup": "Set up in iOS app",
"unavailable": "Apple Health status unavailable"
},
"lastDataLabel": "Last Apple Health data:",
"setupTitle": "Turn on live sync",
"permissionStep": "In the HealthLog iOS app, open Settings → Apple Health and allow the health categories you want to share.",
"backgroundStep": "Keep Background App Refresh enabled. The app syncs new data automatically in the foreground and when iOS grants background time.",
"importNote": "Need older history or cannot use the iOS app? The Apple Health XML/ZIP upload is a one-shot fallback and does not enable live sync.",
"importAction": "Open one-shot import"
},
"garminInfo": {
"title": "Garmin",
"body": "Garmin has no direct connection here. Connect via Apple Health (iOS) or Google Health Connect (Android). Garmin's proprietary scores — Body Battery, Training Load, HRV Status, VO₂ max — are not shared by Garmin to either platform.",
@@ -8536,6 +8613,7 @@
"withheld": "A summary was generated but did not pass the safety check, so it is not shown. You can try again, or read the document itself.",
"unavailable": "No summary could be generated for this document.",
"generate": "Generate summary",
"regenerate": "Generate again",
"generating": "Generating…"
},
"loadError": "Couldn't load this document.",
@@ -8773,6 +8851,7 @@
"quickAddCustomPlaceholder": "250",
"quickAddSubmit": "Add",
"quickAddError": "Couldn't save that — try again.",
"quickAddSuccess": "{amount} mL water added.",
"editTotal": "Edit today's total"
},
"caffeine": {
+97 -6
View File
@@ -46,6 +46,69 @@
"durationHoursMinutes": "{hours} h {minutes} min",
"durationMinutes": "{minutes} min"
},
"apiErrors": {
"measurement": {
"create": {
"invalid": "Comprueba los datos de la medición e inténtalo de nuevo."
},
"duplicate_timestamp": "Ya existe una medición para esta hora."
},
"mood": {
"list": { "invalid": "Los filtros de la lista de ánimo no son válidos." },
"create": {
"invalid": "Comprueba la entrada de ánimo e inténtalo de nuevo."
},
"update": {
"invalid": "Comprueba la entrada de ánimo e inténtalo de nuevo."
},
"duplicate_timestamp": "Ya existe una entrada de ánimo para esta hora.",
"not_found": "Esta entrada de ánimo ya no está disponible.",
"ratedFactor": {
"out_of_range": "Una valoración está fuera del intervalo permitido."
}
},
"labs": {
"create": {
"invalid": "Comprueba el resultado de laboratorio e inténtalo de nuevo."
},
"result": {
"notFound": "Este resultado de laboratorio ya no está disponible."
},
"biomarker": {
"notFound": "Este biomarcador ya no está disponible.",
"duplicate": "Ya existe un biomarcador con este nombre.",
"create": {
"invalid": "Comprueba los datos del biomarcador e inténtalo de nuevo."
},
"update": {
"invalid": "Comprueba los datos del biomarcador e inténtalo de nuevo.",
"referenceRangeInvalid": "El valor de referencia inferior no puede superar al superior."
}
},
"update": {
"invalid": "Comprueba el resultado de laboratorio e inténtalo de nuevo.",
"linkedFieldsImmutable": "Edita el biomarcador para cambiar su nombre, unidad, panel o intervalo de referencia.",
"qualitativeExpected": "Introduce un resultado de texto para esta lectura cualitativa.",
"numericExpected": "Introduce un número para esta lectura numérica.",
"referenceRangeInvalid": "El valor de referencia inferior no puede superar al superior."
}
},
"onboarding": {
"disclaimer": { "invalid": "Confirma de nuevo el aviso médico." },
"tour": { "invalid": "No se pudo guardar el progreso del recorrido." },
"complete": {
"invalid": "Comprueba los datos del perfil e inténtalo de nuevo."
},
"user": { "notFound": "No se pudo encontrar tu cuenta." },
"step": {
"rateLimited": "Demasiados intentos. Espera e inténtalo de nuevo.",
"invalid": "No se pudo guardar este paso de la introducción.",
"completed": "La introducción ya está completada.",
"outOfOrder": "Tu progreso ha cambiado. Actualiza la página y continúa.",
"concurrent": "Tu progreso ha cambiado. Actualiza la página y continúa."
}
}
},
"doctorReport": {
"title": "Informe de salud",
"subtitle": "HealthLog — Informe personal de salud",
@@ -255,7 +318,9 @@
"medication": "Medicamento",
"medicationDescription": "Marcar una dosis como tomada",
"mood": "Estado de ánimo",
"moodDescription": "Registrar estado de ánimo y etiquetas"
"moodDescription": "Registra tu estado de ánimo y etiquetas",
"water": "Agua",
"waterDescription": "Añade rápidamente tu consumo de agua"
},
"cycle": "Ciclo",
"labs": "Análisis",
@@ -358,6 +423,7 @@
"quickAddMeasurement": "Registrar medición",
"quickAddMood": "Registrar estado de ánimo",
"quickAddMedicationIntake": "Registrar toma",
"quickAddWater": "Registrar agua",
"customizeDashboard": "Personalizar panel",
"medicationIntakeQuickAdd": {
"sheetTitle": "Registrar toma de medicación",
@@ -2331,6 +2397,13 @@
"historyEmpty": "Tus conversaciones aparecen aquí. Lanza una pregunta para empezar.",
"historyDeleteAria": "Eliminar conversación",
"historyDeleted": "Conversación eliminada",
"rename": {
"action": "Renombrar conversación",
"input": "Título de la conversación",
"save": "Guardar título",
"cancel": "Cancelar cambio de nombre",
"error": "No se pudo guardar el título de la conversación."
},
"historySearchEmpty": "Ninguna conversación coincide con tu búsqueda.",
"historyLoadingMore": "Cargando más…",
"sourcesTitle": "A qué accedo",
@@ -2469,10 +2542,6 @@
"settingsClustersHint": "Elige qué grupos de datos puede leer el Coach. Más datos significan respuestas más ricas y un prompt mayor.",
"settingsClusterNoData": "Aún no hay datos",
"sourcesMemberCount": "{count} métricas",
"dictate": "Dictar",
"dictateStop": "Detener el dictado",
"voiceUnsupported": "La entrada de voz no es compatible con este navegador",
"dictateError": "No se pudo iniciar la entrada de voz. Comprueba el permiso del micrófono e inténtalo de nuevo.",
"showConversations": "Mostrar conversaciones",
"hideConversations": "Ocultar conversaciones",
"heroGreeting": "Pregúntame lo que quieras sobre tus datos",
@@ -3046,7 +3115,10 @@
"mixedCardio": "Cardio mixto",
"other": "Otro"
},
"loadError": "No se pudieron cargar tus entrenamientos"
"loadError": "No se pudieron cargar tus entrenamientos",
"loadMore": "Cargar más entrenamientos",
"loadingMore": "Cargando más entrenamientos",
"loadMoreError": "No se pudieron cargar más entrenamientos"
},
"bloodGlucose": {
"title": "Glucosa en sangre",
@@ -4523,6 +4595,7 @@
"failed": "La importación falló.",
"rowsImported": "{count} filas importadas hasta ahora",
"doneSummary": "Listo: {imported} de {read} registros importados, {skipped} registros clínicos omitidos.",
"estimateWarning": "Los totales acumulados de {count} día(s) se estimaron a partir de la mayor fuente de la exportación. Las estadísticas nativas de Apple Health siguen siendo la referencia.",
"phase": {
"queued": "En cola…",
"unpacking": "Descomprimiendo el archivo…",
@@ -5751,6 +5824,22 @@
"rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
},
"appleHealth": {
"title": "Apple Health",
"description": "La sincronización en vivo se realiza desde la app HealthLog para iOS, no mediante una conexión web ni OAuth.",
"status": {
"checking": "Comprobando datos de Apple Health…",
"dataReceived": "Datos de Apple Health recibidos",
"setup": "Configurar en la app de iOS",
"unavailable": "Estado de Apple Health no disponible"
},
"lastDataLabel": "Últimos datos de Apple Health:",
"setupTitle": "Activar la sincronización en vivo",
"permissionStep": "En la app HealthLog para iOS, abre Ajustes → Apple Health y permite las categorías de salud que quieras compartir.",
"backgroundStep": "Mantén activada la actualización en segundo plano. La app sincroniza datos nuevos automáticamente en primer plano y cuando iOS le concede tiempo en segundo plano.",
"importNote": "¿Necesitas un historial anterior o no puedes usar la app de iOS? La carga XML/ZIP de Apple Health es una alternativa puntual y no activa la sincronización en vivo.",
"importAction": "Abrir importación puntual"
},
"garminInfo": {
"title": "Garmin",
"body": "Garmin no tiene conexión directa aquí. Conéctalo a través de Apple Health (iOS) o Google Health Connect (Android). Las métricas propias de Garmin — Body Battery, Training Load, estado de HRV, VO₂ máx.— no las comparte Garmin con ninguna de las dos plataformas.",
@@ -8536,6 +8625,7 @@
"withheld": "Se generó un resumen, pero no superó la comprobación de seguridad, así que no se muestra. Puedes intentarlo de nuevo o leer el documento.",
"unavailable": "No se pudo generar ningún resumen para este documento.",
"generate": "Generar resumen",
"regenerate": "Volver a generar",
"generating": "Generando…"
},
"loadError": "No se pudo cargar este documento.",
@@ -8773,6 +8863,7 @@
"quickAddCustomPlaceholder": "250",
"quickAddSubmit": "Añadir",
"quickAddError": "No se pudo guardar. Inténtalo de nuevo.",
"quickAddSuccess": "Se añadieron {amount} mL de agua.",
"editTotal": "Editar el total de hoy"
},
"caffeine": {
+99 -6
View File
@@ -46,6 +46,71 @@
"durationHoursMinutes": "{hours} h {minutes} min",
"durationMinutes": "{minutes} min"
},
"apiErrors": {
"measurement": {
"create": {
"invalid": "Vérifiez les données de la mesure et réessayez."
},
"duplicate_timestamp": "Une mesure existe déjà pour cette heure."
},
"mood": {
"list": {
"invalid": "Les filtres de la liste d’humeur ne sont pas valides."
},
"create": { "invalid": "Vérifiez l’entrée d’humeur et réessayez." },
"update": { "invalid": "Vérifiez l’entrée d’humeur et réessayez." },
"duplicate_timestamp": "Une entrée d’humeur existe déjà pour cette heure.",
"not_found": "Cette entrée d’humeur n’est plus disponible.",
"ratedFactor": {
"out_of_range": "Une évaluation est hors de la plage autorisée."
}
},
"labs": {
"create": {
"invalid": "Vérifiez le résultat de laboratoire et réessayez."
},
"result": {
"notFound": "Ce résultat de laboratoire n’est plus disponible."
},
"biomarker": {
"notFound": "Ce biomarqueur n’est plus disponible.",
"duplicate": "Un biomarqueur portant ce nom existe déjà.",
"create": {
"invalid": "Vérifiez les informations du biomarqueur et réessayez."
},
"update": {
"invalid": "Vérifiez les informations du biomarqueur et réessayez.",
"referenceRangeInvalid": "La valeur de référence basse ne doit pas dépasser la valeur haute."
}
},
"update": {
"invalid": "Vérifiez le résultat de laboratoire et réessayez.",
"linkedFieldsImmutable": "Modifiez le biomarqueur pour changer son nom, son unité, son panel ou sa plage de référence.",
"qualitativeExpected": "Saisissez un résultat textuel pour cette mesure qualitative.",
"numericExpected": "Saisissez un nombre pour cette mesure numérique.",
"referenceRangeInvalid": "La valeur de référence basse ne doit pas dépasser la valeur haute."
}
},
"onboarding": {
"disclaimer": {
"invalid": "Veuillez confirmer à nouveau l’avertissement médical."
},
"tour": {
"invalid": "La progression de la visite n’a pas pu être enregistrée."
},
"complete": {
"invalid": "Vérifiez les informations du profil et réessayez."
},
"user": { "notFound": "Votre compte est introuvable." },
"step": {
"rateLimited": "Trop de tentatives. Patientez puis réessayez.",
"invalid": "Cette étape d’accueil n’a pas pu être enregistrée.",
"completed": "L’accueil est déjà terminé.",
"outOfOrder": "Votre progression a changé. Actualisez la page et continuez.",
"concurrent": "Votre progression a changé. Actualisez la page et continuez."
}
}
},
"doctorReport": {
"title": "Rapport de santé",
"subtitle": "HealthLog — Rapport personnel de santé",
@@ -255,7 +320,9 @@
"medication": "Médicament",
"medicationDescription": "Marquer une dose comme prise",
"mood": "Humeur",
"moodDescription": "Enregistrer l'humeur et les tags"
"moodDescription": "Enregistrez votre humeur et des étiquettes",
"water": "Eau",
"waterDescription": "Ajoutez rapidement votre consommation d’eau"
},
"cycle": "Cycle",
"labs": "Analyses",
@@ -358,6 +425,7 @@
"quickAddMeasurement": "Saisir une mesure",
"quickAddMood": "Saisir une humeur",
"quickAddMedicationIntake": "Saisir une prise",
"quickAddWater": "Saisir de l’eau",
"customizeDashboard": "Personnaliser le tableau de bord",
"medicationIntakeQuickAdd": {
"sheetTitle": "Saisir une prise de médicament",
@@ -2331,6 +2399,13 @@
"historyEmpty": "Tes conversations s’affichent ici. Lance une question pour commencer.",
"historyDeleteAria": "Supprimer la conversation",
"historyDeleted": "Conversation supprimée",
"rename": {
"action": "Renommer la conversation",
"input": "Titre de la conversation",
"save": "Enregistrer le titre",
"cancel": "Annuler le renommage",
"error": "Impossible d’enregistrer le titre de la conversation."
},
"historySearchEmpty": "Aucune conversation ne correspond à ta recherche.",
"historyLoadingMore": "Chargement…",
"sourcesTitle": "Ce à quoi j’accède",
@@ -2469,10 +2544,6 @@
"settingsClustersHint": "Choisissez les groupes de données que le Coach peut lire. Plus de données, des réponses plus riches et un prompt plus grand.",
"settingsClusterNoData": "Pas encore de données",
"sourcesMemberCount": "{count} mesures",
"dictate": "Dicter",
"dictateStop": "Arrêter la dictée",
"voiceUnsupported": "La saisie vocale n'est pas prise en charge par ce navigateur",
"dictateError": "Impossible de démarrer la saisie vocale. Vérifiez l'autorisation du microphone et réessayez.",
"showConversations": "Afficher les conversations",
"hideConversations": "Masquer les conversations",
"heroGreeting": "Pose-moi une question sur tes données",
@@ -3046,7 +3117,10 @@
"mixedCardio": "Cardio mixte",
"other": "Autre"
},
"loadError": "Tes séances n'ont pas pu être chargées"
"loadError": "Tes séances n'ont pas pu être chargées",
"loadMore": "Charger plus de séances",
"loadingMore": "Chargement de séances supplémentaires",
"loadMoreError": "Les séances supplémentaires n'ont pas pu être chargées"
},
"bloodGlucose": {
"title": "Glycémie",
@@ -4523,6 +4597,7 @@
"failed": "L'import a échoué.",
"rowsImported": "{count} lignes importées jusqu'ici",
"doneSummary": "Terminé — {imported} enregistrements sur {read} importés, {skipped} enregistrements cliniques ignorés.",
"estimateWarning": "Les totaux cumulés de {count} jour(s) ont été estimés à partir de la source d’export la plus élevée. Les statistiques Apple Santé natives restent la référence.",
"phase": {
"queued": "En file d'attente…",
"unpacking": "Décompression de l'archive…",
@@ -5751,6 +5826,22 @@
"rate_limited": "Trop de tentatives de connexion. Attends un instant et réessaie.",
"generic": "Erreur inconnue lors de la connexion. Relance la connexion."
},
"appleHealth": {
"title": "Apple Health",
"description": "La synchronisation en direct passe par l’app HealthLog pour iOS, et non par une connexion web ou OAuth.",
"status": {
"checking": "Vérification des données Apple Health…",
"dataReceived": "Données Apple Health reçues",
"setup": "Configurer dans l’app iOS",
"unavailable": "Statut Apple Health indisponible"
},
"lastDataLabel": "Dernières données Apple Health :",
"setupTitle": "Activer la synchronisation en direct",
"permissionStep": "Dans l’app HealthLog pour iOS, ouvre Réglages → Apple Health et autorise les catégories de santé que tu souhaites partager.",
"backgroundStep": "Laisse l’actualisation en arrière-plan activée. L’app synchronise automatiquement les nouvelles données au premier plan et lorsque iOS lui accorde du temps en arrière-plan.",
"importNote": "Besoin d’un ancien historique ou impossible d’utiliser l’app iOS ? L’envoi XML/ZIP Apple Health est une solution ponctuelle et n’active pas la synchronisation en direct.",
"importAction": "Ouvrir l’import ponctuel"
},
"garminInfo": {
"title": "Garmin",
"body": "Garmin n'a pas de connexion directe ici. Connecte-le via Apple Health (iOS) ou Google Health Connect (Android). Les scores propriétaires de Garmin — Body Battery, Training Load, statut VFC, VO₂ max — ne sont partagés par Garmin avec aucune des deux plateformes.",
@@ -8536,6 +8627,7 @@
"withheld": "Un résumé a été généré mais n’a pas passé le contrôle de sécurité, il n’est donc pas affiché. Vous pouvez réessayer ou lire le document lui-même.",
"unavailable": "Aucun résumé n’a pu être généré pour ce document.",
"generate": "Générer le résumé",
"regenerate": "Regénérer",
"generating": "Génération…"
},
"loadError": "Impossible de charger ce document.",
@@ -8773,6 +8865,7 @@
"quickAddCustomPlaceholder": "250",
"quickAddSubmit": "Ajouter",
"quickAddError": "Échec de l'enregistrement — réessayez.",
"quickAddSuccess": "{amount} mL d’eau ajoutés.",
"editTotal": "Modifier le total du jour"
},
"caffeine": {
+89 -6
View File
@@ -46,6 +46,61 @@
"durationHoursMinutes": "{hours} h {minutes} min",
"durationMinutes": "{minutes} min"
},
"apiErrors": {
"measurement": {
"create": { "invalid": "Controlla i dati della misurazione e riprova." },
"duplicate_timestamp": "Esiste già una misurazione per questo orario."
},
"mood": {
"list": { "invalid": "I filtri dell’elenco dell’umore non sono validi." },
"create": { "invalid": "Controlla la voce dell’umore e riprova." },
"update": { "invalid": "Controlla la voce dell’umore e riprova." },
"duplicate_timestamp": "Esiste già una voce dell’umore per questo orario.",
"not_found": "Questa voce dell’umore non è più disponibile.",
"ratedFactor": {
"out_of_range": "Una valutazione non rientra nell’intervallo consentito."
}
},
"labs": {
"create": {
"invalid": "Controlla il risultato di laboratorio e riprova."
},
"result": {
"notFound": "Questo risultato di laboratorio non è più disponibile."
},
"biomarker": {
"notFound": "Questo biomarcatore non è più disponibile.",
"duplicate": "Esiste già un biomarcatore con questo nome.",
"create": { "invalid": "Controlla i dati del biomarcatore e riprova." },
"update": {
"invalid": "Controlla i dati del biomarcatore e riprova.",
"referenceRangeInvalid": "Il valore di riferimento inferiore non può superare quello superiore."
}
},
"update": {
"invalid": "Controlla il risultato di laboratorio e riprova.",
"linkedFieldsImmutable": "Modifica il biomarcatore per cambiare nome, unità, pannello o intervallo di riferimento.",
"qualitativeExpected": "Inserisci un risultato testuale per questa lettura qualitativa.",
"numericExpected": "Inserisci un numero per questa lettura numerica.",
"referenceRangeInvalid": "Il valore di riferimento inferiore non può superare quello superiore."
}
},
"onboarding": {
"disclaimer": { "invalid": "Conferma nuovamente l’avviso medico." },
"tour": {
"invalid": "Non è stato possibile salvare l’avanzamento del tour."
},
"complete": { "invalid": "Controlla i dati del profilo e riprova." },
"user": { "notFound": "Impossibile trovare il tuo account." },
"step": {
"rateLimited": "Troppi tentativi. Attendi e riprova.",
"invalid": "Non è stato possibile salvare questo passaggio introduttivo.",
"completed": "La procedura introduttiva è già completata.",
"outOfOrder": "I tuoi progressi sono cambiati. Aggiorna la pagina e continua.",
"concurrent": "I tuoi progressi sono cambiati. Aggiorna la pagina e continua."
}
}
},
"doctorReport": {
"title": "Rapporto sanitario",
"subtitle": "HealthLog — Rapporto personale di salute",
@@ -255,7 +310,9 @@
"medication": "Farmaco",
"medicationDescription": "Segna una dose come assunta",
"mood": "Umore",
"moodDescription": "Registra umore e tag"
"moodDescription": "Registra umore e tag",
"water": "Acqua",
"waterDescription": "Aggiungi rapidamente l’acqua bevuta"
},
"cycle": "Ciclo",
"labs": "Esami",
@@ -358,6 +415,7 @@
"quickAddMeasurement": "Registra misurazione",
"quickAddMood": "Registra umore",
"quickAddMedicationIntake": "Registra assunzione",
"quickAddWater": "Registra acqua",
"customizeDashboard": "Personalizza dashboard",
"medicationIntakeQuickAdd": {
"sheetTitle": "Registra assunzione del farmaco",
@@ -2331,6 +2389,13 @@
"historyEmpty": "Le tue conversazioni compaiono qui. Fai una domanda per iniziare.",
"historyDeleteAria": "Elimina conversazione",
"historyDeleted": "Conversazione eliminata",
"rename": {
"action": "Rinomina conversazione",
"input": "Titolo della conversazione",
"save": "Salva titolo",
"cancel": "Annulla rinomina",
"error": "Impossibile salvare il titolo della conversazione."
},
"historySearchEmpty": "Nessuna conversazione corrisponde alla tua ricerca.",
"historyLoadingMore": "Caricamento…",
"sourcesTitle": "A cosa accedo",
@@ -2469,10 +2534,6 @@
"settingsClustersHint": "Scegli quali gruppi di dati il Coach può leggere. Più dati significano risposte più ricche e un prompt più grande.",
"settingsClusterNoData": "Ancora nessun dato",
"sourcesMemberCount": "{count} metriche",
"dictate": "Detta",
"dictateStop": "Interrompi dettatura",
"voiceUnsupported": "L'inserimento vocale non è supportato in questo browser",
"dictateError": "Impossibile avviare l'inserimento vocale. Controlla l'autorizzazione del microfono e riprova.",
"showConversations": "Mostra conversazioni",
"hideConversations": "Nascondi conversazioni",
"heroGreeting": "Chiedimi qualcosa sui tuoi dati",
@@ -3046,7 +3107,10 @@
"mixedCardio": "Cardio misto",
"other": "Altro"
},
"loadError": "Non è stato possibile caricare i tuoi allenamenti"
"loadError": "Non è stato possibile caricare i tuoi allenamenti",
"loadMore": "Carica altri allenamenti",
"loadingMore": "Caricamento di altri allenamenti",
"loadMoreError": "Non è stato possibile caricare altri allenamenti"
},
"bloodGlucose": {
"title": "Glicemia",
@@ -4523,6 +4587,7 @@
"failed": "L'importazione non è riuscita.",
"rowsImported": "{count} righe importate finora",
"doneSummary": "Fatto: {imported} di {read} record importati, {skipped} record clinici saltati.",
"estimateWarning": "I totali cumulativi di {count} giorno/i sono stati stimati dalla fonte di esportazione più alta. Le statistiche native di Apple Salute restano autorevoli.",
"phase": {
"queued": "In coda…",
"unpacking": "Estrazione dell'archivio…",
@@ -5751,6 +5816,22 @@
"rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
},
"appleHealth": {
"title": "Apple Health",
"description": "La sincronizzazione in tempo reale avviene tramite l’app HealthLog per iOS, non tramite una connessione web o OAuth.",
"status": {
"checking": "Controllo dei dati Apple Health…",
"dataReceived": "Dati Apple Health ricevuti",
"setup": "Configura nell’app iOS",
"unavailable": "Stato di Apple Health non disponibile"
},
"lastDataLabel": "Ultimi dati Apple Health:",
"setupTitle": "Attiva la sincronizzazione in tempo reale",
"permissionStep": "Nell’app HealthLog per iOS, apri Impostazioni → Apple Health e autorizza le categorie di salute che vuoi condividere.",
"backgroundStep": "Mantieni attivo l’aggiornamento in background. L’app sincronizza automaticamente i nuovi dati in primo piano e quando iOS concede tempo in background.",
"importNote": "Ti serve lo storico precedente o non puoi usare l’app iOS? Il caricamento XML/ZIP di Apple Health è un’alternativa una tantum e non attiva la sincronizzazione in tempo reale.",
"importAction": "Apri importazione una tantum"
},
"garminInfo": {
"title": "Garmin",
"body": "Garmin non ha una connessione diretta qui. Collegalo tramite Apple Health (iOS) o Google Health Connect (Android). I punteggi proprietari di Garmin — Body Battery, Training Load, stato HRV, VO₂ max — non vengono condivisi da Garmin con nessuna delle due piattaforme.",
@@ -8536,6 +8617,7 @@
"withheld": "Un riepilogo è stato generato ma non ha superato il controllo di sicurezza, quindi non viene mostrato. Puoi riprovare o leggere il documento stesso.",
"unavailable": "Non è stato possibile generare un riepilogo per questo documento.",
"generate": "Genera riepilogo",
"regenerate": "Genera di nuovo",
"generating": "Generazione…"
},
"loadError": "Impossibile caricare questo documento.",
@@ -8773,6 +8855,7 @@
"quickAddCustomPlaceholder": "250",
"quickAddSubmit": "Aggiungi",
"quickAddError": "Salvataggio non riuscito. Riprova.",
"quickAddSuccess": "Aggiunti {amount} mL di acqua.",
"editTotal": "Modifica il totale di oggi"
},
"caffeine": {
+83 -6
View File
@@ -46,6 +46,55 @@
"durationHoursMinutes": "{hours} godz. {minutes} min",
"durationMinutes": "{minutes} min"
},
"apiErrors": {
"measurement": {
"create": { "invalid": "Sprawdź dane pomiaru i spróbuj ponownie." },
"duplicate_timestamp": "Pomiar dla tego czasu już istnieje."
},
"mood": {
"list": { "invalid": "Filtry listy nastroju są nieprawidłowe." },
"create": { "invalid": "Sprawdź wpis nastroju i spróbuj ponownie." },
"update": { "invalid": "Sprawdź wpis nastroju i spróbuj ponownie." },
"duplicate_timestamp": "Wpis nastroju dla tego czasu już istnieje.",
"not_found": "Ten wpis nastroju nie jest już dostępny.",
"ratedFactor": {
"out_of_range": "Ocena czynnika jest poza dozwolonym zakresem."
}
},
"labs": {
"create": { "invalid": "Sprawdź wynik badania i spróbuj ponownie." },
"result": { "notFound": "Ten wynik badania nie jest już dostępny." },
"biomarker": {
"notFound": "Ten biomarker nie jest już dostępny.",
"duplicate": "Biomarker o tej nazwie już istnieje.",
"create": { "invalid": "Sprawdź dane biomarkera i spróbuj ponownie." },
"update": {
"invalid": "Sprawdź dane biomarkera i spróbuj ponownie.",
"referenceRangeInvalid": "Dolna wartość referencyjna nie może przekraczać górnej."
}
},
"update": {
"invalid": "Sprawdź wynik badania i spróbuj ponownie.",
"linkedFieldsImmutable": "Edytuj biomarker, aby zmienić nazwę, jednostkę, panel lub zakres referencyjny.",
"qualitativeExpected": "Wprowadź wynik tekstowy dla tego odczytu jakościowego.",
"numericExpected": "Wprowadź liczbę dla tego odczytu ilościowego.",
"referenceRangeInvalid": "Dolna wartość referencyjna nie może przekraczać górnej."
}
},
"onboarding": {
"disclaimer": { "invalid": "Potwierdź ponownie informację medyczną." },
"tour": { "invalid": "Nie udało się zapisać postępu prezentacji." },
"complete": { "invalid": "Sprawdź dane profilu i spróbuj ponownie." },
"user": { "notFound": "Nie znaleziono Twojego konta." },
"step": {
"rateLimited": "Zbyt wiele prób. Zaczekaj i spróbuj ponownie.",
"invalid": "Nie udało się zapisać tego kroku wprowadzenia.",
"completed": "Wprowadzenie zostało już ukończone.",
"outOfOrder": "Twój postęp się zmienił. Odśwież stronę i kontynuuj.",
"concurrent": "Twój postęp się zmienił. Odśwież stronę i kontynuuj."
}
}
},
"doctorReport": {
"title": "Raport zdrowotny",
"subtitle": "HealthLog — Osobisty raport zdrowotny",
@@ -255,7 +304,9 @@
"medication": "Lek",
"medicationDescription": "Oznacz dawkę jako przyjętą",
"mood": "Nastrój",
"moodDescription": "Zapisz nastrój i tagi"
"moodDescription": "Zapisz nastrój i tagi",
"water": "Woda",
"waterDescription": "Szybko dodaj ilość wypitej wody"
},
"cycle": "Cykl",
"labs": "Wyniki badań",
@@ -358,6 +409,7 @@
"quickAddMeasurement": "Zarejestruj pomiar",
"quickAddMood": "Zarejestruj nastrój",
"quickAddMedicationIntake": "Zarejestruj przyjęcie",
"quickAddWater": "Zapisz wodę",
"customizeDashboard": "Dostosuj pulpit",
"medicationIntakeQuickAdd": {
"sheetTitle": "Zarejestruj przyjęcie leku",
@@ -2331,6 +2383,13 @@
"historyEmpty": "Twoje rozmowy pojawią się tutaj. Zadaj pytanie, aby zacząć.",
"historyDeleteAria": "Usuń rozmowę",
"historyDeleted": "Rozmowa usunięta",
"rename": {
"action": "Zmień nazwę rozmowy",
"input": "Tytuł rozmowy",
"save": "Zapisz tytuł",
"cancel": "Anuluj zmianę nazwy",
"error": "Nie udało się zapisać tytułu rozmowy."
},
"historySearchEmpty": "Żadna rozmowa nie pasuje do wyszukiwania.",
"historyLoadingMore": "Wczytywanie…",
"sourcesTitle": "Do czego mam dostęp",
@@ -2469,10 +2528,6 @@
"settingsClustersHint": "Wybierz, które grupy danych Coach może czytać. Więcej danych to bogatsze odpowiedzi i większy prompt.",
"settingsClusterNoData": "Brak danych",
"sourcesMemberCount": "{count} wskaźników",
"dictate": "Dyktuj",
"dictateStop": "Zatrzymaj dyktowanie",
"voiceUnsupported": "Wprowadzanie głosowe nie jest obsługiwane w tej przeglądarce",
"dictateError": "Nie udało się uruchomić wprowadzania głosowego. Sprawdź uprawnienia mikrofonu i spróbuj ponownie.",
"showConversations": "Pokaż rozmowy",
"hideConversations": "Ukryj rozmowy",
"heroGreeting": "Zapytaj mnie o cokolwiek na temat twoich danych",
@@ -3046,7 +3101,10 @@
"mixedCardio": "Cardio mieszane",
"other": "Inne"
},
"loadError": "Nie udało się wczytać treningów"
"loadError": "Nie udało się wczytać treningów",
"loadMore": "Wczytaj więcej treningów",
"loadingMore": "Wczytywanie kolejnych treningów",
"loadMoreError": "Nie udało się wczytać kolejnych treningów"
},
"bloodGlucose": {
"title": "Poziom glukozy",
@@ -4523,6 +4581,7 @@
"failed": "Import nie powiódł się.",
"rowsImported": "Zaimportowano dotąd {count} wierszy",
"doneSummary": "Gotowe — zaimportowano {imported} z {read} rekordów, pominięto {skipped} rekordów klinicznych.",
"estimateWarning": "Łączne wartości dla {count} dni oszacowano na podstawie największego źródła w eksporcie. Natywne statystyki Apple Health pozostają nadrzędne.",
"phase": {
"queued": "W kolejce…",
"unpacking": "Rozpakowywanie archiwum…",
@@ -5751,6 +5810,22 @@
"rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
},
"appleHealth": {
"title": "Apple Health",
"description": "Synchronizacja na żywo odbywa się przez aplikację HealthLog na iOS, a nie przez połączenie internetowe ani OAuth.",
"status": {
"checking": "Sprawdzanie danych Apple Health…",
"dataReceived": "Odebrano dane Apple Health",
"setup": "Skonfiguruj w aplikacji iOS",
"unavailable": "Stan Apple Health jest niedostępny"
},
"lastDataLabel": "Najnowsze dane Apple Health:",
"setupTitle": "Włącz synchronizację na żywo",
"permissionStep": "W aplikacji HealthLog na iOS otwórz Ustawienia → Apple Health i zezwól na udostępnianie wybranych kategorii zdrowotnych.",
"backgroundStep": "Pozostaw włączone odświeżanie aplikacji w tle. Aplikacja synchronizuje nowe dane automatycznie na pierwszym planie oraz gdy iOS przydzieli jej czas w tle.",
"importNote": "Potrzebujesz starszej historii lub nie możesz użyć aplikacji iOS? Przesłanie pliku XML/ZIP z Apple Health jest jednorazową alternatywą i nie włącza synchronizacji na żywo.",
"importAction": "Otwórz jednorazowy import"
},
"garminInfo": {
"title": "Garmin",
"body": "Garmin nie ma tutaj bezpośredniego połączenia. Połącz przez Apple Health (iOS) lub Google Health Connect (Android). Zastrzeżone wskaźniki Garmina — Body Battery, Training Load, status HRV, VO₂ max — nie są udostępniane przez Garmina żadnej z tych platform.",
@@ -8536,6 +8611,7 @@
"withheld": "Podsumowanie zostało wygenerowane, ale nie przeszło kontroli bezpieczeństwa, więc nie jest wyświetlane. Możesz spróbować ponownie lub przeczytać sam dokument.",
"unavailable": "Nie udało się wygenerować podsumowania dla tego dokumentu.",
"generate": "Wygeneruj podsumowanie",
"regenerate": "Wygeneruj ponownie",
"generating": "Generowanie…"
},
"loadError": "Nie udało się wczytać tego dokumentu.",
@@ -8773,6 +8849,7 @@
"quickAddCustomPlaceholder": "250",
"quickAddSubmit": "Dodaj",
"quickAddError": "Nie udało się zapisać — spróbuj ponownie.",
"quickAddSuccess": "Dodano {amount} mL wody.",
"editTotal": "Edytuj dzisiejszą sumę"
},
"caffeine": {
-1
View File
@@ -231,7 +231,6 @@ const nextConfig: NextConfig = {
serverExternalPackages: [
"@prisma/client",
"pg-boss",
"@prisma/adapter-pg",
"pg",
// Document PDF handling MUST run the real, un-bundled modules. When
// Turbopack bundles pdfjs-dist into the server chunks, its runtime
+4 -14
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.31.2",
"version": "1.32.2",
"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",
@@ -22,9 +22,9 @@
"passkey"
],
"private": true,
"packageManager": "pnpm@10.31.0",
"packageManager": "pnpm@11.15.1",
"scripts": {
"prepare": "lefthook install || echo 'lefthook install skipped (no git repo or binary unavailable)'",
"prepare": "node scripts/prepare.mjs",
"dev": "next dev",
"predev": "node scripts/generate-i18n-boot.mjs",
"prebuild": "node scripts/generate-sw-version.mjs && node scripts/generate-i18n-boot.mjs",
@@ -79,7 +79,7 @@
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.18.0",
"mmdb-lib": "^3.0.2",
"next": "16.2.10",
"next": "16.2.11",
"next-themes": "^0.4.6",
"nodemailer": "9.0.3",
"otpauth": "^9.5.1",
@@ -135,15 +135,5 @@
"tw-animate-css": "^1.4.0",
"typescript": "^6",
"vitest": "^4.1.5"
},
"pnpm": {
"onlyBuiltDependencies": [
"prisma",
"@prisma/engines"
],
"overrides": {
"hono@<4.12.25": "^4.12.25",
"picomatch@<4.0.4": "^4.0.4"
}
}
}
+9 -5
View File
@@ -22,13 +22,13 @@ export default defineConfig({
expect: { timeout: 10_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
failOnFlakyTests: !!process.env.CI,
// Two retries on CI: under a loaded shared runner, DOM-settle-gated
// assertions (wizard step transitions, list refetch → card provenance,
// disclosure toggles) intermittently sample a mid-transition frame. A
// rotating single test failed each run while 211 passed; a second retry
// absorbs that transient contention without masking a real failure (a true
// break fails all three attempts). The default expect timeout is also
// lifted from 5s → 10s for the same settle headroom.
// retry distinguishes transient contention from a persistent break, while
// failOnFlakyTests keeps either outcome visible to CI. The default expect
// timeout is lifted from 5s → 10s for the same settle headroom.
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI
@@ -95,7 +95,7 @@ export default defineConfig({
webServer: process.env.E2E_SKIP_WEB_SERVER
? undefined
: {
command: "pnpm exec next start --port 3000",
command: `mkdir -p .next/standalone/.next/static .next/standalone/public && cp -R .next/static/. .next/standalone/.next/static && cp -R public/. .next/standalone/public && PORT=3000 HOSTNAME=127.0.0.1 ${JSON.stringify(process.execPath)} .next/standalone/server.js`,
url: "http://localhost:3000/api/version",
timeout: 60_000,
reuseExistingServer: !process.env.CI,
@@ -103,6 +103,10 @@ export default defineConfig({
stderr: "pipe",
env: {
...process.env,
// Native document rendering is fail-soft and outside the browser
// suite. Disable it here so queued local thumbnail jobs cannot
// crash the shared server inside Skia during unrelated scenarios.
NATIVE_CANVAS: "off",
// The dashboard RSC wrapper server-prefetches the snapshot into
// the first HTML (HydrationBoundary). Playwright's route mocks
// only see CLIENT fetches, so an SSR-embedded snapshot would
+421 -423
View File
File diff suppressed because it is too large. Load diff
+23 -3
View File
@@ -1,3 +1,23 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver
overrides:
"dompurify@<=3.4.10": "^3.4.11"
"postcss@<8.5.10": "^8.5.10"
"@hono/node-server@<1.19.13": "^1.19.13"
"hono@<4.12.25": "^4.12.25"
"picomatch@<4.0.4": "^4.0.4"
"body-parser@<2.3.0": "^2.3.0"
"fast-uri@<3.1.3": "^3.1.3"
"sharp@<0.35.0": "^0.35.3"
allowBuilds:
"@prisma/engines": true
prisma: true
core-js: false
cpu-features: false
esbuild: false
lefthook: false
msw: false
protobufjs: false
sharp: false
ssh2: false
tesseract.js: false
unrs-resolver: false
@@ -0,0 +1,27 @@
-- Aggregate provenance makes the shared Apple Health `stats:*` identity
-- authority-aware. Existing rows cannot be classified safely from their stored
-- shape, so they remain repairable but are never promoted heuristically.
CREATE TYPE "measurement_aggregation_provenance" AS ENUM (
'HEALTHKIT_STATISTICS',
'EXPORT_XML_SOURCE_MAX',
'LEGACY_UNKNOWN'
);
ALTER TABLE "measurements"
ADD COLUMN "aggregation_provenance" "measurement_aggregation_provenance",
ADD COLUMN "aggregation_contributor_count" INTEGER,
ADD COLUMN "aggregation_selected_source_hash" TEXT;
UPDATE "measurements"
SET "aggregation_provenance" = 'LEGACY_UNKNOWN'
WHERE "source" = 'APPLE_HEALTH'
AND "external_id" LIKE 'stats:%';
-- Revision 1 denotes rows created under the pre-authority parser. New binaries
-- write revision 2 explicitly; the default stays legacy-safe during rollouts.
ALTER TABLE "import_jobs"
ADD COLUMN "parser_revision" INTEGER NOT NULL DEFAULT 1;
UPDATE "import_jobs"
SET "parser_revision" = 1;
@@ -0,0 +1,26 @@
-- WHOOP provider identities are globally owned by one local account.
-- Existing duplicate identities keep the earliest connection; ties are broken
-- by the primary key so every database chooses the same winner.
ALTER TABLE "whoop_connections"
ALTER COLUMN "whoop_user_id" DROP NOT NULL;
WITH ranked_connections AS (
SELECT
"id",
ROW_NUMBER() OVER (
PARTITION BY "whoop_user_id"
ORDER BY "created_at" ASC, "id" ASC
) AS owner_rank
FROM "whoop_connections"
WHERE "whoop_user_id" IS NOT NULL
)
UPDATE "whoop_connections" AS connection
SET
"whoop_user_id" = NULL,
"updated_at" = CURRENT_TIMESTAMP
FROM ranked_connections AS ranked
WHERE connection."id" = ranked."id"
AND ranked.owner_rank > 1;
CREATE UNIQUE INDEX "whoop_connections_whoop_user_id_key"
ON "whoop_connections"("whoop_user_id");
@@ -0,0 +1,11 @@
ALTER TABLE "withings_connections"
ADD COLUMN "webhook_subscription_state" JSONB,
ADD COLUMN "webhook_subscription_retry_at" TIMESTAMP(3);
-- Existing connections have no durable outcome yet. Mark them due so the
-- hourly repair reconciles each category; status 294 is idempotent success.
UPDATE "withings_connections"
SET "webhook_subscription_retry_at" = CURRENT_TIMESTAMP;
CREATE INDEX "withings_connections_webhook_subscription_retry_at_idx"
ON "withings_connections"("webhook_subscription_retry_at");
@@ -0,0 +1,36 @@
-- Durable medication-intake imports keep the validated payload and monotonic
-- progress in Postgres so queue retries and process restarts cannot duplicate
-- intake or inventory side effects. This is additive and preserves all rows.
CREATE TABLE "medication_intake_import_jobs" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"medication_id" TEXT NOT NULL,
"pg_boss_job_id" TEXT,
"status" TEXT NOT NULL DEFAULT 'queued',
"payload" JSONB NOT NULL,
"progress" JSONB NOT NULL DEFAULT '{}',
"result" JSONB,
"failure_reason" TEXT,
"heartbeat_at" TIMESTAMP(3),
"started_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "medication_intake_import_jobs_pkey" PRIMARY KEY ("id"),
CONSTRAINT "medication_intake_import_jobs_status_check"
CHECK ("status" IN ('queued', 'running', 'done', 'failed')),
CONSTRAINT "medication_intake_import_jobs_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "medication_intake_import_jobs_medication_id_fkey"
FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "medication_intake_import_jobs_pg_boss_job_id_key"
ON "medication_intake_import_jobs"("pg_boss_job_id");
CREATE INDEX "medication_intake_import_jobs_user_id_created_at_idx"
ON "medication_intake_import_jobs"("user_id", "created_at");
CREATE INDEX "medication_intake_import_jobs_medication_id_created_at_idx"
ON "medication_intake_import_jobs"("medication_id", "created_at");
CREATE INDEX "medication_intake_import_jobs_status_heartbeat_at_idx"
ON "medication_intake_import_jobs"("status", "heartbeat_at");
+65 -8
View File
@@ -737,6 +737,7 @@ model User {
// pg-boss job state so the polling endpoint can read live per-
// phase progress without round-tripping through pg-boss internals.
importJobs ImportJob[]
medicationIntakeImportJobs MedicationIntakeImportJob[]
// v1.5.0 — persistent measurement rollup table. Carries the
// count/min/max/mean/sd/slope/r2 stats per (type, granularity,
// bucketStart) so the analytics + comprehensive read paths bypass
@@ -1592,6 +1593,14 @@ enum SleepStage {
@@map("sleep_stage")
}
enum MeasurementAggregationProvenance {
HEALTHKIT_STATISTICS
EXPORT_XML_SOURCE_MAX
LEGACY_UNKNOWN
@@map("measurement_aggregation_provenance")
}
model Measurement {
id String @id @default(cuid())
userId String @map("user_id")
@@ -1625,6 +1634,18 @@ model Measurement {
// sample's `metadata.HKWasUserEntered`, Withings model code, …).
// Stored as a string so the ingest path can replay it back unchanged.
externalSourceVersion String? @map("external_source_version")
// Authority of an aggregate stored under a `stats:*` identity. NULL for
// ordinary point/sample rows. Native HealthKit statistics outrank the
// source-day estimate derived from export.xml; migrated ambiguous rows are
// explicitly LEGACY_UNKNOWN until a native sync or archive reprocess repairs
// them.
aggregationProvenance MeasurementAggregationProvenance? @map("aggregation_provenance")
// Distinct source identities contributing to an export.xml source-day
// estimate. Source labels themselves are never persisted.
aggregationContributorCount Int? @map("aggregation_contributor_count")
// SHA-256 of the deterministically selected source identity. Non-identifying
// provenance for repeatability without retaining device/source labels.
aggregationSelectedSourceHash String? @map("aggregation_selected_source_hash")
// Only set for BLOOD_GLUCOSE measurements. NULL for all others.
glucoseContext GlucoseContext? @map("glucose_context")
// v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL
@@ -2399,6 +2420,7 @@ model Medication {
schedules MedicationSchedule[]
intakeEvents MedicationIntakeEvent[]
intakeImportJobs MedicationIntakeImportJob[]
phaseConfig ReminderPhaseConfig?
telegramMessages TelegramReminderMessage[]
doseChanges MedicationDoseChange[]
@@ -3378,11 +3400,17 @@ model WithingsConnection {
/// and re-syncs with the corrected END stamp, then sets this so the discovery
/// query drops the connection. Null = backfill not yet run.
sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
/// v1.32 — durable per-category webhook subscription outcomes. The JSON
/// payload is versioned and validated before use; the timestamp mirrors the
/// earliest transient/pending category retry for indexed hourly discovery.
webhookSubscriptionState Json? @map("webhook_subscription_state")
webhookSubscriptionRetryAt DateTime? @map("webhook_subscription_retry_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([webhookSubscriptionRetryAt])
@@map("withings_connections")
}
@@ -3432,7 +3460,7 @@ model WithingsOAuthState {
model WhoopConnection {
id String @id @default(cuid())
userId String @unique @map("user_id")
whoopUserId String @map("whoop_user_id")
whoopUserId String? @unique @map("whoop_user_id")
accessToken String @map("access_token") // encrypted at app level
refreshToken String @map("refresh_token") // encrypted at app level
tokenExpiresAt DateTime @map("token_expires_at")
@@ -4284,6 +4312,9 @@ model ImportJob {
/// kick-off endpoint short-circuit a re-upload of the exact same
/// file back to the existing job id without re-queueing.
uploadSha256 String? @map("upload_sha256")
/// Parser semantics revision. Existing archives migrated at revision 1 can
/// be deliberately reprocessed when the current parser revision advances.
parserRevision Int @default(1) @map("parser_revision")
/// Apple's `ExportDate` attribute string-formatted, parsed during
/// the unpack phase. NULL until the parser sees it.
exportedAt DateTime? @map("exported_at")
@@ -4310,6 +4341,33 @@ model ImportJob {
@@map("import_jobs")
}
// Durable medication-intake CSV imports. The validated, normalized payload
// stays in Postgres so retries never depend on request-process memory.
model MedicationIntakeImportJob {
id String @id @default(cuid())
userId String @map("user_id")
medicationId String @map("medication_id")
pgBossJobId String? @unique @map("pg_boss_job_id")
status String @default("queued")
payload Json
progress Json @default("{}")
result Json?
failureReason String? @map("failure_reason")
heartbeatAt DateTime? @map("heartbeat_at")
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
@@index([medicationId, createdAt])
@@index([status, heartbeatAt])
@@map("medication_intake_import_jobs")
}
// ─── Rate Limiting ──────────────────────────────────────────
model RateLimit {
@@ -5353,11 +5411,10 @@ model InsightNarrative {
// `data-arrival` worker after a SALIENT ingest — never by a backfill, which
// the seam classifier drops before anything is enqueued.
//
// The unique index is load-bearing three times over: it is the "just in"
// marker the reaction surfaces read, it is the at-most-one-generated-line
// per kind per day claim (inserted with ON CONFLICT DO NOTHING, so a lost
// race means another worker already owns today's line), and it is the audit
// trail for what the spine actually reacted to.
// The unique index is load-bearing twice over: it is the "just in" marker the
// reaction surfaces read and the durable identity whose newest same-day
// revision owns generation. A strictly newer arrival atomically invalidates
// stale generation state; equal or older deliveries are deduplicated.
model ArrivalReaction {
id String @id @default(cuid())
userId String @map("user_id")
@@ -5394,8 +5451,8 @@ model ArrivalReaction {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// The once-per-kind-per-local-day claim. Do not relax this to include
// `refId` — the day scope is the throttle.
// One mutable marker per kind and local day. Do not include `refId`: newer
// same-day referents replace this row atomically.
@@unique([userId, kind, localDate])
// The reaction surfaces read "today's rows for this user".
@@index([userId, localDate])
+47 -15
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.31.2";
/* @sw-version-fallback */ "v1.32.2";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -202,9 +202,12 @@ async function cacheFirst(request, cacheName) {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
trimCache(cacheName, MAX_STATIC_ENTRIES);
await bestEffortCacheWrite(
cacheName,
request,
response.clone(),
MAX_STATIC_ENTRIES,
);
}
return response;
}
@@ -225,7 +228,6 @@ async function cacheFirst(request, cacheName) {
* refusal discipline the idempotency cache uses.
*/
async function networkFirstApi(request) {
const cache = await caches.open(DATA_CACHE);
try {
const response = await fetch(request);
@@ -236,17 +238,21 @@ async function networkFirstApi(request) {
.text()
.catch(() => undefined);
if (isCacheableApiResponse(response, bodyText)) {
const toStore = new Response(bodyText, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
await cache.put(request, toStore);
trimCache(DATA_CACHE, MAX_DATA_ENTRIES);
await bestEffortCacheWrite(
DATA_CACHE,
request,
new Response(bodyText, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
}),
MAX_DATA_ENTRIES,
);
}
return response;
} catch {
// Network failed (offline) — serve the last cached copy if we have one.
const cache = await caches.open(DATA_CACHE);
const cached = await cache.match(request);
if (cached) return cached;
@@ -303,9 +309,14 @@ async function networkFirst(event, cacheName) {
(event.preloadResponse ? await event.preloadResponse : null) ||
(await fetch(request));
if (response.ok && isCacheableNavigation(request, response)) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
trimCache(cacheName, MAX_PAGE_ENTRIES);
event.waitUntil(
bestEffortCacheWrite(
cacheName,
request,
response.clone(),
MAX_PAGE_ENTRIES,
),
);
}
return response;
} catch {
@@ -336,6 +347,27 @@ async function networkFirst(event, cacheName) {
}
}
/**
* Cache a successful network response without making CacheStorage
* availability part of the fetch contract. Quota, eviction, or trim failures
* are intentionally swallowed: caching may improve a later request, but it
* must never replace this request's already-successful network response.
*/
async function bestEffortCacheWrite(
cacheName,
request,
response,
maxEntries,
) {
try {
const cache = await caches.open(cacheName);
await cache.put(request, response);
await trimCache(cacheName, maxEntries);
} catch {
// CacheStorage is an optional optimization.
}
}
/**
* Trim cache to maxEntries by removing oldest entries. `keys()` returns
* insertion order, so one read + a single pass over the excess prefix
+7 -1
View File
@@ -4,11 +4,17 @@ One-shot operator and build scripts. These are not part of the running applicati
## How to run them
From a source checkout:
```bash
pnpm dlx tsx scripts/<file>.ts
```
Use `pnpm dlx tsx`, not bare `pnpm tsx`. The production standalone image strips `tsx`, so the bare invocation fails inside the container. `scripts/` is excluded from the project typecheck.
The production image intentionally removes npm, pnpm, and Corepack. Only
container-safe scripts named by an operator runbook use its pinned
`healthlog-tsx` launcher; scripts that import the full application dependency
graph must run from a source checkout. `scripts/` is excluded from the project
typecheck.
## What lives here
+38
View File
@@ -7,6 +7,8 @@
* exit-code behaviour is best left to a smoke test in CI (deferred to
* v1.4.43).
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { checkEnv, parseEnvFile } from "../check-env";
@@ -252,3 +254,39 @@ describe("checkEnv — optional groups (informational)", () => {
expect(apnsKeyId.required).toBe(false);
});
});
describe("environment inventory", () => {
it("lists every optional AI runtime variable in compose, both examples, and the manifest", () => {
const repositoryRoot = join(__dirname, "../..");
const variableNames = [
"ALLOW_LOCAL_AI_PRIVATE_HOSTS",
"INSIGHTS_MAX_TOKENS",
];
const compose = readFileSync(
join(repositoryRoot, "docker-compose.yml"),
"utf8",
);
const exampleEnvironments = [
readFileSync(join(repositoryRoot, ".env.example"), "utf8"),
readFileSync(join(repositoryRoot, ".env.production.example"), "utf8"),
];
const manifest = JSON.parse(
readFileSync(join(repositoryRoot, "scripts/env-manifest.json"), "utf8"),
) as {
groups: Array<{ variables: Array<{ name: string }> }>;
};
const manifestNames = manifest.groups.flatMap((group) =>
group.variables.map((variable) => variable.name),
);
for (const variableName of variableNames) {
expect(compose).toMatch(new RegExp(`^\\s*${variableName}:`, "m"));
for (const exampleEnvironment of exampleEnvironments) {
expect(exampleEnvironment).toMatch(
new RegExp(`^# ${variableName}=`, "m"),
);
}
expect(manifestNames).toContain(variableName);
}
});
});
+143
View File
@@ -0,0 +1,143 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { parse } from "yaml";
import integrationConfig from "../../vitest.integration.config.mts";
import unitConfig from "../../vitest.config.mts";
function repositoryRoot(): string {
const testPath = expect.getState().testPath;
if (!testPath) throw new Error("Vitest did not expose the current test path");
return join(dirname(testPath), "../..");
}
function readRepoFile(path: string): string {
return readFileSync(join(repositoryRoot(), path), "utf8");
}
type WorkflowStep = {
if?: string;
uses?: string;
with?: Record<string, string | number>;
};
type WorkflowJob = {
env?: Record<string, string>;
steps?: WorkflowStep[];
};
type Workflow = {
jobs: Record<string, WorkflowJob>;
};
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
describe("Playwright CI determinism", () => {
it("fails CI when a retry resolves a flaky test", async () => {
vi.stubEnv("CI", "true");
// Re-import after stubbing CI so config conditionals are evaluated as CI.
const { default: config } = await import("../../playwright.config");
expect(config.retries).toBe(2);
expect(config.failOnFlakyTests).toBe(true);
expect(config.reporter).toEqual([["github"], ["html", { open: "never" }]]);
expect(config.webServer).toMatchObject({
command: expect.stringContaining(
`${JSON.stringify(process.execPath)} .next/standalone/server.js`,
),
env: expect.objectContaining({
NATIVE_CANVAS: "off",
}),
});
});
it("retains the Playwright report after every workflow outcome", () => {
const workflow = parse(
readRepoFile(".github/workflows/e2e.yml"),
) as Workflow;
const upload = workflow.jobs.e2e?.steps?.find((step) =>
step.uses?.startsWith("actions/upload-artifact@"),
);
expect(upload).toBeDefined();
expect(upload?.if).toBe("always()");
expect(upload?.with).toMatchObject({
name: "playwright-report",
path: "playwright-report/",
"retention-days": 7,
"if-no-files-found": "ignore",
});
});
it("uses production-valid secrets and plain-HTTP cookies", () => {
const workflow = parse(
readRepoFile(".github/workflows/e2e.yml"),
) as Workflow;
const env = workflow.jobs.e2e?.env;
expect(env?.API_TOKEN_HMAC_KEY).toMatch(/^[0-9a-f]{64}$/);
expect(env?.SESSION_COOKIE_SECURE).toBe("false");
});
});
describe("local worktree isolation", () => {
it("keeps nested worktree suites out of the root unit run", () => {
expect(unitConfig.test?.exclude ?? []).toContain(".worktrees/**");
});
});
describe("integration environment isolation", () => {
it("registers a worker setup bridge and authoritative test secrets", () => {
const test = integrationConfig.test;
expect(test?.setupFiles ?? []).toContain(
"./tests/integration/environment-setup.ts",
);
expect(test?.env).toMatchObject({
TZ: "UTC",
ENCRYPTION_KEY:
"0000000000000000000000000000000000000000000000000000000000000000",
ENCRYPTION_KEYS: "",
ENCRYPTION_ACTIVE_KEY_ID: "",
API_TOKEN_HMAC_KEY:
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
SESSION_SECRET: "integration-test-session-secret-32-bytes",
});
expect(
existsSync(
join(repositoryRoot(), "tests/integration/environment-setup.ts"),
),
).toBe(true);
});
});
describe("container dependency installation", () => {
it("copies the prepare helper before the Git-free frozen install", () => {
const dockerfile = readRepoFile("Dockerfile");
expect(dockerfile).toContain(
"COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./\n" +
"COPY scripts/prepare.mjs scripts/prepare.mjs\n" +
"RUN pnpm install --frozen-lockfile --prod=false",
);
});
});
describe("production dependency advisory floors", () => {
it("pins vulnerable transitive ranges to compatible patched versions", () => {
const workspace = parse(readRepoFile("pnpm-workspace.yaml")) as {
overrides?: Record<string, string>;
};
expect(workspace.overrides).toMatchObject({
"dompurify@<=3.4.10": "^3.4.11",
"postcss@<8.5.10": "^8.5.10",
"@hono/node-server@<1.19.13": "^1.19.13",
"hono@<4.12.25": "^4.12.25",
});
});
});
+135
View File
@@ -0,0 +1,135 @@
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import {
chmodSync,
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const path of temporaryDirectories.splice(0)) {
rmSync(path, { recursive: true, force: true });
}
});
type GitMarker = "absent" | "directory" | "file";
type PrepareResult = {
invocationLog: string | null;
result: SpawnSyncReturns<string>;
};
function runPrepareFixture(
gitMarker: GitMarker,
lefthookStatus = 0,
): PrepareResult {
const testPath = expect.getState().testPath;
if (!testPath) throw new Error("Vitest did not expose the current test path");
const root = join(dirname(testPath), "../..");
const directory = mkdtempSync(join(tmpdir(), "healthlog-prepare-"));
temporaryDirectories.push(directory);
const packageJson = JSON.parse(
readFileSync(join(root, "package.json"), "utf8"),
) as {
scripts: { prepare: string };
};
const prepareHelper = join(root, "scripts/prepare.mjs");
if (existsSync(prepareHelper)) {
mkdirSync(join(directory, "scripts"));
cpSync(prepareHelper, join(directory, "scripts/prepare.mjs"));
}
if (gitMarker === "directory") {
mkdirSync(join(directory, ".git"));
} else if (gitMarker === "file") {
writeFileSync(
join(directory, ".git"),
"gitdir: ../shared/.git/worktrees/fixture\n",
);
}
const binDirectory = join(directory, "bin");
const invocationLog = join(directory, "lefthook.log");
mkdirSync(binDirectory);
const lefthook = join(
binDirectory,
process.platform === "win32" ? "lefthook.cmd" : "lefthook",
);
writeFileSync(
lefthook,
process.platform === "win32"
? '@echo off\r\necho %*>>"%FIXTURE_LEFTHOOK_LOG%"\r\nexit /b %FIXTURE_LEFTHOOK_STATUS%\r\n'
: '#!/bin/sh\nprintf \'%s\\n\' "$*" >> "$FIXTURE_LEFTHOOK_LOG"\nexit "$FIXTURE_LEFTHOOK_STATUS"\n',
);
if (process.platform !== "win32") chmodSync(lefthook, 0o755);
const result = spawnSync(packageJson.scripts.prepare, {
cwd: directory,
encoding: "utf8",
env: {
...process.env,
PATH: `${binDirectory}:${process.env.PATH ?? ""}`,
FIXTURE_LEFTHOOK_LOG: invocationLog,
FIXTURE_LEFTHOOK_STATUS: String(lefthookStatus),
},
shell: true,
});
return {
result,
invocationLog: existsSync(invocationLog)
? readFileSync(invocationLog, "utf8").replaceAll("\r\n", "\n")
: null,
};
}
describe("package prepare hook installation", () => {
it("enables command-shim execution on Windows only", () => {
const testPath = expect.getState().testPath;
if (!testPath)
throw new Error("Vitest did not expose the current test path");
const source = readFileSync(
join(dirname(testPath), "../prepare.mjs"),
"utf8",
);
expect(source).toContain('shell: process.platform === "win32"');
});
it("skips hook installation only when Git metadata is absent", () => {
const { invocationLog, result } = runPrepareFixture("absent");
expect(result.status, result.stderr).toBe(0);
expect(invocationLog).toBeNull();
});
it.each(["directory", "file"] as const)(
"installs hooks when .git is a %s",
(gitMarker) => {
const { invocationLog, result } = runPrepareFixture(gitMarker);
expect(result.status, result.stderr).toBe(0);
expect(invocationLog).toBe("install --force\n");
},
);
it.each(["directory", "file"] as const)(
"propagates Lefthook failure when .git is a %s",
(gitMarker) => {
const { invocationLog, result } = runPrepareFixture(gitMarker, 42);
expect(invocationLog).toBe("install --force\n");
expect(result.status).toBe(42);
},
);
});
+473
View File
@@ -0,0 +1,473 @@
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { parse } from "yaml";
function readRepoFile(path: string): string {
const testPath = expect.getState().testPath;
if (!testPath) throw new Error("Vitest did not expose the current test path");
return readFileSync(join(dirname(testPath), "../..", path), "utf8");
}
type WorkflowStep = {
name?: string;
run?: string;
"continue-on-error"?: boolean;
};
type WorkflowJob = {
needs?: string | string[];
outputs?: Record<string, string>;
steps?: WorkflowStep[];
uses?: string;
with?: Record<string, string>;
};
type Workflow = {
on?: {
workflow_call?: { inputs?: Record<string, { required?: boolean }> };
};
jobs: Record<string, WorkflowJob>;
};
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const path of temporaryDirectories.splice(0)) {
rmSync(path, { recursive: true, force: true });
}
});
function loadWorkflow(name: string): Workflow {
return parse(readRepoFile(join(".github/workflows", name))) as Workflow;
}
function stepScript(
workflow: Workflow,
jobName: string,
stepName: string,
): string {
const step = workflow.jobs[jobName]?.steps?.find(
(candidate) => candidate.name === stepName,
);
if (!step?.run) {
throw new Error(`Missing ${jobName}/${stepName} run step`);
}
return step.run;
}
function runReleasePolicy(
overrides: Record<string, string> = {},
): SpawnSyncReturns<string> {
const directory = mkdtempSync(join(tmpdir(), "healthlog-release-policy-"));
temporaryDirectories.push(directory);
const binDirectory = join(directory, "bin");
mkdirSync(binDirectory);
writeFileSync(
join(directory, "package.json"),
JSON.stringify({ version: overrides.FIXTURE_PACKAGE_VERSION ?? "1.31.4" }),
);
const gitPath = join(binDirectory, "git");
writeFileSync(
gitPath,
`#!/bin/sh
if [ "$1" = "fetch" ]; then exit 0; fi
if [ "$1" = "merge-base" ] && [ "$FIXTURE_MAIN_ANCESTRY" = "yes" ]; then exit 0; fi
exit 1
`,
);
chmodSync(gitPath, 0o755);
const ghPath = join(binDirectory, "gh");
writeFileSync(
ghPath,
`#!/bin/sh
if [ "$FIXTURE_PRIOR_CI" = "yes" ]; then printf '1\\n'; else printf '0\\n'; fi
`,
);
chmodSync(ghPath, 0o755);
const workflow = loadWorkflow("docker-publish.yml");
return spawnSync(
"bash",
[
"-euo",
"pipefail",
"-c",
stepScript(workflow, "release-policy", "Validate release policy"),
],
{
cwd: directory,
encoding: "utf8",
env: {
...process.env,
PATH: `${binDirectory}:${process.env.PATH ?? ""}`,
GITHUB_EVENT_NAME: "push",
GITHUB_REF_TYPE: "tag",
GITHUB_REF_NAME: "v1.31.4",
GITHUB_SHA: "0123456789abcdef0123456789abcdef01234567",
GITHUB_REPOSITORY: "MBombeck/HealthLog",
GH_TOKEN: "fixture-token",
FIXTURE_MAIN_ANCESTRY: "yes",
FIXTURE_PRIOR_CI: "yes",
...overrides,
},
},
);
}
function runExactPull(resolvedDigest: string): {
result: SpawnSyncReturns<string>;
commands: string;
} {
const directory = mkdtempSync(join(tmpdir(), "healthlog-image-pull-"));
temporaryDirectories.push(directory);
const dockerPath = join(directory, "docker");
const commandLog = join(directory, "commands.log");
writeFileSync(
dockerPath,
`#!/bin/sh
printf '%s\\n' "$*" >> "$FIXTURE_COMMAND_LOG"
if [ "$1" = "pull" ]; then exit 0; fi
if [ "$1" = "buildx" ] && [ "$2" = "imagetools" ] && [ "$3" = "inspect" ]; then
printf '\"%s\"\\n' "$FIXTURE_RESOLVED_DIGEST"
exit 0
fi
exit 1
`,
);
chmodSync(dockerPath, 0o755);
const workflow = loadWorkflow("post-publish-verify.yml");
const result = spawnSync(
"bash",
[
"-euo",
"pipefail",
"-c",
stepScript(workflow, "verify", "Pull exact image"),
],
{
encoding: "utf8",
env: {
...process.env,
PATH: `${directory}:${process.env.PATH ?? ""}`,
IMAGE_REF: "ghcr.io/mbombeck/healthlog",
IMAGE_TAG: "v1.31.4",
IMAGE_DIGEST:
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
FIXTURE_COMMAND_LOG: commandLog,
FIXTURE_RESOLVED_DIGEST: resolvedDigest,
},
},
);
return {
result,
commands: readFileSync(commandLog, "utf8"),
};
}
function runPromotion(
overrides: Record<string, string> = {},
): SpawnSyncReturns<string> {
const directory = mkdtempSync(join(tmpdir(), "healthlog-promotion-"));
temporaryDirectories.push(directory);
const sleepPath = join(directory, "sleep");
writeFileSync(sleepPath, "#!/bin/sh\nexit 0\n");
chmodSync(sleepPath, 0o755);
const curlPath = join(directory, "curl");
writeFileSync(
curlPath,
`#!/bin/sh
printf '{\"deployment_uuid\":\"fixture\"}\\n__HTTP_STATUS__:%s' "$FIXTURE_HTTP_STATUS"
`,
);
chmodSync(curlPath, 0o755);
const workflow = loadWorkflow("docker-publish.yml");
return spawnSync(
"bash",
[
"-euo",
"pipefail",
"-c",
stepScript(workflow, "promote", "Trigger Coolify deploy"),
],
{
encoding: "utf8",
env: {
...process.env,
PATH: `${directory}:${process.env.PATH ?? ""}`,
COOLIFY_AUTO_DEPLOY: "on",
COOLIFY_WEBHOOK: "https://coolify.example/deploy?uuid=fixture",
COOLIFY_TOKEN: "fixture-token",
IMAGE_TAG: "v1.31.4",
IMAGE_DIGEST:
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
BUILD_SHA: "0123456789ab",
FIXTURE_HTTP_STATUS: "200",
...overrides,
},
},
);
}
describe("release container inputs", () => {
it("pins every Node stage to one reviewed multi-arch digest", () => {
const dockerfile = readRepoFile("Dockerfile");
const fromLines = dockerfile.match(/^FROM node:[^\n]+$/gm) ?? [];
expect(fromLines).toHaveLength(3);
expect(new Set(fromLines.map((line) => line.split(" AS ")[0]))).toEqual(
new Set([
"FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2",
]),
);
});
it("pins pnpm for builds and removes package managers from runtime", () => {
const dockerfile = readRepoFile("Dockerfile");
const packageJson = JSON.parse(readRepoFile("package.json")) as {
packageManager: string;
};
expect(packageJson.packageManager).toBe("pnpm@11.15.1");
expect(dockerfile).not.toContain("pnpm@latest");
expect(
dockerfile.match(/corepack prepare pnpm@11\.15\.1 --activate/g),
).toHaveLength(2);
expect(dockerfile).toContain(
"rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack",
);
expect(dockerfile).toContain("tsx@4.23.1");
expect(dockerfile).toContain(
"ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx",
);
expect(dockerfile).toContain("dotenv@17.4.2");
expect(dockerfile).toContain(
"ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv",
);
expect(dockerfile).toContain(
"ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma",
);
});
it("keeps linked worktrees out of the Docker build context", () => {
const dockerignore = readRepoFile(".dockerignore");
expect(dockerignore.split(/\r?\n/)).toContain(".worktrees");
});
it("bundles Prisma's adapter and verifies only runtime externals", () => {
const dockerfile = readRepoFile("Dockerfile");
const nextConfig = readRepoFile("next.config.ts");
expect(dockerfile).not.toContain("/opt/pg-boss");
expect(dockerfile).not.toContain('NODE_PATH="/opt/pg-boss/node_modules"');
expect(dockerfile).toMatch(
/require\.resolve\(['"]pg-boss['"]\)[\s\S]*require\.resolve\(['"]pg['"]\)/,
);
expect(dockerfile).not.toContain("require.resolve('@prisma/adapter-pg')");
expect(dockerfile).not.toContain("require.resolve('@prisma/client')");
expect(nextConfig).not.toMatch(
/serverExternalPackages:\s*\[[\s\S]*["']@prisma\/adapter-pg["']/,
);
});
});
describe("release pre-build policy", () => {
it("accepts a strict SemVer tag that matches package.json on main with prior CI", () => {
const result = runReleasePolicy();
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("Release policy passed");
});
it.each(["v1.2", "v01.2.3", "v1.2.3-rc.1", "1.2.3"])(
"rejects non-strict release tag %s",
(tag) => {
const result = runReleasePolicy({ GITHUB_REF_NAME: tag });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("strict SemVer");
},
);
it("rejects a tag that does not match package.json", () => {
const result = runReleasePolicy({ FIXTURE_PACKAGE_VERSION: "1.31.3" });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("package.json version");
});
it("rejects a release commit outside main ancestry", () => {
const result = runReleasePolicy({ FIXTURE_MAIN_ANCESTRY: "no" });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("ancestor of origin/main");
});
it("rejects a release commit without successful prior quality CI", () => {
const result = runReleasePolicy({ FIXTURE_PRIOR_CI: "no" });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("successful prior Security & Quality");
});
it("gates every image build on the release policy", () => {
const workflow = loadWorkflow("docker-publish.yml");
const needs = workflow.jobs.build?.needs;
expect(Array.isArray(needs) ? needs : [needs]).toContain("release-policy");
});
});
describe("exact post-publish verification", () => {
it("passes the published ref, tag, and digest into the blocking verifier", () => {
const workflow = loadWorkflow("docker-publish.yml");
const merge = workflow.jobs.merge;
const verify = workflow.jobs["verify-published-image"];
expect(merge?.outputs).toEqual({
image_ref: "${{ steps.published.outputs.image_ref }}",
image_tag: "${{ steps.published.outputs.image_tag }}",
image_digest: "${{ steps.published.outputs.image_digest }}",
build_sha: "${{ steps.published.outputs.build_sha }}",
});
expect(verify?.uses).toBe("./.github/workflows/post-publish-verify.yml");
expect(verify?.needs).toBe("merge");
expect(verify?.with).toEqual({
image_ref: "${{ needs.merge.outputs.image_ref }}",
image_tag: "${{ needs.merge.outputs.image_tag }}",
image_digest: "${{ needs.merge.outputs.image_digest }}",
build_sha: "${{ needs.merge.outputs.build_sha }}",
});
});
it("requires exact image identity inputs for every invocation", () => {
const workflow = loadWorkflow("post-publish-verify.yml");
const inputs = workflow.on?.workflow_call?.inputs;
expect(inputs?.image_ref?.required).toBe(true);
expect(inputs?.image_tag?.required).toBe(true);
expect(inputs?.image_digest?.required).toBe(true);
expect(inputs?.build_sha?.required).toBe(true);
});
it("pulls the exact tag and digest when the registry resolves correctly", () => {
const digest =
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const { result, commands } = runExactPull(digest);
expect(result.status, result.stderr).toBe(0);
expect(commands).toContain("pull ghcr.io/mbombeck/healthlog:v1.31.4");
expect(commands).toContain(
"pull ghcr.io/mbombeck/healthlog@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
);
});
it("fails closed when the published tag resolves to another digest", () => {
const { result } = runExactPull(
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("digest mismatch");
});
it("keeps pull, migration, start, version, and health checks blocking", () => {
const workflow = loadWorkflow("post-publish-verify.yml");
const steps = workflow.jobs.verify?.steps ?? [];
const requiredSteps = [
"Pull exact image",
"Run migrations",
"Start exact image",
"Verify exact version",
"Verify health",
];
for (const name of requiredSteps) {
const step = steps.find((candidate) => candidate.name === name);
expect(step, `missing ${name}`).toBeDefined();
expect(step?.["continue-on-error"], name).not.toBe(true);
}
expect(stepScript(workflow, "verify", "Run migrations")).toContain(
"${IMAGE_REF}@${IMAGE_DIGEST}",
);
const startScript = stepScript(workflow, "verify", "Start exact image");
expect(startScript).toContain("${IMAGE_REF}@${IMAGE_DIGEST}");
expect(startScript).toMatch(/API_TOKEN_HMAC_KEY=[0-9a-f]{64}(?:\s|\\)/);
const versionScript = stepScript(
workflow,
"verify",
"Verify exact version",
);
expect(versionScript).toContain('EXPECTED_VERSION="${IMAGE_TAG#v}"');
expect(versionScript).toContain('jq -e --arg expected "$EXPECTED_VERSION"');
expect(stepScript(workflow, "verify", "Verify health")).toContain(
"jq -e '.status == \"ok\"'",
);
});
});
describe("Coolify promotion policy", () => {
it("waits for exact-image verification and keeps enabled promotion blocking", () => {
const workflow = loadWorkflow("docker-publish.yml");
const promotion = workflow.jobs.promote;
const needs = promotion?.needs;
const trigger = promotion?.steps?.find(
(step) => step.name === "Trigger Coolify deploy",
);
expect(Array.isArray(needs) ? needs : [needs]).toEqual(
expect.arrayContaining(["merge", "verify-published-image"]),
);
expect(trigger).toBeDefined();
expect(trigger?.["continue-on-error"]).not.toBe(true);
expect(
workflow.jobs.merge?.steps?.some(
(step) => step.name === "Trigger Coolify deploy",
),
).toBe(false);
});
it("accepts an enabled promotion only after a successful webhook response", () => {
const result = runPromotion();
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("Coolify deploy queued");
});
it.each([
["webhook", { COOLIFY_WEBHOOK: "" }],
["token", { COOLIFY_TOKEN: "" }],
])("rejects enabled promotion with missing %s credentials", (_name, env) => {
const result = runPromotion(env);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("COOLIFY_AUTO_DEPLOY=on");
});
it("fails enabled promotion when Coolify rejects the webhook", () => {
const result = runPromotion({ FIXTURE_HTTP_STATUS: "503" });
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("returned HTTP 503");
});
it("allows explicit disabled promotion without credentials", () => {
const result = runPromotion({
COOLIFY_AUTO_DEPLOY: "off",
COOLIFY_WEBHOOK: "",
COOLIFY_TOKEN: "",
});
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("COOLIFY_AUTO_DEPLOY=off");
});
});
+15
View File
@@ -117,6 +117,21 @@
}
]
},
{
"name": "AI Provider",
"description": "Optional runtime policy and output tuning for user-configured AI providers. Each variable stands alone and has a safe default when unset.",
"required": false,
"variables": [
{
"name": "ALLOW_LOCAL_AI_PRIVATE_HOSTS",
"purpose": "Local-AI SSRF opt-in. Unset rejects private/internal hosts; set an exact comma-separated host allowlist or \"true\" to allow any private host."
},
{
"name": "INSIGHTS_MAX_TOKENS",
"purpose": "Daily-briefing output-token ceiling. Default 2500, clamped to 500-8000."
}
]
},
{
"name": "APNs (iOS push)",
"description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic <all-or-none> required row whenever 1-3 of the 4 are set.",
+17
View File
@@ -0,0 +1,17 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
if (!existsSync(".git")) {
console.log("Skipping Lefthook install: Git metadata is absent");
} else {
const result = spawnSync("lefthook", ["install", "--force"], {
stdio: "inherit",
shell: process.platform === "win32",
});
if (result.error) {
console.error(`Failed to run Lefthook: ${result.error.message}`);
process.exitCode = 1;
} else {
process.exitCode = result.status ?? 1;
}
}
+12 -19
View File
@@ -64,32 +64,25 @@
*
* SELF-CONTAINED by design (v1.16.0)
* ----------------------------------
* The production standalone image ships neither the project
* `node_modules` tree nor the `@/` path alias, so the previous
* `dotenv/config` + `@/lib/db` import chain died with "Cannot find
* module" inside the container. This script therefore depends on
* exactly ONE package — `pg` — and reads `DATABASE_URL` straight from
* the environment (no dotenv). Inside the production image `pg` resolves
* via `NODE_PATH=/opt/pg-boss/node_modules` (Dockerfile installs
* `pg@8.x` there); in a repo checkout it resolves from the project
* `node_modules`. The `pnpm dlx` invocation below supplies `tsx` (the
* standalone image strips it) and pins `pg` for any environment where
* neither resolution path exists.
* The production standalone image does not expose the project package
* manager. It provides a pinned `healthlog-tsx` maintenance launcher, while
* `pg` resolves from the standalone runtime tree. In a repo checkout, run the
* same script through `pnpm dlx --package pg --package tsx tsx ...`.
*
* Usage
* -----
* Usage inside the production container
* -------------------------------------
* # dry-run (DATABASE_URL must point at the target database)
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
* healthlog-tsx scripts/repair-intake-anomalies.ts
*
* # scoped dry-run
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user <id>
* healthlog-tsx scripts/repair-intake-anomalies.ts --user <id>
*
* # apply
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
* healthlog-tsx scripts/repair-intake-anomalies.ts --fix
* healthlog-tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
*
* # create the proposed schedule revisions + startsOn fixes (defect 5)
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras
* healthlog-tsx scripts/repair-intake-anomalies.ts --backfill-eras
*/
import { Client, types } from "pg";
@@ -425,7 +418,7 @@ async function main(): Promise<void> {
if (!process.env.DATABASE_URL) {
console.error(
`${TAG} DATABASE_URL must be set (no dotenv — export it or prefix ` +
`the command: DATABASE_URL='postgresql://…' pnpm dlx …)`,
`the command: DATABASE_URL='postgresql://…' healthlog-tsx …)`,
);
process.exit(1);
}
+2 -3
View File
@@ -14,9 +14,8 @@
* BACKUP_S3_SECRET_KEY=... \
* BACKUP_S3_REGION=auto \
* BACKUP_ENCRYPTION_KEY=<hex64> \
* pnpm tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json
* pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json
*/
import "dotenv/config";
import { writeFileSync } from "node:fs";
import {
loadOffhostConfig,
@@ -82,7 +81,7 @@ async function main() {
?.slice("--user-id=".length);
if (!key) {
console.error(
"Usage: pnpm tsx scripts/restore-backup.ts <s3-key> [output-file] [--user-id=<id>]",
"Usage: pnpm dlx tsx scripts/restore-backup.ts <s3-key> [output-file] [--user-id=<id>]",
);
process.exit(1);
}
+2 -5
View File
@@ -1,11 +1,8 @@
/**
* Test script for notification delivery and reminder check.
*
* Usage (inside Docker container):
* npx tsx scripts/test-notifications.ts
*
* Or from host:
* docker compose exec app npx tsx scripts/test-notifications.ts
* Usage (from a source checkout with the target environment exported):
* pnpm dlx tsx scripts/test-notifications.ts
*
* What it does:
* 1. Lists all configured notification channels
@@ -112,6 +112,7 @@ const LEGIT_IDENTICAL = new Set<string>([
"settings.moodLogUrl",
"settings.sections.export.import.appleHealth.title",
"settings.sections.sources.sourceLabels.APPLE_HEALTH",
"settings.appleHealth.title",
"mood.tag.fastFood",
// Technical / config labels conventionally kept verbatim.
"admin.hostMetrics.diskBusyPercent",
+1 -1
View File
@@ -245,5 +245,5 @@ describe("i18n reverse coverage", () => {
"Wire a call site, or add a DB-driven prefix to DYNAMIC_ALLOWLIST_PREFIXES.",
);
}
});
}, 15_000);
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/process-type", () => ({
shouldRunWeb: () => false,
}));
import { proxy } from "../proxy";
function request(pathname: string): NextRequest {
return new NextRequest(`http://localhost${pathname}`);
}
describe("worker-only process HTTP boundary", () => {
it("allows the process-aware health endpoint through", () => {
const response = proxy(request("/api/health"));
expect(response.status).toBe(200);
expect(response.headers.get("X-HealthLog-Process-Type")).toBeNull();
});
it("continues to refuse non-health HTTP traffic", () => {
const response = proxy(request("/api/version"));
expect(response.status).toBe(503);
expect(response.headers.get("X-HealthLog-Process-Type")).toBe("worker");
});
});
+168 -5
View File
@@ -3,14 +3,16 @@
*
* `public/sw.js` is plain script (no module system), so the test boots it
* inside a vm context with a fake CacheStorage and dispatches the captured
* event listeners. Three behaviours are pinned:
* event listeners. Four behaviours are pinned:
*
* 1. the offline HTML fallback only serves a shell cached under the
* 1. cache write and trim failures never replace a successful network
* response for API, navigation, or static-asset requests;
* 2. the offline HTML fallback only serves a shell cached under the
* CURRENT `CACHE_VERSION` cache names (a stale pre-update shell would
* reference a chunk graph that no longer exists);
* 2. `activate` enables navigation preload and `networkFirst` consumes
* 3. `activate` enables navigation preload and `networkFirst` consumes
* `event.preloadResponse` when the browser supplies it;
* 3. `trimCache` reads the key list once and deletes the excess prefix
* 4. `trimCache` reads the key list once and deletes the excess prefix
* in a single pass (the previous loop re-fetched all keys per delete).
*/
import { describe, it, expect } from "vitest";
@@ -34,10 +36,15 @@ const FALLBACK_VERSION = /\/\* @sw-version-fallback \*\/\s*"(v[^"]*)"/.exec(
)![1];
const CURRENT_PAGE_CACHE = `healthlog-pages-${FALLBACK_VERSION}`;
const CURRENT_STATIC_CACHE = `healthlog-static-${FALLBACK_VERSION}`;
const CURRENT_DATA_CACHE = `healthlog-data-${FALLBACK_VERSION}`;
class FakeCache {
map = new Map<string, Response>();
keysCalls = 0;
putError: Error | undefined;
putDelay: Promise<void> | undefined;
onPut: (() => void) | undefined;
keysError: Error | undefined;
private keyOf(request: RequestInfo | URL): string {
if (typeof request === "string") return new URL(request, ORIGIN).href;
@@ -50,6 +57,9 @@ class FakeCache {
}
async put(request: RequestInfo | URL, response: Response): Promise<void> {
this.onPut?.();
if (this.putDelay) await this.putDelay;
if (this.putError) throw this.putError;
this.map.set(this.keyOf(request), response);
}
@@ -61,6 +71,7 @@ class FakeCache {
async keys(): Promise<Request[]> {
this.keysCalls += 1;
if (this.keysError) throw this.keysError;
return [...this.map.keys()].map((url) => new Request(url));
}
@@ -102,12 +113,14 @@ interface SwHarness {
listeners: Map<string, (event: unknown) => void>;
cacheStorage: FakeCacheStorage;
navPreload: { enabled: boolean; enable: () => Promise<void> };
lifetimePromises: Promise<unknown>[];
context: Record<string, unknown>;
}
function bootServiceWorker(): SwHarness {
const listeners = new Map<string, (event: unknown) => void>();
const cacheStorage = new FakeCacheStorage();
const lifetimePromises: Promise<unknown>[] = [];
const navPreload = {
enabled: false,
enable: async () => {
@@ -173,7 +186,7 @@ function bootServiceWorker(): SwHarness {
};
vm.createContext(context);
vm.runInContext(SW_SOURCE, context);
return { listeners, cacheStorage, navPreload, context };
return { listeners, cacheStorage, navPreload, lifetimePromises, context };
}
async function dispatchActivate(harness: SwHarness): Promise<void> {
@@ -200,11 +213,161 @@ function dispatchNavigationFetch(
captured = p;
},
preloadResponse,
waitUntil: (p: Promise<unknown>) => {
harness.lifetimePromises.push(p);
},
});
if (!captured) throw new Error("fetch handler did not respond");
return captured;
}
function dispatchApiFetch(harness: SwHarness, path: string): Promise<Response> {
let captured: Promise<Response> | null = null;
harness.listeners.get("fetch")!({
request: new Request(`${ORIGIN}${path}`),
respondWith: (p: Promise<Response>) => {
captured = p;
},
});
if (!captured) throw new Error("fetch handler did not respond");
return captured;
}
function dispatchAssetFetch(
harness: SwHarness,
path: string,
): Promise<Response> {
let captured: Promise<Response> | null = null;
harness.listeners.get("fetch")!({
request: new Request(`${ORIGIN}${path}`),
respondWith: (p: Promise<Response>) => {
captured = p;
},
});
if (!captured) throw new Error("fetch handler did not respond");
return captured;
}
describe("sw.js — best-effort cache writes", () => {
it("returns the original successful API response when cache.put rejects", async () => {
const harness = bootServiceWorker();
const dataCache = await harness.cacheStorage.open(CURRENT_DATA_CACHE);
dataCache.putError = new Error("CacheStorage quota exceeded");
const networkResponse = new Response(
JSON.stringify({ data: { weightKg: 80 }, error: null }),
{
status: 200,
headers: {
"Content-Type": "application/json",
"X-Network-Response": "original",
},
},
);
harness.context.fetch = async () => networkResponse;
const response = await dispatchApiFetch(
harness,
"/api/measurements?type=WEIGHT",
);
expect(response).toBe(networkResponse);
expect(response.status).toBe(200);
expect(response.headers.get("X-Network-Response")).toBe("original");
expect(await response.text()).toBe(
JSON.stringify({ data: { weightKg: 80 }, error: null }),
);
});
it("returns the original successful navigation response when cache.put rejects", async () => {
const harness = bootServiceWorker();
const pageCache = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
pageCache.putError = new Error("CacheStorage quota exceeded");
const networkResponse = new Response("network shell", {
headers: { "X-Network-Response": "original" },
});
harness.context.fetch = async () => networkResponse;
const response = await dispatchNavigationFetch(harness, "/measurements");
expect(response).toBe(networkResponse);
expect(response.headers.get("X-Network-Response")).toBe("original");
expect(await response.text()).toBe("network shell");
});
it("does not delay a successful navigation response while cache.put is pending", async () => {
const harness = bootServiceWorker();
const pageCache = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
let releasePut!: () => void;
pageCache.putDelay = new Promise<void>((resolve) => {
releasePut = resolve;
});
let notifyPutStarted!: () => void;
const putStarted = new Promise<void>((resolve) => {
notifyPutStarted = resolve;
});
pageCache.onPut = notifyPutStarted;
const networkResponse = new Response("network shell");
harness.context.fetch = async () => networkResponse;
const responsePromise = dispatchNavigationFetch(harness, "/measurements");
await putStarted;
let settledResponse: Response | undefined;
void responsePromise.then((response) => {
settledResponse = response;
});
try {
await new Promise<void>((resolve) => setImmediate(resolve));
expect(settledResponse).toBe(networkResponse);
expect(harness.lifetimePromises).toHaveLength(1);
} finally {
releasePut();
await Promise.all(harness.lifetimePromises);
}
expect(await pageCache.match(`${ORIGIN}/measurements`)).toBeDefined();
});
it("returns the original successful static response when cache.put rejects", async () => {
const harness = bootServiceWorker();
const staticCache = await harness.cacheStorage.open(CURRENT_STATIC_CACHE);
staticCache.putError = new Error("CacheStorage quota exceeded");
const networkResponse = new Response("immutable chunk", {
headers: { "X-Network-Response": "original" },
});
harness.context.fetch = async () => networkResponse;
const response = await dispatchAssetFetch(
harness,
"/_next/static/chunks/app.js",
);
expect(response).toBe(networkResponse);
expect(response.headers.get("X-Network-Response")).toBe("original");
expect(await response.text()).toBe("immutable chunk");
});
it("returns the original successful API response when cache trimming rejects", async () => {
const harness = bootServiceWorker();
const dataCache = await harness.cacheStorage.open(CURRENT_DATA_CACHE);
dataCache.keysError = new Error("CacheStorage keys unavailable");
const networkResponse = new Response(
JSON.stringify({ data: { weightKg: 80 }, error: null }),
{ headers: { "Content-Type": "application/json" } },
);
harness.context.fetch = async () => networkResponse;
const response = await dispatchApiFetch(harness, "/api/measurements");
expect(response).toBe(networkResponse);
expect(response.status).toBe(200);
expect(await response.text()).toBe(
JSON.stringify({ data: { weightKg: 80 }, error: null }),
);
expect(dataCache.keysCalls).toBe(1);
});
});
describe("sw.js — activate", () => {
it("drops stale-version caches and enables navigation preload", async () => {
const harness = bootServiceWorker();
+8 -1
View File
@@ -40,6 +40,7 @@ interface Messages {
* Hinzufügen, Hinzufügen, Hinzufügen" with no way to discriminate.
*/
quickAddMedicationIntake: string;
quickAddWater: string;
};
}
@@ -61,25 +62,30 @@ describe("dashboard quick-add submenu labels", () => {
["it", IT_PATH],
["pl", PL_PATH],
])(
"%s: measurement and mood entries have distinct labels and neither equals the trigger label",
"%s: every quick-add entry has a distinct accessible label",
(_locale, path) => {
const messages = load(path);
const measurement = messages.dashboard.quickAddMeasurement;
const mood = messages.dashboard.quickAddMood;
const medicationIntake = messages.dashboard.quickAddMedicationIntake;
const water = messages.dashboard.quickAddWater;
const trigger = messages.common.add;
// Non-empty
expect(measurement.trim().length).toBeGreaterThan(0);
expect(mood.trim().length).toBeGreaterThan(0);
expect(medicationIntake.trim().length).toBeGreaterThan(0);
expect(water.trim().length).toBeGreaterThan(0);
// Distinct from each other — the icon is decorative (aria-hidden),
// so the only thing distinguishing the rows is the visible text.
expect(measurement).not.toBe(mood);
expect(measurement).not.toBe(medicationIntake);
expect(mood).not.toBe(medicationIntake);
expect(measurement).not.toBe(water);
expect(mood).not.toBe(water);
expect(medicationIntake).not.toBe(water);
// Distinct from the trigger label. The trigger sits ABOVE the menu
// and announces itself first; if a menu item then repeats the same
@@ -87,6 +93,7 @@ describe("dashboard quick-add submenu labels", () => {
expect(measurement).not.toBe(trigger);
expect(mood).not.toBe(trigger);
expect(medicationIntake).not.toBe(trigger);
expect(water).not.toBe(trigger);
},
);
});
@@ -226,6 +226,7 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
"src/app/api/medications/[id]/intake/[eventId]/route.ts",
"src/app/api/medications/[id]/intake/bulk-delete/route.ts",
"src/app/api/medications/[id]/intake/import/route.ts",
"src/app/api/medications/[id]/intake/import/[jobId]/status/route.ts",
"src/app/api/medications/[id]/intake/purge/route.ts",
"src/app/api/medications/[id]/inventory/route.ts",
"src/app/api/medications/[id]/inventory/[itemId]/route.ts",
@@ -188,9 +188,29 @@ describe("POST /api/admin/import-apple-health-export", () => {
expect(body.data.jobId).not.toBe("old-failed-job");
expect(mocks.bossSend).toHaveBeenCalledTimes(1);
expect(mocks.importJobCreate).toHaveBeenCalledTimes(1);
expect(mocks.importJobCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ parserRevision: 2 }),
}),
);
expect(mocks.bossSend).toHaveBeenCalledWith(
"apple-health-import-v2",
expect.objectContaining({
userId: "user-2",
triggeredByAdminId: "admin-1",
}),
expect.objectContaining({ retryLimit: 0 }),
);
expect(mocks.importJobUpdate).toHaveBeenCalledWith({
where: { id: "ij-1" },
data: { pgBossJobId: "boss-job-1" },
});
expect(mocks.importJobFindFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: { not: "failed" } }),
where: expect.objectContaining({
status: { not: "failed" },
parserRevision: 2,
}),
}),
);
expect(mocks.unlink).not.toHaveBeenCalled();
@@ -211,5 +231,10 @@ describe("POST /api/admin/import-apple-health-export", () => {
expect(mocks.bossSend).not.toHaveBeenCalled();
expect(mocks.importJobCreate).not.toHaveBeenCalled();
expect(mocks.unlink).toHaveBeenCalledWith(STAGED_PATH);
expect(mocks.importJobFindFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ parserRevision: 2 }),
}),
);
});
});
@@ -21,7 +21,8 @@ import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
APPLE_HEALTH_IMPORT_QUEUE,
APPLE_HEALTH_IMPORT_V2_QUEUE,
APPLE_HEALTH_IMPORT_PARSER_REVISION,
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
type AppleHealthImportPayload,
} from "@/lib/jobs/apple-health-import-worker";
@@ -121,6 +122,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
where: {
userId: targetUser.id,
uploadSha256: uploaded.sha256,
parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
status: { not: "failed" },
},
orderBy: { startedAt: "desc" },
@@ -157,6 +159,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
status: "queued",
uploadBytes: uploaded.bytes,
uploadSha256: uploaded.sha256,
parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
},
});
@@ -171,7 +174,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// first run, so a redelivery could only fail on the deleted `/tmp`
// file and mask the real outcome (see the worker's send-options doc).
const bossJobId = await boss.send(
APPLE_HEALTH_IMPORT_QUEUE,
APPLE_HEALTH_IMPORT_V2_QUEUE,
payload,
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
);
+12 -4
View File
@@ -87,7 +87,9 @@ export const PUT = apiHandler(
const existing = await prisma.biomarker.findFirst({ where: { id } });
if (!existing || existing.userId !== user.id) {
return apiError("Biomarker not found", 404);
return apiError("Biomarker not found", 404, {
errorCode: "labs.biomarker.notFound",
});
}
const { data: body, error: jsonError } = await safeJson(request, {
@@ -115,7 +117,9 @@ export const PUT = apiHandler(
.catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.biomarker.update.invalid",
});
}
const d = parsed.data;
@@ -127,7 +131,9 @@ export const PUT = apiHandler(
select: { id: true },
});
if (clash) {
return apiError("A biomarker with this name already exists", 409);
return apiError("A biomarker with this name already exists", 409, {
errorCode: "labs.biomarker.duplicate",
});
}
}
@@ -152,7 +158,9 @@ export const PUT = apiHandler(
effectiveBound(d.upperBound, existing.upperBound),
)
) {
return apiError("lowerBound must not exceed upperBound", 422);
return apiError("lowerBound must not exceed upperBound", 422, {
errorCode: "labs.biomarker.update.referenceRangeInvalid",
});
}
if (d.context !== undefined) {
data.contextEncrypted = d.context
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
biomarker: { findFirst: vi.fn(), create: vi.fn(), findMany: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("@/lib/labs/biomarker-store", () => ({
decryptContextSoft: vi.fn(),
encryptContextToBytes: vi.fn(),
}));
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";
const SESSION = {
session: { id: "s1", expiresAt: new Date(Date.now() + 60_000) },
user: { id: "u1", username: "user", role: "USER" as const },
};
function req(body: unknown) {
return new NextRequest("http://localhost/api/biomarkers", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
describe("biomarker create error contract", () => {
it("pins validation failures", async () => {
const res = await POST(req({ name: "" }));
expect(res.status).toBe(422);
expect((await res.json()).meta?.errorCode).toBe(
"labs.biomarker.create.invalid",
);
});
it("pins duplicate-name conflicts", async () => {
vi.mocked(prisma.biomarker.findFirst).mockResolvedValue({
id: "bm-existing",
} as never);
const res = await POST(req({ name: "LDL", unit: "mg/dL" }));
expect(res.status).toBe(409);
expect((await res.json()).meta?.errorCode).toBe("labs.biomarker.duplicate");
});
});
+6 -2
View File
@@ -118,7 +118,9 @@ async function postBiomarker(request: NextRequest) {
.catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.biomarker.create.invalid",
});
}
const { name, unit, lowerBound, upperBound, context, panel } = parsed.data;
@@ -131,7 +133,9 @@ async function postBiomarker(request: NextRequest) {
select: { id: true },
});
if (existing) {
return apiError("A biomarker with this name already exists", 409);
return apiError("A biomarker with this name already exists", 409, {
errorCode: "labs.biomarker.duplicate",
});
}
// Field-by-field assignment — never spread `parsed.data`.
@@ -62,7 +62,13 @@ import {
DASHBOARD_WIDGET_IDS,
DASHBOARD_IOS_ONLY_WIDGET_IDS,
DASHBOARD_WIDGET_CATALOGUE_IDS,
DEFAULT_DASHBOARD_LAYOUT,
serializeDashboardLayout,
// v1.32.1 — the exact payload builder `ringMutation.mutationFn` calls
// (see the settings component). Importing the shipped function rather
// than re-implementing its shape means this test exercises the REAL
// client contract against the REAL route, not a hand-rolled stand-in.
buildRingMutationPayload,
type DashboardLayout,
} from "@/lib/dashboard-layout";
@@ -636,3 +642,102 @@ describe("dashboard widgets — preserve-when-absent on PUT", () => {
}
});
});
/**
* v1.32.1 — regression for issue #581: dashboard layout changes silently
* overwritten by a concurrent score-ring save.
*
* The scenario from the report: the user edits tile/chart visibility
* (local draft B), then reorders/toggles a hero score ring before hitting
* Save. The ring PUT (`ringMutation`) fires immediately, built from
* whatever layout the client had cached (layout A — the state BEFORE the
* draft edit). The normal Save button then PUTs the full draft (layout B)
* and commits first. If the earlier-fired ring request resolves AFTER
* Save, its body — built from the stale A snapshot — used to carry
* `widgets: A` explicitly, and an explicitly-present field always wins on
* write. Save's B silently reverted to A even though the ring PUT reported
* 200 and only meant to touch the ring selection.
*
* The fix is `buildRingMutationPayload` (imported from the real component
* module, not re-implemented here): it never includes `widgets` at all.
* This test proves the SERVER half of the fix — a PUT shaped exactly like
* the shipped ring mutation's body preserves whatever layout is CURRENTLY
* stored, so the request that resolves last can no longer matter.
*/
describe("dashboard widgets — ring-only PUT cannot race a concurrent full-layout Save (regression #581)", () => {
it("preserves the widgets a concurrent Save already committed, even though the ring request started from a stale snapshot", async () => {
// The state AFTER the normal Save committed layout B — a tile turned
// off that was on in the stale snapshot A the ring mutation started
// from. Starts from the FULL default widget catalogue (rather than a
// single-widget array) so `resolveDashboardLayout`'s auto-upgrade
// append (missing catalogue ids get seeded invisible) is a no-op here
// and the persisted array can be compared for exact equality below.
const savedLayoutB: DashboardLayout = serializeDashboardLayout({
version: 1,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) =>
w.id === "weight" ? { ...w, visible: false, tileVisible: false } : w,
),
comparisonBaseline: "lastYear",
selectedScoreRings: ["MED_COMPLIANCE"],
heroRingOrder: ["HEALTH_SCORE", "MED_COMPLIANCE"],
});
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: savedLayoutB,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
// The ring mutation's real, shipped payload — built without any
// knowledge of layout B. If this were still `{...staleA, ...}` the
// stale `widgets`/`comparisonBaseline` would land here explicitly and
// overwrite B; `buildRingMutationPayload` never carries them.
const ringOnlyBody = buildRingMutationPayload({
selectedScoreRings: ["READINESS"],
heroRingOrder: ["HEALTH_SCORE", "READINESS"],
});
const res = await callPut(makeReq(ringOnlyBody));
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: DashboardLayout };
};
// B's widgets (and comparisonBaseline) survive — the ring PUT never
// touched them, so nothing it carries can outrace the Save that
// already committed.
expect(updateArg.data.dashboardWidgetsJson.widgets).toEqual(
savedLayoutB.widgets,
);
expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe(
"lastYear",
);
// The ring selection itself DID apply.
expect(updateArg.data.dashboardWidgetsJson.selectedScoreRings).toEqual([
"READINESS",
]);
const body = (await res.json()) as { data: DashboardLayout };
expect(body.data.widgets[0].visible).toBe(false);
expect(body.data.widgets[0].tileVisible).toBe(false);
expect(body.data.selectedScoreRings).toEqual(["READINESS"]);
});
it("still accepts a normal full-layout Save (widgets present + replace semantics unchanged)", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [
{ id: "weight", visible: false, tileVisible: false, order: 0 },
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: DashboardLayout };
expect(body.data.widgets[0].visible).toBe(false);
});
});
+14 -2
View File
@@ -84,7 +84,15 @@ const layoutSchema = z.object({
// catalogue (42 ids). The cap only has to clear the catalogue size —
// the enum bounds each entry and the id set is closed — so it carries
// headroom rather than tracking the count exactly.
.max(50),
.max(50)
// v1.32.1 — optional + preserve-when-absent (issue #581). The
// Settings page's instant score-ring toggle now PUTs ONLY the ring
// fields; omitting `widgets` lets the server carry forward whatever
// is CURRENTLY stored instead of the ring mutation resending a
// client-held snapshot that can go stale mid-flight and race a
// concurrent full-layout Save. A normal Save still always sends
// `widgets` — this only widens what a PARTIAL PUT can leave out.
.optional(),
// v1.4.16 phase B8 — comparison baseline (Vormonat / Vorjahr) rides
// on the layout blob per research §7 Q3 (no Prisma migration). Optional
// so v1.4.15 clients that don't know the field can still PUT.
@@ -285,7 +293,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// visibility / order, while chart prefs are PUT through their own route
// (`/api/dashboard/chart-overlay-prefs`), the hero rings come from
// Settings, and `comparisonBaseline` is web-only — an omission from any
// one surface must not reset a choice made on another.
// one surface must not reset a choice made on another. `widgets` itself
// joined this set in v1.32.1 (issue #581): the Settings page's instant
// score-ring PUT omits it entirely so it always carries forward
// whatever is CURRENTLY stored, rather than a client-held snapshot that
// could be stale relative to a concurrent full-layout Save.
//
// The preserve set is not a list maintained here: it is derived from
// `LAYOUT_FIELD_MERGE_DISPOSITION`, which the type system forces to cover
+16 -16
View File
@@ -274,6 +274,22 @@ export const POST = apiHandler(async (request: NextRequest) => {
select: { id: true },
});
// Keep channel reconciliation under the same identity locks as the device
// row. Moving it after commit lets two first-time registrations race the
// compound unique key and intermittently turns one valid request into 500.
if (apnsToken) {
await tx.notificationChannel.upsert({
where: { userId_type: { userId: user.id, type: "APNS" } },
create: {
userId: user.id,
type: "APNS",
enabled: true,
config: encrypt("{}"),
},
update: {},
});
}
return { id: device.id };
});
@@ -292,22 +308,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { id } = registration;
// Reconcile APNs channel state only after the canonical device transaction
// commits. Config is an encrypted empty record by design — the per-device
// token and environment live on the Device row.
if (apnsToken) {
await prisma.notificationChannel.upsert({
where: { userId_type: { userId: user.id, type: "APNS" } },
create: {
userId: user.id,
type: "APNS",
enabled: true,
config: encrypt("{}"),
},
update: {},
});
}
await auditLog("devices.register", {
userId: user.id,
details: {
@@ -93,18 +93,25 @@ const SESSION_OK = {
user: { id: "user-1", username: "tester", role: "USER" as const },
};
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
const req = (id: string, mode?: string) =>
new NextRequest(
new URL(
`http://localhost/api/documents/inbound/${id}/summary${mode ? `?mode=${mode}` : ""}`,
),
const req = (
id: string,
mode?: string,
options: { persist?: boolean; replace?: boolean } = {},
) => {
const query = new URLSearchParams();
if (mode) query.set("mode", mode);
if (options.persist) query.set("persist", "true");
if (options.replace) query.set("replace", "true");
const suffix = query.size > 0 ? `?${query.toString()}` : "";
return new NextRequest(
new URL(`http://localhost/api/documents/inbound/${id}/summary${suffix}`),
{ method: "POST" },
);
};
/**
* Nothing beyond the summary column may be written. `mode=summary` DOES persist
* onto the document since v1.30.31 (see the route header) — that leg is asserted
* explicitly below; everything else stays session-only.
* Nothing beyond the explicitly requested summary column may be written.
* Transcription and ordinary action-row summaries stay session-only.
*/
function assertNoPersistence(): void {
expect(prisma.inboundDocument.update).not.toHaveBeenCalled();
@@ -124,6 +131,9 @@ beforeEach(() => {
mimeType: "image/png",
status: "STORED",
} as never);
vi.mocked(prisma.inboundDocument.updateMany).mockResolvedValue({
count: 1,
});
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
chain: [{ providerType: "anthropic", instance: {} }],
pick: {
@@ -135,9 +145,9 @@ beforeEach(() => {
});
describe("POST /api/documents/inbound/[id]/summary", () => {
it("mode=summary returns { summary } and STORES it, so a second open is free", async () => {
it("keeps an ordinary requested summary session-only", async () => {
vi.mocked(runDocumentSummary).mockResolvedValue({
summary: "A blood panel from a lab.",
summary: "A transient summary.",
blocked: null,
} as never);
@@ -145,16 +155,34 @@ describe("POST /api/documents/inbound/[id]/summary", () => {
req("doc-1", "summary") as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(200);
expect((await res.json()).data).toEqual({
summary: "A transient summary.",
});
expect(prisma.inboundDocument.updateMany).not.toHaveBeenCalled();
});
it("stores the first requested summary without replacing an existing one", async () => {
vi.mocked(runDocumentSummary).mockResolvedValue({
summary: "A blood panel from a lab.",
blocked: null,
} as never);
const res = await POST(
req("doc-1", "summary", { persist: true }) as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data).toEqual({ summary: "A blood panel from a lab." });
expect(body.data).toEqual({
summary: "A blood panel from a lab.",
persistence: "stored",
});
expect(auditLog).toHaveBeenCalledWith(
"documents.inbound.summary",
expect.anything(),
);
// Persisted READY, owner-scoped, and only onto a document without one —
// an explicit re-run must not replace a summary already on screen.
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({
where: {
id: "doc-1",
@@ -167,6 +195,49 @@ describe("POST /api/documents/inbound/[id]/summary", () => {
assertNoPersistence();
});
it("replaces a stored summary only when replacement is explicit", async () => {
vi.mocked(runDocumentSummary).mockResolvedValue({
summary: "A newly generated summary.",
blocked: null,
} as never);
const res = await POST(
req("doc-1", "summary", { persist: true, replace: true }) as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(200);
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({
where: {
id: "doc-1",
userId: "user-1",
deletedAt: null,
},
data: expect.objectContaining({ summaryState: "READY" }),
});
});
it("uses the signed-in user's locale for a requested summary", async () => {
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, locale: "de" },
} as never);
vi.mocked(runDocumentSummary).mockResolvedValue({
summary: "Ein Laborbericht.",
blocked: null,
} as never);
const res = await POST(
req("doc-1", "summary") as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(200);
expect(runDocumentSummary).toHaveBeenCalledWith(
expect.objectContaining({ locale: "de" }),
);
});
it("records WITHHELD without ever storing the blocked prose", async () => {
vi.mocked(runDocumentSummary).mockResolvedValue({
summary: "raise the dose to 10 mg",
@@ -174,7 +245,7 @@ describe("POST /api/documents/inbound/[id]/summary", () => {
} as never);
const res = await POST(
req("doc-1", "summary") as never,
req("doc-1", "summary", { persist: true }) as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(200);
@@ -182,6 +253,7 @@ describe("POST /api/documents/inbound/[id]/summary", () => {
// The user gets the honest statement, never the blocked text.
const body = await res.json();
expect(body.data.summary).toBe("The summary was withheld.");
expect(body.data.persistence).toBe("withheld");
expect(body.data.summary).not.toContain("10 mg");
// Only the state lands. No ciphertext column is touched on this path.
@@ -201,6 +273,27 @@ describe("POST /api/documents/inbound/[id]/summary", () => {
assertNoPersistence();
});
it("reports a failed save when the document changed before the write", async () => {
vi.mocked(runDocumentSummary).mockResolvedValue({
summary: "A blood panel from a lab.",
blocked: null,
} as never);
vi.mocked(prisma.inboundDocument.updateMany).mockResolvedValueOnce({
count: 0,
});
const res = await POST(
req("doc-1", "summary", { persist: true }) as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(200);
expect((await res.json()).data).toEqual({
summary: "A blood panel from a lab.",
persistence: "failed",
});
});
it("still answers when the summary write fails", async () => {
vi.mocked(runDocumentSummary).mockResolvedValue({
summary: "A blood panel from a lab.",
@@ -213,12 +306,13 @@ describe("POST /api/documents/inbound/[id]/summary", () => {
// The user is waiting on a summary they can already read; a failed write
// must not turn that into an error.
const res = await POST(
req("doc-1", "summary") as never,
req("doc-1", "summary", { persist: true }) as never,
ctx("doc-1") as never,
);
expect(res.status).toBe(200);
expect((await res.json()).data).toEqual({
summary: "A blood panel from a lab.",
persistence: "failed",
});
});
@@ -86,39 +86,56 @@ function rateLimited(rl: Awaited<ReturnType<typeof checkRateLimit>>): Response {
return response;
}
type SummaryPersistenceTarget = {
userId: string;
documentId: string;
replaceExisting: boolean;
};
function resolveMode(request: NextRequest): DocumentSummaryMode {
const raw = new URL(request.url).searchParams.get("mode");
return (DOCUMENT_SUMMARY_MODES as readonly string[]).includes(raw ?? "")
? (raw as DocumentSummaryMode)
: "summary";
}
function resolvePersistRequested(request: NextRequest): boolean {
return new URL(request.url).searchParams.get("persist") === "true";
}
function resolveReplaceExisting(request: NextRequest): boolean {
return new URL(request.url).searchParams.get("replace") === "true";
}
/**
* Run the requested describe call (summary or raw text) over the input.
*
* `mode=summary` PERSISTS a clean result onto the document (v1.30.31). The
* route was session-only by design (P2-D4), and the transcription leg still is
* — but a summary that the user explicitly asked for and that passed the screen
* is exactly the artefact the background job stores, and re-deriving it on every
* open would mean paying a provider call to show the same paragraph twice. This
* is a deliberate user action, not a warm-on-mount: nothing generates because a
* view rendered. It is also the only repair path for the documents the
* background job never ran for — those uploaded before the auto-read opt-in
* went on, which no backfill ever reaches.
* Ordinary action-row summaries and raw transcription remain session-only.
* The document summary block opts into persistence so an empty document can be
* repaired and an existing stored summary can be explicitly regenerated.
*/
async function describe(
mode: DocumentSummaryMode,
input: DescribeInput,
locale: Locale,
persist: { userId: string; documentId: string } | null,
): Promise<{ summary: string } | { text: string }> {
persist: SummaryPersistenceTarget | null,
): Promise<
| {
summary: string;
persistence?: "stored" | "withheld" | "failed";
}
| { text: string }
> {
if (mode === "text") return transcribeDocument(input);
// REPLACE policy: the user clicked "summarise" and is waiting, so a screened
// summary becomes a short honest statement rather than an empty panel. The
// document itself and the extracted text remain reachable and unaltered.
// A normal summary request fills an empty slot only. Replacement is reserved
// for the document detail's explicit "Generate again" action.
const { summary, blocked } = await runDocumentSummary({ ...input, locale });
if (persist) await persistSummary(persist, summary, blocked);
return { summary: blocked ? documentSummaryBlockedCopy(locale) : summary };
const persistence = persist
? await persistSummary(persist, summary, blocked)
: undefined;
return {
summary: blocked ? documentSummaryBlockedCopy(locale) : summary,
...(persistence ? { persistence } : {}),
};
}
/**
@@ -133,10 +150,10 @@ async function describe(
* read in the response; a failed write must not turn that into an error.
*/
async function persistSummary(
target: { userId: string; documentId: string },
target: SummaryPersistenceTarget,
summary: string,
blocked: OutboundReason | null,
): Promise<void> {
): Promise<"stored" | "withheld" | "failed"> {
try {
if (blocked) {
await prisma.inboundDocument.updateMany({
@@ -148,16 +165,17 @@ async function persistSummary(
},
data: { summaryState: "WITHHELD" },
});
return;
return "withheld";
}
// Scoped to a document without a stored summary: an explicit re-run must
// not silently replace a summary the user already has on screen.
// Preserve a previously stored summary unless the caller explicitly chose
// the replacement action. Failed or screened attempts never reach this
// write, so the previous clean summary remains available in those cases.
const written = await prisma.inboundDocument.updateMany({
where: {
id: target.documentId,
userId: target.userId,
deletedAt: null,
summaryEncrypted: null,
...(target.replaceExisting ? {} : { summaryEncrypted: null }),
},
data: {
summaryEncrypted: encryptDocumentSummary(summary),
@@ -169,11 +187,13 @@ async function persistSummary(
action: { name: "documents.summary.persisted" },
meta: { documentId: target.documentId, stored: written.count > 0 },
});
return written.count > 0 ? "stored" : "failed";
} catch {
annotate({
action: { name: "documents.summary.persistFailed" },
meta: { documentId: target.documentId },
});
return blocked ? "withheld" : "failed";
}
}
@@ -200,6 +220,14 @@ export const POST = apiHandler(
}
const mode = resolveMode(request);
const persist: SummaryPersistenceTarget | null =
mode === "summary" && resolvePersistRequested(request)
? {
userId: user.id,
documentId: document.id,
replaceExisting: resolveReplaceExisting(request),
}
: null;
// The outbound screen on the summary needs the reader's locale to pick its
// pattern banks; resolve it once here and thread it into both describe legs.
const locale = await resolveServerLocale({
@@ -208,9 +236,23 @@ export const POST = apiHandler(
});
const contentType = request.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return handleTextSummary(request, user.id, document, mode, locale);
return handleTextSummary(
request,
user.id,
document,
mode,
locale,
persist,
);
}
return handleVisionSummary(request, user.id, document, mode, locale);
return handleVisionSummary(
request,
user.id,
document,
mode,
locale,
persist,
);
},
);
@@ -241,6 +283,7 @@ async function handleTextSummary(
document: LoadedDocument,
mode: DocumentSummaryMode,
locale: Locale,
persist: SummaryPersistenceTarget | null,
): Promise<Response> {
const row = await prisma.user.findUnique({
where: { id: userId },
@@ -315,7 +358,7 @@ async function handleTextSummary(
ocrText: parsed.data.text,
},
locale,
{ userId, documentId: document.id },
persist,
);
await reconcileSpend(
userId,
@@ -337,6 +380,7 @@ async function handleVisionSummary(
document: LoadedDocument,
mode: DocumentSummaryMode,
locale: Locale,
persist: SummaryPersistenceTarget | null,
): Promise<Response> {
const { pick } = await resolveDocumentVisionProvider(userId);
if (!pick) {
@@ -401,7 +445,7 @@ async function handleVisionSummary(
documents: vision.documents,
},
locale,
{ userId, documentId: document.id },
persist,
);
await reconcileSpend(
userId,
@@ -106,6 +106,11 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { enqueueDocumentIndex } from "@/lib/jobs/document-index";
import {
acquireDocumentUploadSlot,
DOCUMENT_UPLOAD_GLOBAL_CONCURRENCY,
DOCUMENT_UPLOAD_PER_USER_CONCURRENCY,
} from "@/lib/documents/upload-policy";
const post = POST as unknown as (r: Request) => Promise<Response>;
const get = GET as unknown as (r: Request) => Promise<Response>;
@@ -311,6 +316,96 @@ describe("POST /api/documents/inbound (vault upload)", () => {
expect(res.status).toBe(404);
expect(txCreate).not.toHaveBeenCalled();
});
it("rejects before reading the body when the user's upload slots are full", async () => {
const releases = Array.from(
{ length: DOCUMENT_UPLOAD_PER_USER_CONCURRENCY },
() => acquireDocumentUploadSlot("user-1"),
);
let bodyRead = false;
const unreadBody = new ReadableStream<Uint8Array>(
{
pull(controller) {
bodyRead = true;
controller.error(new Error("body must not be read"));
},
},
{ highWaterMark: 0 },
);
const request = new Request("http://localhost/api/documents/inbound", {
method: "POST",
body: unreadBody,
duplex: "half",
} as RequestInit & { duplex: "half" });
try {
const res = await post(request);
expect(res.status).toBe(429);
await expect(res.json()).resolves.toMatchObject({
meta: { reason: "uploadBusy" },
});
expect(bodyRead).toBe(false);
expect(txCreate).not.toHaveBeenCalled();
} finally {
for (const release of releases) release!();
}
});
it("rejects before reading the body when process-wide upload slots are full", async () => {
const releases = Array.from(
{ length: DOCUMENT_UPLOAD_GLOBAL_CONCURRENCY },
(_, index) => acquireDocumentUploadSlot(`other-user-${index}`),
);
try {
const res = await post(mkUpload());
expect(res.status).toBe(429);
await expect(res.json()).resolves.toMatchObject({
meta: { reason: "uploadBusy" },
});
expect(txCreate).not.toHaveBeenCalled();
} finally {
for (const release of releases) release!();
}
});
it("releases an upload slot after an invalid multipart body", async () => {
const invalid = new Request("http://localhost/api/documents/inbound", {
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=incomplete" },
body: "--incomplete\r\n",
});
const invalidResponse = await post(invalid);
expect(invalidResponse.status).toBe(400);
const held = acquireDocumentUploadSlot("user-1");
expect(held).not.toBeNull();
try {
const validResponse = await post(mkUpload());
expect(validResponse.status).toBe(201);
} finally {
held!();
}
});
it("does not make another whole-body Blob copy before multipart parsing", async () => {
const request = mkUpload();
const NativeBlob = globalThis.Blob;
let blobConstructions = 0;
class CountingBlob extends NativeBlob {
constructor(parts?: BlobPart[], options?: BlobPropertyBag) {
blobConstructions += 1;
super(parts, options);
}
}
globalThis.Blob = CountingBlob;
try {
const res = await post(request);
expect(res.status).toBe(201);
expect(blobConstructions).toBe(0);
} finally {
globalThis.Blob = NativeBlob;
}
});
});
describe("GET /api/documents/inbound (list)", () => {
+45 -7
View File
@@ -27,7 +27,7 @@ import { createHash } from "node:crypto";
import { NextResponse } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
@@ -45,6 +45,7 @@ import { enqueueDocumentIndex } from "@/lib/jobs/document-index";
import { enqueueDocumentSummary } from "@/lib/jobs/document-summary";
import { enqueueDocumentThumbnail } from "@/lib/jobs/document-thumbnail";
import {
acquireDocumentUploadSlot,
detectDocumentType,
resolveDocumentLimits,
} from "@/lib/documents/upload-policy";
@@ -76,6 +77,7 @@ const UPLOAD_WINDOW_MS = 60 * 60 * 1000;
* exact per-file cap is re-enforced on the extracted file bytes below.
*/
const MULTIPART_OVERHEAD_BYTES = 64 * 1024;
const UPLOAD_BODY_TIMEOUT_MS = 30_000;
/** Parse a YYYY-MM-DD form field into a UTC midnight Date. */
function isoDateToUtc(value: string): Date {
@@ -140,10 +142,11 @@ async function duplicateResponse(
);
}
/** POST — store an uploaded document encrypted at rest. No extraction. */
async function postUpload(request: Request): Promise<Response> {
const { user } = await requireAuth();
/** Process one admitted upload. The caller owns and releases its memory slot. */
async function processUpload(
request: Request,
user: AuthContext["user"],
): Promise<Response> {
// Opt-in module gate — even a valid Bearer token is refused when the surface
// is off (it ships dark; the user turns it on deliberately).
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
@@ -176,14 +179,25 @@ async function postUpload(request: Request): Promise<Response> {
let formData: FormData;
try {
const bytes = await readBoundedBody(request.body, bodyCap);
formData = await new Response(new Blob([bytes]), {
const bytes = await readBoundedBody(request.body, bodyCap, {
signal: request.signal,
deadline: Date.now() + UPLOAD_BODY_TIMEOUT_MS,
});
formData = await new Request(request.url, {
method: "POST",
headers: { "content-type": request.headers.get("content-type") ?? "" },
body: bytes,
}).formData();
} catch (err) {
if (err instanceof BodyTooLargeError) {
return fileTooLarge(limits.maxFileBytes);
}
if (err instanceof DOMException && err.name === "AbortError") {
return apiError("Upload body read timed out.", 408, {
errorCode: "documents.inbound.uploadTimeout",
reason: "uploadTimeout",
});
}
return apiError("Invalid multipart body", 400);
}
@@ -397,6 +411,30 @@ async function postUpload(request: Request): Promise<Response> {
);
}
/** Authenticate and reserve memory capacity before any request-body read. */
async function postUpload(request: Request): Promise<Response> {
const { user } = await requireAuth();
const releaseUploadSlot = acquireDocumentUploadSlot(user.id);
if (!releaseUploadSlot) {
const response = apiError(
"Too many uploads are already in progress. Try again shortly.",
429,
{
errorCode: "documents.inbound.uploadBusy",
reason: "uploadBusy",
},
);
response.headers.set("Retry-After", "1");
return response;
}
try {
return await processUpload(request, user);
} finally {
releaseUploadSlot();
}
}
export const POST = apiHandler(withIdempotency<[Request]>(postUpload));
/** GET — list the caller's documents (search / filter / sort / paginate). */
@@ -0,0 +1,259 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { formatMeasurementsForExport, toCSV } from "@/lib/export";
import { MEASUREMENT_EXPORT_PAGE_SIZE } from "@/lib/export/paged-measurements";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/geo", () => ({ lookupIpLocation: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn().mockResolvedValue("UTC"),
}));
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn().mockResolvedValue(null),
}));
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { GET } from "../measurements/route";
import { GET as GET_LEGACY } from "../route";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", role: "USER" as const },
};
function request(): NextRequest {
return new NextRequest(
"http://localhost/api/export/measurements?granularity=raw",
{ method: "GET" },
);
}
function legacyRequest(): NextRequest {
return new NextRequest(
"http://localhost/api/export?format=json&type=measurements",
{ method: "GET" },
);
}
function measurement(index: number) {
return {
id: `m-${String(index).padStart(5, "0")}`,
type: index % 2 === 0 ? "WEIGHT" : "HEART_RATE",
value: index + 0.25,
unit: index % 2 === 0 ? "kg" : "bpm",
measuredAt: new Date(Date.UTC(2026, 6, 21, 12, 0, 0) - index * 1_000),
source: "MANUAL",
notes: index === 1 ? 'comma, quote " and\nnewline' : null,
notesEncrypted: null,
glucoseContext: null,
sleepStage: null,
deviceType: null,
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(auditLog).mockReset().mockResolvedValue(undefined);
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3_600_000,
});
vi.mocked(prisma.user.findUnique).mockResolvedValue({
glucoseUnit: "mg/dL",
} as never);
});
describe("GET /api/export/measurements streaming", () => {
it("does not audit a failed authorization", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const response = await GET(request());
expect(response.status).toBe(401);
expect(auditLog).not.toHaveBeenCalled();
});
it("persists the audit event before returning the streaming response", async () => {
let persistAudit!: () => void;
const auditPersistence = new Promise<void>((resolve) => {
persistAudit = resolve;
});
let markAuditStarted: (() => void) | undefined;
const auditStarted = new Promise<void>((resolve) => {
markAuditStarted = resolve;
});
vi.mocked(auditLog).mockImplementationOnce(() => {
markAuditStarted?.();
return auditPersistence;
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
const responsePromise = GET(request());
const firstSettled = await Promise.race([
responsePromise.then(() => "response" as const),
auditStarted.then(() => "audit" as const),
]);
persistAudit();
const response = await responsePromise;
expect(response.status).toBe(200);
expect(firstSettled).toBe("audit");
expect(auditLog).toHaveBeenCalledTimes(1);
await response.body?.cancel();
});
it("streams exact CSV across a keyset page boundary", async () => {
const firstPage = Array.from(
{ length: MEASUREMENT_EXPORT_PAGE_SIZE },
(_, index) => measurement(index),
);
const finalPage = [measurement(MEASUREMENT_EXPORT_PAGE_SIZE)];
vi.mocked(prisma.measurement.findMany)
.mockResolvedValueOnce(firstPage as never)
.mockResolvedValueOnce(finalPage as never);
const response = await GET(request());
expect(response.status).toBe(200);
expect(response.body).not.toBeNull();
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1);
expect(auditLog).toHaveBeenCalledTimes(1);
const body = await response.text();
const allRows = [...firstPage, ...finalPage];
const expected = toCSV(
formatMeasurementsForExport(allRows, "UTC", {
granularity: "raw",
sleepTz: "UTC",
sourcePriorityJson: null,
glucoseUnit: "mg/dL",
}),
);
expect(body).toBe(expected);
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(2);
expect(auditLog).toHaveBeenCalledWith("user.export.measurements", {
userId: "user-1",
ipAddress: null,
details: {
outcome: "attempted",
since: null,
until: null,
},
});
expect(auditLog).toHaveBeenCalledTimes(1);
});
it("propagates a later page failure through the response body", async () => {
const failure = new Error("measurement page failed");
const firstPage = Array.from(
{ length: MEASUREMENT_EXPORT_PAGE_SIZE },
(_, index) => measurement(index),
);
vi.mocked(prisma.measurement.findMany)
.mockResolvedValueOnce(firstPage as never)
.mockRejectedValueOnce(failure);
const response = await GET(request());
expect(response.status).toBe(200);
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1);
await expect(response.text()).rejects.toThrow("measurement page failed");
expect(auditLog).toHaveBeenCalledTimes(1);
});
});
describe("GET /api/export legacy measurement streaming", () => {
it("does not audit a failed authorization", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const response = await GET_LEGACY(legacyRequest());
expect(response.status).toBe(401);
expect(auditLog).not.toHaveBeenCalled();
});
it("persists the audit event before returning the streaming response", async () => {
let persistAudit!: () => void;
const auditPersistence = new Promise<void>((resolve) => {
persistAudit = resolve;
});
let markAuditStarted: (() => void) | undefined;
const auditStarted = new Promise<void>((resolve) => {
markAuditStarted = resolve;
});
vi.mocked(auditLog).mockImplementationOnce(() => {
markAuditStarted?.();
return auditPersistence;
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
const responsePromise = GET_LEGACY(legacyRequest());
const firstSettled = await Promise.race([
responsePromise.then(() => "response" as const),
auditStarted.then(() => "audit" as const),
]);
persistAudit();
const response = await responsePromise;
expect(response.status).toBe(200);
expect(firstSettled).toBe("audit");
expect(auditLog).toHaveBeenCalledTimes(1);
await response.body?.cancel();
});
it("frames exact JSON while pulling later measurement pages from the body", async () => {
const firstPage = Array.from(
{ length: MEASUREMENT_EXPORT_PAGE_SIZE },
(_, index) => measurement(index),
);
const finalPage = [measurement(MEASUREMENT_EXPORT_PAGE_SIZE)];
vi.mocked(prisma.measurement.findMany)
.mockResolvedValueOnce(firstPage as never)
.mockResolvedValueOnce(finalPage as never);
const response = await GET_LEGACY(legacyRequest());
expect(response.status).toBe(200);
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1);
expect(auditLog).toHaveBeenCalledTimes(1);
const body = await response.text();
const records = formatMeasurementsForExport(
[...firstPage, ...finalPage],
"UTC",
{
sleepTz: "UTC",
sourcePriorityJson: null,
glucoseUnit: "mg/dL",
},
);
expect(body).toBe(JSON.stringify({ data: { measurements: records } }));
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(2);
expect(auditLog).toHaveBeenCalledWith("export.download", {
userId: "user-1",
ipAddress: null,
details: {
format: "json",
type: "measurements",
outcome: "attempted",
},
});
expect(auditLog).toHaveBeenCalledTimes(1);
});
});
@@ -96,6 +96,7 @@ describe("GET /api/export/measurements", () => {
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "measurement-weight-1",
type: "WEIGHT",
value: 80,
unit: "kg",
@@ -166,6 +167,7 @@ describe("GET /api/export/measurements", () => {
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "measurement-glucose-mmol",
type: "BLOOD_GLUCOSE",
value: 100,
unit: "mg/dL",
@@ -196,6 +198,7 @@ describe("GET /api/export/measurements", () => {
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{
id: "measurement-glucose-mgdl",
type: "BLOOD_GLUCOSE",
value: 100,
unit: "mg/dL",
+105 -57
View File
@@ -22,9 +22,11 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { toCSV, formatMeasurementsForExport } from "@/lib/export";
import { findMeasurementsPaged } from "@/lib/export/paged-measurements";
import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
import { toCSV, type ExportableRecord } from "@/lib/export";
import {
formatMeasurementPageChunks,
iterateMeasurementPages,
} from "@/lib/export/paged-measurements";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { resolveGlucoseUnit } from "@/lib/glucose";
@@ -41,80 +43,123 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { since, until } = parseRange(request.url);
const where = buildWhere(user.id, { since, until });
// v1.11.5 — sleep export defaults to one row per night; `granularity=raw`
// keeps the per-stage rows for power users.
const granularity =
new URL(request.url).searchParams.get("granularity") === "raw"
? "raw"
: "night";
const [measurements, userTz, sourcePriorityJson, profile] = await Promise.all(
[
// v1.28.25 — keyset-paginated read with a narrow select. The route
// keeps its contract (an absent `since`/`until` means the full
// history), but the read is now chunked so an unbounded window on a
// CGM / per-sample-HR account never materialises the whole table in
// one query. The select is exactly the formatter's `ExportMeasurement`
// shape + note decryption + the `id` cursor key.
findMeasurementsPaged(prisma, where, {
id: true,
type: true,
value: true,
unit: true,
measuredAt: true,
source: true,
notes: true,
notesEncrypted: true,
glucoseContext: true,
sleepStage: true,
deviceType: true,
}),
resolveUserTimezone(user.id),
loadUserSourcePriority(user.id),
// v1.16.16 — resolve the glucose display unit so BLOOD_GLUCOSE rows
// export in the user's unit (matching FHIR), not raw stored mg/dL.
prisma.user.findUnique({
where: { id: user.id },
select: { glucoseUnit: true },
}),
],
);
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
const csv = toCSV(
formatMeasurementsForExport(
measurements.map(shapeMeasurementNotes),
userTz,
{
granularity,
sleepTz: userTz,
sourcePriorityJson,
glucoseUnit,
},
),
);
await auditLog("user.export.measurements", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
count: measurements.length,
outcome: "attempted",
since: since?.toISOString() ?? null,
until: until?.toISOString() ?? null,
},
});
annotate({
meta: {
export_count: measurements.length,
export_outcome: "attempted",
export_since: since?.toISOString() ?? null,
export_until: until?.toISOString() ?? null,
},
});
const pageIterator = iterateMeasurementPages(prisma, where, {
id: true,
type: true,
value: true,
unit: true,
measuredAt: true,
source: true,
notes: true,
notesEncrypted: true,
glucoseContext: true,
sleepStage: true,
deviceType: true,
})[Symbol.asyncIterator]();
return csvResponse(csv, `healthlog-measurements-${user.id}`);
// Prime one bounded page before sending headers. Authentication, metadata,
// and first-query failures therefore keep the historical API error
// behaviour; later pages are pulled only as the client consumes the body.
const [firstPage, userTz, sourcePriorityJson, profile] = await Promise.all([
pageIterator.next(),
resolveUserTimezone(user.id),
loadUserSourcePriority(user.id),
prisma.user.findUnique({
where: { id: user.id },
select: { glucoseUnit: true },
}),
]).catch(async (error: unknown) => {
await pageIterator.return?.();
throw error;
});
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
const pages = prefetchedPages(pageIterator, firstPage);
const recordChunks = formatMeasurementPageChunks(pages, userTz, {
granularity,
sourcePriorityJson,
glucoseUnit,
});
const body = streamText(csvChunks(recordChunks));
return csvResponse(body, `healthlog-measurements-${user.id}`);
});
async function* prefetchedPages<T>(
iterator: AsyncIterator<T[]>,
first: IteratorResult<T[]>,
): AsyncGenerator<readonly T[], void, void> {
try {
let next = first;
while (!next.done) {
yield next.value;
next = await iterator.next();
}
} finally {
await iterator.return?.();
}
}
async function* csvChunks(
chunks: AsyncIterable<ExportableRecord[]>,
): AsyncGenerator<string, void, void> {
let wroteHeader = false;
for await (const records of chunks) {
const csv = toCSV(records);
if (csv.length === 0) continue;
if (!wroteHeader) {
wroteHeader = true;
yield csv;
continue;
}
const headerEnd = csv.indexOf("\n");
if (headerEnd >= 0 && headerEnd + 1 < csv.length) {
yield `\n${csv.slice(headerEnd + 1)}`;
}
}
}
function streamText(chunks: AsyncIterable<string>): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
const iterator = chunks[Symbol.asyncIterator]();
return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const next = await iterator.next();
if (next.done) {
controller.close();
return;
}
controller.enqueue(encoder.encode(next.value));
} catch (error) {
controller.error(error);
}
},
async cancel() {
await iterator.return?.();
},
});
}
function parseRange(url: string): {
since: Date | undefined;
until: Date | undefined;
@@ -159,7 +204,10 @@ function buildWhere(
return where;
}
function csvResponse(body: string, prefix: string): NextResponse {
function csvResponse(
body: ReadableStream<Uint8Array>,
prefix: string,
): NextResponse {
const stamp = new Date().toISOString().slice(0, 10);
return new NextResponse(body, {
status: 200,
+154 -47
View File
@@ -6,13 +6,16 @@ import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import {
toCSV,
formatMeasurementsForExport,
formatMedicationsForExport,
formatIntakeEventsForExport,
formatMoodEntriesForExport,
type ExportableRecord,
} from "@/lib/export";
import { shapeMeasurementNotes, shapeMoodNote } from "@/lib/crypto/note-cipher";
import { findMeasurementsPaged } from "@/lib/export/paged-measurements";
import { shapeMoodNote } from "@/lib/crypto/note-cipher";
import {
formatMeasurementPageChunks,
iterateMeasurementPages,
} from "@/lib/export/paged-measurements";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { NextRequest, NextResponse } from "next/server";
@@ -52,26 +55,28 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const userId = user.id;
await auditLog("export.download", {
userId,
ipAddress: getClientIp(request),
details: { format, type, outcome: "attempted" },
});
annotate({
meta: {
export_format: format,
export_type: type,
export_outcome: "attempted",
},
});
const [userTz, sourcePriorityJson] = await Promise.all([
resolveUserTimezone(userId),
loadUserSourcePriority(userId),
]);
const data: Record<string, unknown> = {};
let measurementChunks: AsyncIterable<ExportableRecord[]> | null = null;
if (type === "measurements" || type === "all") {
// v1.28.25 — keyset-paginated read with a narrow select. The legacy
// export has no date window, so a CGM / per-sample-HR account holds a
// six-figure row set; one unbounded full-width `findMany` here was the
// scale hazard. The select carries exactly the fields the formatter +
// note decryption consume (`ExportMeasurement` + `shapeMeasurementNotes`
// + the `id` cursor key) — no Bytes column beyond the note ciphertext
// the export must decrypt.
const measurements = await findMeasurementsPaged(
const pageIterator = iterateMeasurementPages(
prisma,
// v1.4.41 W-DELETED-2 — exclude soft-deleted measurements from
// the legacy /api/export endpoint so CSV + JSON downloads stay
// consistent with the live measurement reads.
{ userId, deletedAt: null },
{
id: true,
@@ -86,19 +91,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
sleepStage: true,
deviceType: true,
},
);
// v1.11.5 — sleep collapses to one row per night by default (the
// formatter's `night` granularity), matching the per-type CSV route.
// Thread the user's source-priority so a multi-source night dedups to
// the same canonical source the UI shows.
const measurementRecords = formatMeasurementsForExport(
// v1.23 — decrypt notes before formatting; strip the ciphertext column.
measurements.map(shapeMeasurementNotes),
)[Symbol.asyncIterator]();
const firstPage = await pageIterator.next();
measurementChunks = formatMeasurementPageChunks(
prefetchedPages(pageIterator, firstPage),
userTz,
{ sleepTz: userTz, sourcePriorityJson },
{
granularity: "night",
sourcePriorityJson,
// Preserve the legacy endpoint's canonical storage-unit contract.
glucoseUnit: "mg/dL",
},
);
data.measurements =
format === "csv" ? toCSV(measurementRecords) : measurementRecords;
}
if (type === "medications" || type === "all") {
@@ -115,8 +119,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (type === "intake" || type === "all") {
const events = await prisma.medicationIntakeEvent.findMany({
// v1.7.0 sync — exclude tombstoned rows from the export (mirrors
// the measurement export's `deletedAt: null` filter above).
where: { userId, deletedAt: null },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "desc" },
@@ -129,7 +131,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (type === "mood" || type === "all") {
const moodEntries = await prisma.moodEntry.findMany({
// v1.7.0 sync — exclude tombstoned rows from the export.
where: { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
});
@@ -140,36 +141,50 @@ export const GET = apiHandler(async (request: NextRequest) => {
: formatMoodEntriesForExport(decryptedMood, userTz);
}
await auditLog("export.download", {
userId,
ipAddress: getClientIp(request),
details: { format, type },
});
const filename = `healthlog-export-${new Date().toISOString().slice(0, 10)}`;
annotate({ meta: { export_format: format, export_type: type } });
if (measurementChunks && format === "csv") {
return new NextResponse(
streamText(legacyCsvChunks(measurementChunks, data)),
{
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${filename}.csv"`,
},
},
);
}
if (measurementChunks) {
return new NextResponse(
streamText(legacyJsonChunks(measurementChunks, data)),
{
status: 200,
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="${filename}.json"`,
},
},
);
}
if (format === "csv") {
// For CSV with all types, combine into separate sections
const csvParts: string[] = [];
if (data.measurements) {
csvParts.push("# Measurements\n" + data.measurements);
}
if (data.medications) {
if (typeof data.medications === "string" && data.medications.length > 0) {
csvParts.push("# Medications\n" + data.medications);
}
if (data.intakeEvents) {
if (typeof data.intakeEvents === "string" && data.intakeEvents.length > 0) {
csvParts.push("# Intake Events\n" + data.intakeEvents);
}
if (data.moodEntries) {
if (typeof data.moodEntries === "string" && data.moodEntries.length > 0) {
csvParts.push("# Mood Entries\n" + data.moodEntries);
}
const csvContent = csvParts.join("\n\n");
return new NextResponse(csvContent, {
return new NextResponse(csvParts.join("\n\n"), {
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="healthlog-export-${new Date().toISOString().slice(0, 10)}.csv"`,
"Content-Disposition": `attachment; filename="${filename}.csv"`,
},
});
}
@@ -179,8 +194,100 @@ export const GET = apiHandler(async (request: NextRequest) => {
{
status: 200,
headers: {
"Content-Disposition": `attachment; filename="healthlog-export-${new Date().toISOString().slice(0, 10)}.json"`,
"Content-Disposition": `attachment; filename="${filename}.json"`,
},
},
);
});
async function* prefetchedPages<T>(
iterator: AsyncIterator<T[]>,
first: IteratorResult<T[]>,
): AsyncGenerator<readonly T[], void, void> {
try {
let next = first;
while (!next.done) {
yield next.value;
next = await iterator.next();
}
} finally {
await iterator.return?.();
}
}
async function* legacyCsvChunks(
measurementChunks: AsyncIterable<ExportableRecord[]>,
data: Record<string, unknown>,
): AsyncGenerator<string, void, void> {
let wroteSection = false;
let wroteMeasurementHeader = false;
for await (const records of measurementChunks) {
const csv = toCSV(records);
if (csv.length === 0) continue;
if (!wroteMeasurementHeader) {
wroteMeasurementHeader = true;
wroteSection = true;
yield `# Measurements\n${csv}`;
continue;
}
const headerEnd = csv.indexOf("\n");
if (headerEnd >= 0 && headerEnd + 1 < csv.length) {
yield `\n${csv.slice(headerEnd + 1)}`;
}
}
const staticSections: Array<[string, unknown]> = [
["Medications", data.medications],
["Intake Events", data.intakeEvents],
["Mood Entries", data.moodEntries],
];
for (const [heading, value] of staticSections) {
if (typeof value !== "string" || value.length === 0) continue;
yield `${wroteSection ? "\n\n" : ""}# ${heading}\n${value}`;
wroteSection = true;
}
}
async function* legacyJsonChunks(
measurementChunks: AsyncIterable<ExportableRecord[]>,
data: Record<string, unknown>,
): AsyncGenerator<string, void, void> {
yield '{"data":{"measurements":[';
let wroteMeasurement = false;
for await (const records of measurementChunks) {
if (records.length === 0) continue;
const json = records.map((record) => JSON.stringify(record)).join(",");
yield `${wroteMeasurement ? "," : ""}${json}`;
wroteMeasurement = true;
}
yield "]";
const staticKeys = ["medications", "intakeEvents", "moodEntries"] as const;
for (const key of staticKeys) {
if (!(key in data)) continue;
yield `,${JSON.stringify(key)}:${JSON.stringify(data[key])}`;
}
yield "}}";
}
function streamText(chunks: AsyncIterable<string>): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
const iterator = chunks[Symbol.asyncIterator]();
return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const next = await iterator.next();
if (next.done) {
controller.close();
return;
}
controller.enqueue(encoder.encode(next.value));
} catch (error) {
controller.error(error);
}
},
async cancel() {
await iterator.return?.();
},
});
}
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type * as ApiHandlerModule from "@/lib/api-handler";
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof ApiHandlerModule>("@/lib/api-handler");
return {
...actual,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
};
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(), update: vi.fn() },
googleHealthConnection: { delete: vi.fn() },
},
}));
vi.mock("@/lib/crypto", () => ({ encrypt: (value: string) => `enc:${value}` }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
import { DELETE } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { markDisconnected } from "@/lib/integrations/status";
const connectionDelete = vi.mocked(prisma.googleHealthConnection.delete);
const userUpdate = vi.mocked(prisma.user.update);
beforeEach(() => {
vi.clearAllMocks();
userUpdate.mockResolvedValue({} as never);
});
describe("DELETE /api/google-health/credentials", () => {
it("succeeds when the connection is already missing", async () => {
connectionDelete.mockRejectedValue({ code: "P2025" });
const response = await DELETE();
const envelope = (await response.json()) as {
data: { deleted: boolean } | null;
error: string | null;
};
expect(response.status).toBe(200);
expect(envelope).toEqual({ data: { deleted: true }, error: null });
expect(userUpdate).toHaveBeenCalledWith({
where: { id: "u1" },
data: {
googleHealthClientIdEncrypted: null,
googleHealthClientSecretEncrypted: null,
},
});
expect(auditLog).toHaveBeenCalledWith("google_health.credentials.delete", {
userId: "u1",
});
expect(markDisconnected).toHaveBeenCalledWith("u1", "google-health");
});
it("returns an opaque error and stops teardown on an operational delete failure", async () => {
connectionDelete.mockRejectedValue(
new Error("database host db.internal.example refused the connection"),
);
const response = await DELETE();
const envelope = (await response.json()) as {
data: unknown;
error: string;
};
expect(response.status).toBe(500);
expect(envelope.data).toBeNull();
expect(envelope.error).not.toContain("db.internal.example");
expect(userUpdate).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalled();
expect(markDisconnected).not.toHaveBeenCalled();
});
});
@@ -1,5 +1,6 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { isP2025 } from "@/lib/prisma-errors";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
@@ -87,7 +88,9 @@ export const DELETE = apiHandler(async () => {
await prisma.googleHealthConnection
.delete({ where: { userId: user.id } })
.catch(() => {});
.catch((err) => {
if (!isP2025(err)) throw err;
});
await prisma.user.update({
where: { id: user.id },
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type * as ApiHandlerModule from "@/lib/api-handler";
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof ApiHandlerModule>("@/lib/api-handler");
return {
...actual,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
};
});
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/google-health/sync", () => ({
syncUserGoogleHealth: vi.fn(),
}));
import { POST } from "../route";
import { checkRateLimit } from "@/lib/rate-limit";
import { syncUserGoogleHealth } from "@/lib/google-health/sync";
function request(body: unknown = {}): NextRequest {
return new NextRequest("http://localhost/api/google-health/sync", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
});
describe("POST /api/google-health/sync", () => {
it("returns a non-success envelope when the core sync reports failure", async () => {
vi.mocked(syncUserGoogleHealth).mockResolvedValue({
imported: 0,
failed: true,
});
const response = await POST(request());
const envelope = (await response.json()) as {
data: unknown;
error: string | null;
};
expect(response.status).toBe(502);
expect(envelope.data).toBeNull();
expect(envelope.error).toBe("Google Health sync failed");
});
it("keeps the successful imported-count response unchanged", async () => {
vi.mocked(syncUserGoogleHealth).mockResolvedValue({
imported: 7,
failed: false,
});
const response = await POST(request());
const envelope = (await response.json()) as {
data: { imported: number; fullSync: boolean };
error: string | null;
};
expect(response.status).toBe(200);
expect(envelope).toEqual({
data: { imported: 7, fullSync: false },
error: null,
});
expect(syncUserGoogleHealth).toHaveBeenCalledWith("u1", {
fullSync: false,
});
});
});
+6 -1
View File
@@ -55,6 +55,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
const { imported } = await syncUserGoogleHealth(user.id, { fullSync });
const { imported, failed } = await syncUserGoogleHealth(user.id, {
fullSync,
});
if (failed) {
return apiError("Google Health sync failed", 502);
}
return apiSuccess({ imported, fullSync });
});
+119
View File
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
interface WorkerStatusFixture {
running: boolean;
startedAt: string | null;
lastHeartbeat: string | null;
lastReminderCheck: string | null;
lastWithingsSync: string | null;
lastInsightsRun: string | null;
jobsProcessed: number;
errors: number;
}
const { queryRaw, getWorkerStatus, getGlobalBoss, getSession } = vi.hoisted(
() => ({
queryRaw: vi.fn(async () => 1),
getWorkerStatus: vi.fn((): WorkerStatusFixture => ({
running: false,
startedAt: null,
lastHeartbeat: null,
lastReminderCheck: null,
lastWithingsSync: null,
lastInsightsRun: null,
jobsProcessed: 0,
errors: 0,
})),
getGlobalBoss: vi.fn((): object | null => null),
getSession: vi.fn(async () => null),
}),
);
vi.mock("@/lib/db", () => ({
prisma: { $queryRaw: queryRaw },
}));
vi.mock("@/lib/jobs/worker-status", () => ({ getWorkerStatus }));
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss }));
vi.mock("@/lib/auth/session", () => ({ getSession }));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { GET } from "../route";
async function health() {
const response = await (GET as unknown as () => Promise<Response>)();
return { response, body: (await response.json()) as { status: string } };
}
beforeEach(() => {
vi.clearAllMocks();
queryRaw.mockResolvedValue(1);
getWorkerStatus.mockReturnValue({
running: false,
startedAt: null,
lastHeartbeat: null,
lastReminderCheck: null,
lastWithingsSync: null,
lastInsightsRun: null,
jobsProcessed: 0,
errors: 0,
});
getGlobalBoss.mockReturnValue(null);
getSession.mockResolvedValue(null);
});
describe("GET /api/health process topology", () => {
it("web mode requires DB and producer readiness, not an in-process worker", async () => {
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "web");
getGlobalBoss.mockReturnValue({});
const { response, body } = await health();
expect(response.status).toBe(200);
expect(body).toEqual({ status: "ok" });
});
it("worker mode requires DB and worker readiness, not web producer readiness", async () => {
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "worker");
getWorkerStatus.mockReturnValue({
running: true,
startedAt: "2026-07-20T00:00:00.000Z",
lastHeartbeat: "2026-07-20T00:00:00.000Z",
lastReminderCheck: null,
lastWithingsSync: null,
lastInsightsRun: null,
jobsProcessed: 0,
errors: 0,
});
const { response, body } = await health();
expect(response.status).toBe(200);
expect(body).toEqual({ status: "ok" });
});
it("all mode requires both producer and worker readiness", async () => {
vi.stubEnv("HEALTHLOG_PROCESS_TYPE", "all");
getWorkerStatus.mockReturnValue({
running: true,
startedAt: "2026-07-20T00:00:00.000Z",
lastHeartbeat: "2026-07-20T00:00:00.000Z",
lastReminderCheck: null,
lastWithingsSync: null,
lastInsightsRun: null,
jobsProcessed: 0,
errors: 0,
});
const missingProducer = await health();
expect(missingProducer.response.status).toBe(503);
expect(missingProducer.body).toEqual({ status: "degraded" });
getGlobalBoss.mockReturnValue({});
const ready = await health();
expect(ready.response.status).toBe(200);
expect(ready.body).toEqual({ status: "ok" });
});
});
+9 -1
View File
@@ -1,5 +1,7 @@
import { prisma } from "@/lib/db";
import { getWorkerStatus } from "@/lib/jobs/worker-status";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { shouldRunWeb, shouldRunWorker } from "@/lib/process-type";
import { getSession } from "@/lib/auth/session";
import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
@@ -19,7 +21,13 @@ export const GET = apiHandler(async () => {
}
const worker = getWorkerStatus();
const status = dbOk && worker.running ? "ok" : "degraded";
const producerReady = getGlobalBoss() !== null;
const status =
dbOk &&
(!shouldRunWeb() || producerReady) &&
(!shouldRunWorker() || worker.running)
? "ok"
: "degraded";
const statusCode = status === "ok" ? 200 : 503;
const cacheHeaders = {
@@ -170,10 +170,27 @@ describe("POST /api/import/apple-health-export — dedup by content hash (issue
expect(body.data.jobId).not.toBe("old-failed-job");
expect(mocks.bossSend).toHaveBeenCalledTimes(1);
expect(mocks.importJobCreate).toHaveBeenCalledTimes(1);
expect(mocks.importJobCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ parserRevision: 2 }),
}),
);
expect(mocks.bossSend).toHaveBeenCalledWith(
"apple-health-import-v2",
expect.objectContaining({ userId: "user-1" }),
expect.objectContaining({ retryLimit: 0 }),
);
expect(mocks.importJobUpdate).toHaveBeenCalledWith({
where: { id: "ij-1" },
data: { pgBossJobId: "boss-job-1" },
});
// The dedup lookup must exclude failed rows.
expect(mocks.importJobFindFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: { not: "failed" } }),
where: expect.objectContaining({
status: { not: "failed" },
parserRevision: 2,
}),
}),
);
// A fresh job was staged — nothing to unlink on this path.
@@ -196,5 +213,10 @@ describe("POST /api/import/apple-health-export — dedup by content hash (issue
expect(mocks.importJobCreate).not.toHaveBeenCalled();
// The freshly staged upload is redundant and must be cleaned up.
expect(mocks.unlink).toHaveBeenCalledWith(STAGED_PATH);
expect(mocks.importJobFindFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ parserRevision: 2 }),
}),
);
});
});
@@ -4,7 +4,7 @@
*
* Streams the upload directly to disk (`/tmp/healthlog-upload-*.bin`)
* without buffering the full body in memory, creates an `ImportJob`
* row in `queued`, and enqueues an `apple-health-import` pg-boss job.
* row in `queued`, and enqueues an `apple-health-import-v2` pg-boss job.
* Returns `{ jobId }` so the client can poll
* `GET /api/import/apple-health-export/[jobId]/status`.
*
@@ -29,7 +29,8 @@ import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import {
APPLE_HEALTH_IMPORT_QUEUE,
APPLE_HEALTH_IMPORT_V2_QUEUE,
APPLE_HEALTH_IMPORT_PARSER_REVISION,
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
type AppleHealthImportPayload,
} from "@/lib/jobs/apple-health-import-worker";
@@ -118,6 +119,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
where: {
userId: user.id,
uploadSha256: uploaded.sha256,
parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
status: { not: "failed" },
},
orderBy: { startedAt: "desc" },
@@ -154,6 +156,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
status: "queued",
uploadBytes: uploaded.bytes,
uploadSha256: uploaded.sha256,
parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
},
});
@@ -167,7 +170,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// first run, so a redelivery could only fail on the deleted `/tmp`
// file and mask the real outcome (see the worker's send-options doc).
const bossJobId = await boss.send(
APPLE_HEALTH_IMPORT_QUEUE,
APPLE_HEALTH_IMPORT_V2_QUEUE,
payload,
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
);
@@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type * as ApiHandlerModule from "@/lib/api-handler";
const { requireAuth, requireAssistantSurface, annotate, auditLog } = vi.hoisted(
() => ({
requireAuth: vi.fn(),
requireAssistantSurface: vi.fn(),
annotate: vi.fn(),
auditLog: vi.fn(),
}),
);
vi.mock("@/lib/api-handler", async (importOriginal) => {
const actual = await importOriginal<typeof ApiHandlerModule>();
return {
...actual,
apiHandler: <T extends (...args: never[]) => unknown>(handler: T) =>
handler,
requireAuth,
};
});
vi.mock("@/lib/feature-flags", () => ({ requireAssistantSurface }));
vi.mock("@/lib/logging/context", () => ({ annotate }));
vi.mock("@/lib/auth/audit", () => ({ auditLog }));
vi.mock("@/lib/ai/coach/persistence", () => ({
COACH_CONVERSATION_TITLE_MAX: 80,
deleteConversation: vi.fn(),
fetchConversationWithMessages: vi.fn(),
renameConversation: vi.fn(),
}));
import { PATCH } from "../route";
import { renameConversation } from "@/lib/ai/coach/persistence";
const USER_ID = "user-1";
const CONVERSATION_ID = "conversation-1";
function request(body: unknown): NextRequest {
return new NextRequest(
`http://localhost/api/insights/chat/${CONVERSATION_ID}`,
{
method: "PATCH",
headers: {
"content-type": "application/json",
"x-forwarded-for": "127.0.0.1",
},
body: JSON.stringify(body),
},
);
}
function context(id = CONVERSATION_ID) {
return { params: Promise.resolve({ id }) };
}
async function body(response: Response) {
return response.json() as Promise<{
data: unknown;
error: string | null;
meta?: { errorCode?: string };
}>;
}
beforeEach(() => {
vi.clearAllMocks();
requireAuth.mockResolvedValue({ user: { id: USER_ID, locale: "en" } });
requireAssistantSurface.mockResolvedValue(undefined);
auditLog.mockResolvedValue(undefined);
});
describe("PATCH /api/insights/chat/[id]", () => {
it("requires authentication before reading or updating a conversation", async () => {
const unauthenticated = new Error("Not authenticated");
requireAuth.mockRejectedValue(unauthenticated);
await expect(PATCH(request({ title: "Renamed" }), context())).rejects.toBe(
unauthenticated,
);
expect(requireAssistantSurface).not.toHaveBeenCalled();
expect(renameConversation).not.toHaveBeenCalled();
});
it("rejects empty, whitespace-only, over-80, and extra-field bodies with a stable code", async () => {
for (const payload of [
{ title: "" },
{ title: " " },
{ title: "x".repeat(81) },
{ title: "Valid", ownerId: "other-user" },
]) {
const response = await PATCH(request(payload), context());
expect(response.status).toBe(422);
expect((await body(response)).meta?.errorCode).toBe(
"coach.conversation.invalidTitle",
);
}
expect(renameConversation).not.toHaveBeenCalled();
});
it("maps foreign and unknown ids to the same 404 envelope without an existence leak", async () => {
vi.mocked(renameConversation).mockResolvedValue(null);
const foreign = await PATCH(
request({ title: "Renamed" }),
context("foreign"),
);
const unknown = await PATCH(
request({ title: "Renamed" }),
context("unknown"),
);
expect(foreign.status).toBe(404);
expect(unknown.status).toBe(404);
expect(await body(foreign)).toEqual(await body(unknown));
expect(auditLog).not.toHaveBeenCalled();
});
it("trims and updates an owned title, returns the standard envelope, and audits the rename", async () => {
vi.mocked(renameConversation).mockResolvedValue({
id: CONVERSATION_ID,
title: "Renamed conversation",
});
const response = await PATCH(
request({ title: " Renamed conversation " }),
context(),
);
expect(response.status).toBe(200);
expect(await body(response)).toEqual({
data: { id: CONVERSATION_ID, title: "Renamed conversation" },
error: null,
});
expect(renameConversation).toHaveBeenCalledWith(
USER_ID,
CONVERSATION_ID,
"Renamed conversation",
);
expect(auditLog).toHaveBeenCalledWith(
"coach.conversation.rename",
expect.objectContaining({
userId: USER_ID,
details: { conversationId: CONVERSATION_ID },
}),
);
expect(annotate).toHaveBeenCalledWith({
action: {
name: "insights.coach.rename",
entity_type: "coach_conversation",
entity_id: CONVERSATION_ID,
},
});
});
});
+60 -4
View File
@@ -1,29 +1,46 @@
/**
* GET /api/insights/chat/[id] — fetch one conversation with all
* messages decrypted on the fly.
* GET /api/insights/chat/[id] — fetch one conversation with all
* messages decrypted on the fly.
* PATCH /api/insights/chat/[id] — rename one conversation.
* DELETE /api/insights/chat/[id] — hard-delete the conversation +
* every message under it.
*
* Ownership: both verbs verify the conversation belongs to the
* Ownership: every verb verifies the conversation belongs to the
* authenticated user. A foreign id maps to 404 (NOT 403) so the
* existence channel never leaks across accounts.
*/
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { auditLog } from "@/lib/auth/audit";
import { COACH_CONVERSATION_TITLE_MAX } from "@/lib/ai/coach/types";
import {
deleteConversation,
fetchConversationWithMessages,
renameConversation,
} from "@/lib/ai/coach/persistence";
interface RouteCtx {
params: Promise<{ id: string }>;
}
const renameConversationSchema = z
.object({
title: z.string().trim().min(1).max(COACH_CONVERSATION_TITLE_MAX),
})
.strict();
export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
const auth = await requireAuth();
// v1.4.38 W-C M6 — operator can hide the Coach surface app-wide;
@@ -55,6 +72,45 @@ export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
return apiSuccess(detail);
});
export const PATCH = apiHandler(async (request: NextRequest, ctx: RouteCtx) => {
const auth = await requireAuth();
await requireAssistantSurface("coach");
const { id } = await ctx.params;
if (!id) return apiError("coach.conversation.notFound", 404);
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = renameConversationSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "coach.conversation.invalidTitle",
});
}
const renamed = await renameConversation(auth.user.id, id, parsed.data.title);
if (!renamed) {
return apiError("coach.conversation.notFound", 404);
}
await auditLog("coach.conversation.rename", {
userId: auth.user.id,
ipAddress: getClientIp(request),
details: { conversationId: id },
});
annotate({
action: {
name: "insights.coach.rename",
entity_type: "coach_conversation",
entity_id: id,
},
});
return apiSuccess(renamed);
});
export const DELETE = apiHandler(
async (_request: NextRequest, ctx: RouteCtx) => {
const auth = await requireAuth();
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createHash } from "node:crypto";
/**
* v1.19.1 (C4) — token-efficiency contract. The full SNAPSHOT block (the
@@ -183,6 +184,26 @@ function lastUserPrompt(): string {
return typeof userTurn?.content === "string" ? userTurn.content : "";
}
function lastProviderRequest(): { system: string; user: string } {
const calls = runStreamingRawCompletionWithFallback.mock
.calls as unknown as Array<
[
{
params: {
system: string;
messages: Array<{ role: string; content: string }>;
};
},
]
>;
const params = calls[calls.length - 1][0].params;
return {
system: params.system,
user:
params.messages.find((message) => message.role === "user")?.content ?? "",
};
}
describe("coach chat — snapshot sent once per conversation (C4)", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -199,6 +220,20 @@ describe("coach chat — snapshot sent once per conversation (C4)", () => {
expect(prompt).toContain(GROUNDING);
});
it("preserves the exact first-turn provider request bytes", async () => {
await postAndDrain({ message: "How is my BP?" });
const request = lastProviderRequest();
expect({
system: createHash("sha256").update(request.system).digest("hex"),
user: createHash("sha256").update(request.user).digest("hex"),
}).toEqual({
system:
"af01480eff5efcf94708812d55adacd1d197382112ada9f21150d841969c670a",
user: "8f33d9d501db243e630ae88ca1680b426892520674b22547a17b9740b758d5b5",
});
});
it("does NOT re-ship the snapshot figures on a follow-up turn", async () => {
fetchConversationWithMessages.mockResolvedValue({
id: "c1",
Loaded 100 of 479 files, more files were not shown because too many files have changed in this diff. Show more