mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 20:32:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bb5e1947c | ||
|
|
938e84a43a | ||
|
|
a9bb2f42ba | ||
|
|
f48f496c39 | ||
|
|
0375fadd85 | ||
|
|
0598c90af2 | ||
|
|
ecf2d1a7d5 | ||
|
|
6715c4a69a | ||
|
|
32edb033e8 | ||
|
|
9ef3207cb6 | ||
|
|
fb50335459 | ||
|
|
d750c1827e | ||
|
|
1371bb1cf9 | ||
|
|
79843f8770 | ||
|
|
8736f44442 | ||
|
|
58fddeb0d6 | ||
|
|
c256d945b3 | ||
|
|
35a3842bba | ||
|
|
62eb431d66 | ||
|
|
b01822be48 | ||
|
|
25a56983b3 | ||
|
|
7f55083c3c | ||
|
|
0d5d61bc36 | ||
|
|
b20f1b5c66 | ||
|
|
403eabb888 | ||
|
|
a30c31b25d | ||
|
|
085aa3f5c5 | ||
|
|
e38179e854 | ||
|
|
5fe952b63d | ||
|
|
b38f41b99d | ||
|
|
3ce6726646 | ||
|
|
9adeb8fc19 | ||
|
|
914e7658db | ||
|
|
be1d0fb728 | ||
|
|
3aa8ccd541 |
No files matched your search
@@ -49,6 +49,7 @@ public/sw-version.js
|
||||
|
||||
# Editor / agent worktrees + caches.
|
||||
.claude
|
||||
.worktrees
|
||||
.cursor
|
||||
.aider*
|
||||
.vscode
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}."
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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`
|
||||
|
||||
@@ -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}"]`),
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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" });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Generated
+421
-423
File diff suppressed because it is too large.
Load diff
+23
-3
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -245,5 +245,5 @@ describe("i18n reverse coverage", () => {
|
||||
"Wire a call site, or add a DB-driven prefix to DYNAMIC_ALLOWLIST_PREFIXES.",
|
||||
);
|
||||
}
|
||||
});
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user