mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b01822be48 | ||
|
|
25a56983b3 | ||
|
|
7f55083c3c | ||
|
|
0d5d61bc36 | ||
|
|
b20f1b5c66 | ||
|
|
403eabb888 | ||
|
|
a30c31b25d | ||
|
|
085aa3f5c5 | ||
|
|
e38179e854 | ||
|
|
5fe952b63d | ||
|
|
b38f41b99d | ||
|
|
3ce6726646 | ||
|
|
9adeb8fc19 | ||
|
|
914e7658db | ||
|
|
be1d0fb728 | ||
|
|
3aa8ccd541 | ||
|
|
f1a1887a01 | ||
|
|
ad5806c53a | ||
|
|
00a8c81c8d | ||
|
|
1840779dd7 | ||
|
|
3c5afea912 | ||
|
|
b9425de40e | ||
|
|
74a386fcfd | ||
|
|
f36bbc51cd | ||
|
|
d966355f83 | ||
|
|
a846bbc90d | ||
|
|
4124c483eb | ||
|
|
ca1ba8b128 | ||
|
|
6ebc278c2c | ||
|
|
e9507a0c93 | ||
|
|
7eb3b91925 | ||
|
|
cd6b5f0cc6 | ||
|
|
8b7cbddd72 | ||
|
|
9484240688 | ||
|
|
1b717b4ca0 | ||
|
|
007ea76327 | ||
|
|
197f7bf5a1 | ||
|
|
e47d02702d | ||
|
|
5fc0b1eead | ||
|
|
a87a144b1e | ||
|
|
2716f302c7 | ||
|
|
5d784a639c | ||
|
|
a8ac376cf7 | ||
|
|
f8b0a64d69 | ||
|
|
5f4017d039 | ||
|
|
7b515f4f82 | ||
|
|
452d1a2ae3 | ||
|
|
a6d4bb414c | ||
|
|
f648c1748a | ||
|
|
1f94249bd5 | ||
|
|
d31a33e14c | ||
|
|
932bbc7955 | ||
|
|
140b6954f0 | ||
|
|
4591899657 | ||
|
|
44add3bc1e | ||
|
|
c8529fc0bb | ||
|
|
4cab5b3c63 | ||
|
|
c97ad3ed55 | ||
|
|
d40da09558 | ||
|
|
29673c1155 | ||
|
|
430cbb2ceb | ||
|
|
7b51f9edaf | ||
|
|
c1f583b114 | ||
|
|
db3e56ed08 | ||
|
|
6e1945e8a0 | ||
|
|
e44cf8b804 | ||
|
|
69b61770c4 | ||
|
|
ac9170945d | ||
|
|
87cda9b7a7 | ||
|
|
bec7538012 | ||
|
|
2bf450cf17 | ||
|
|
048d7a4d84 | ||
|
|
663b3530dd | ||
|
|
d5e23ebee6 | ||
|
|
6fee8ad091 | ||
|
|
1a469301b3 | ||
|
|
a0b7dae8eb | ||
|
|
ca170925dc | ||
|
|
096c8c799b | ||
|
|
e53a8ce8a0 | ||
|
|
ee6ae9c450 | ||
|
|
0dba15e902 | ||
|
|
bfb4dbd7da | ||
|
|
8b13b46ac7 | ||
|
|
f9112925d2 | ||
|
|
80e88cfb23 | ||
|
|
9ab8ed8545 | ||
|
|
ccd3c5e68f | ||
|
|
5d62236412 | ||
|
|
1b50dc0909 | ||
|
|
ed049661b7 | ||
|
|
b2168df172 | ||
|
|
d68a7ca244 | ||
|
|
4c65949bac | ||
|
|
670e7097a2 | ||
|
|
30954901ac | ||
|
|
571e415eaa | ||
|
|
4c3cc84daf | ||
|
|
e9d28b399e | ||
|
|
4ddc08f784 | ||
|
|
4bee263a59 | ||
|
|
8168e60dfd | ||
|
|
b0021a8258 | ||
|
|
fd9603a578 | ||
|
|
03fdcddb92 | ||
|
|
2ab21bb433 | ||
|
|
0627648bea | ||
|
|
306d1f7443 | ||
|
|
1c2cfffdb8 | ||
|
|
8bc1d75373 | ||
|
|
12844a9206 | ||
|
|
a8b0365ca5 | ||
|
|
fd6a80a921 | ||
|
|
0fca5ca207 | ||
|
|
b00187e659 | ||
|
|
eb5f81c84a | ||
|
|
80258914f2 | ||
|
|
851a82cc79 | ||
|
|
8e938a4f45 | ||
|
|
d40cb3eef3 | ||
|
|
860cb61b0b | ||
|
|
f05e91e1ef | ||
|
|
02a56047a4 | ||
|
|
fadd21b3fd | ||
|
|
54634a4f12 | ||
|
|
9cd0cc3d03 | ||
|
|
d85e7bf63c | ||
|
|
cd502d5aef | ||
|
|
6f3d06c39b | ||
|
|
d773f460e5 | ||
|
|
e4f5aa0b09 | ||
|
|
db86b4f76d | ||
|
|
cbb9528cb2 | ||
|
|
f8bb8f4b9b | ||
|
|
1679d24660 | ||
|
|
d4c4e5d08b | ||
|
|
943c6ad01f | ||
|
|
59ffa02b0c | ||
|
|
323b111a24 | ||
|
|
f31a12e174 | ||
|
|
c87ce161ba | ||
|
|
c23f2545cc | ||
|
|
57626c9c28 | ||
|
|
0e0f006702 | ||
|
|
f96c18574b | ||
|
|
e6e2210314 | ||
|
|
4183202ed3 | ||
|
|
576be8b746 | ||
|
|
f61483d19b | ||
|
|
b77a721474 | ||
|
|
5c2bee79d8 | ||
|
|
de088febe6 | ||
|
|
624ce3292c | ||
|
|
a3be2d7c92 | ||
|
|
395d59fd30 | ||
|
|
a5631f3d12 | ||
|
|
cd7e2bc0c4 | ||
|
|
79922631ce | ||
|
|
f2b146556a | ||
|
|
79c663e680 | ||
|
|
8a1d7502d0 | ||
|
|
3ccc991851 | ||
|
|
db08962d41 | ||
|
|
a877dcddf5 | ||
|
|
3d1a1cec86 | ||
|
|
e9424ef0be | ||
|
|
eb443a314c | ||
|
|
d0553cdace | ||
|
|
abb9a37672 | ||
|
|
0511e36ac9 | ||
|
|
813ad3ec5a | ||
|
|
81e66cbc5c | ||
|
|
b1370626fc | ||
|
|
7edf79bff3 | ||
|
|
0ddc5097e4 |
No files matched your search
@@ -49,6 +49,7 @@ public/sw-version.js
|
||||
|
||||
# Editor / agent worktrees + caches.
|
||||
.claude
|
||||
.worktrees
|
||||
.cursor
|
||||
.aider*
|
||||
.vscode
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -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:
|
||||
@@ -48,7 +49,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
@@ -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
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
@@ -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"
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
@@ -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
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -85,3 +85,7 @@ next-env.d.ts
|
||||
|
||||
# Local screenshot/audit harnesses (repo-root for module resolution; never committed)
|
||||
.audit-*.mjs
|
||||
|
||||
|
||||
# Isolated development worktrees
|
||||
/.worktrees/
|
||||
+475
-55
@@ -2,105 +2,525 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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,
|
||||
WHOOP, Oura, Polar, Strava, and Apple Health reconcile external and natural
|
||||
measurement identities under database locks, preserve tombstones, emit
|
||||
arrivals only for genuine inserts, and advance cursors only after durable
|
||||
writes. OAuth refresh-token rotation is serialized per user and provider.
|
||||
- **Webhook and import success now means the source data is durable.** moodLog
|
||||
writes before acknowledging delivery, while Withings ECG callbacks enter a
|
||||
durable queue with bounded retry and replay-safe processing. WHOOP backfills
|
||||
commit bounded chunks without advancing failed resource cursors, and legacy
|
||||
mood imports use deterministic, collision-free retry identities. Operational
|
||||
JSON import failures are reported as failures instead of misleading
|
||||
duplicate skips.
|
||||
- **Recovery is complete, exact, and owner-safe.** Canonical backups round-trip
|
||||
every supported record class, including encrypted document content,
|
||||
measurement reconciliation identities, and tombstones. Restore rejects
|
||||
account mismatches before writing, validates backup enums at the boundary,
|
||||
processes stable measurement IDs in bounded batches, and preserves recovery
|
||||
integrity across both uploaded and off-host payloads.
|
||||
- **Device and mood edits no longer lose related state.** Same-user APNs
|
||||
registrations merge transactionally without crossing account boundaries,
|
||||
refresh-token bindings follow the surviving device, and mood edits preserve
|
||||
rated factors unless the caller explicitly replaces them.
|
||||
- **Polar sync follows the current AccessLink collection API.** Collection
|
||||
requests no longer include the obsolete user ID path segment, and current
|
||||
activity, Nightly Recharge, cardio-load, sleep, and SpO2 response shapes are
|
||||
normalized without losing stable measurement identity.
|
||||
- **The July dependency bundle is included.** PDF.js 6 now releases its loading
|
||||
task correctly; Canvas 1.0, Radix UI 1.6.2, Tailwind CSS 4.3.3, the Tailwind
|
||||
Prettier plugin 0.8.1, and setup-node 7 are integrated.
|
||||
|
||||
No migrations. No breaking changes.
|
||||
|
||||
## [1.31.1] — 2026-07-19
|
||||
|
||||
- **Arrival events now follow committed inserts across every writer.** Provider
|
||||
re-sync updates stay silent, partial Apple Health imports retain arrivals
|
||||
from earlier committed batches, all newly inserted workouts emit, and sleep
|
||||
refreshes distinguish new stages from updates. Web-only processes now retry
|
||||
their bounded send-only queue connection instead of permanently losing
|
||||
arrival jobs after one transient startup failure.
|
||||
- **Reaction and workout generation are single-spend operations.** Durable
|
||||
database claims close worker races and provider-retry gaps, daily token
|
||||
reservations are atomic and reconciled on every terminal path, and both
|
||||
generation and display honor the user's Insights and Workouts module choices
|
||||
plus server-managed consent.
|
||||
- **AI context is narrower and more accurate.** Arrival reactions use the
|
||||
record that actually landed, completed sleep uses the reconstructed night
|
||||
rather than one stage segment, user-authored lab fields are fenced as data,
|
||||
workout comparisons use canonical sessions and dates, and every metric and
|
||||
derived-score coach entry carries its exact supported scope.
|
||||
- **Dashboard state survives old and partial clients.** Saved comparison range
|
||||
points and newly supported metric widgets are preserved, while Today
|
||||
freshness remains aligned across local-day boundaries, DST, tab focus, and
|
||||
split web/worker deployments.
|
||||
|
||||
Three migrations (0260, 0261, 0262). No breaking changes.
|
||||
|
||||
## [1.31.0] — "The record that reacts" — 2026-07-19
|
||||
|
||||
A milestone release: the record now visibly responds when something lands,
|
||||
instead of waiting for a daily rollup to notice.
|
||||
|
||||
- **Today shows what just came in.** A completed night's sleep, a workout, a
|
||||
first weigh-in of the day — the home screen now carries a calm "just in"
|
||||
note for a few hours after each one, and at most one written line a day per
|
||||
kind, grounded in your own numbers. Nothing changes if you run without an
|
||||
AI provider: the sentence is the garnish, not the feature — the arrival
|
||||
itself still appears, including on an otherwise-empty record, and an open
|
||||
dashboard refreshes it as soon as the tab becomes visible again. The
|
||||
underlying change that makes this possible without new polling or a flood of
|
||||
background work: every write path that can produce a genuinely new reading
|
||||
now emits a single typed event, day-scoped and de-duplicated, that a mass
|
||||
import or a provider re-sync produces none of — a ten-year import or a
|
||||
month of catch-up sync stays silent, by construction, not by luck.
|
||||
- **A finished workout gets its own paragraph.** Opening a recent session now
|
||||
shows a short, grounded read of that one activity — duration, effort,
|
||||
where it sits against your own recent sessions for the sport — with no
|
||||
training advice and no target zones. It is generated once, the first time
|
||||
you open a fresh session, never on re-sync and never for older workouts
|
||||
already in your record.
|
||||
- **"Ask why" reaches every read-only card.** Every per-metric status card,
|
||||
every derived-score page, and a finished workout now carries a way straight
|
||||
into the coach, pre-loaded with just that card's own data so the
|
||||
conversation starts already grounded — not with a blank prompt.
|
||||
- **The lab-marker card and the offline metric texts speak with the same
|
||||
voice as everything else** — meaning first, in your own language across
|
||||
all six locales, checked by an automated tone pass that now runs on every
|
||||
change to these surfaces so this kind of drift gets caught before it ships
|
||||
again.
|
||||
- **The dashboard layout stops discarding a comparison choice on save,** and
|
||||
four clinical widgets stop disappearing from a saved layout — both from a
|
||||
save made by a client that does not know those particular fields yet.
|
||||
- **Mood and BMI reach the app's home screen**, and a medication pen can now
|
||||
carry the maker and printed strength that show up in the native pen list.
|
||||
- **Every new AI call this release can cause is metered and bounded**: a
|
||||
daily cap per kind, a duplicate-content check that makes a re-post free,
|
||||
and a token reservation that is always reconciled, including on every
|
||||
failure path — the same discipline the rest of the AI surface already
|
||||
holds to.
|
||||
|
||||
Two migrations this release (0257, 0259); an additional dashboard-widget
|
||||
migration carried over from the prior line. No breaking changes.
|
||||
|
||||
## [1.30.35] — 2026-07-19
|
||||
|
||||
- **The metric texts you see without an AI provider now speak your
|
||||
language.** They shipped in German and English only; French, Spanish,
|
||||
Italian and Polish fell through to English. These are what nine metric
|
||||
cards render whenever no provider is reachable — no key configured, an
|
||||
outage, a spent budget — so for those readers this text was the app's
|
||||
voice, in the wrong language. Written per language rather than
|
||||
translated: where a sentence could not be formed naturally, that
|
||||
language got its own sentence.
|
||||
- **The lab-marker card leads with what a value means**, like every other
|
||||
card already did. It was the last one still instructed to state the
|
||||
number first.
|
||||
- **The response length for a metric assessment is set in one place**
|
||||
again, instead of one card carrying its own inline pair.
|
||||
- **The fallback texts open on something honest** rather than on an
|
||||
instruction, and the lab-marker card no longer shows the multi-metric
|
||||
overview tip on a single value.
|
||||
|
||||
No migrations. No breaking changes.
|
||||
|
||||
## [1.30.34] — 2026-07-19
|
||||
|
||||
- **Rearranging your dashboard no longer wipes the comparison baseline.**
|
||||
A layout save from a client that does not know about that setting — the
|
||||
app never sends it — silently reset it to "none". Every field of the
|
||||
layout now has to declare whether an absent value means "clear it" or
|
||||
"leave it alone", so the next field added cannot repeat this.
|
||||
- **Four clinical widgets stopped disappearing from a saved layout.**
|
||||
Pain, grip strength, waist and waist-to-height were dropped by the save
|
||||
as unknown, so a placed widget was lost every time.
|
||||
- **Mood and BMI reach the app's home screen.** Both existed in the
|
||||
record but were absent from the summary the app reads, so those two
|
||||
tiles could not be shown while every other one worked.
|
||||
- **A pen carries its manufacturer and printed strength.** Without them,
|
||||
containers entered on the web could not be shown in the app's pen list.
|
||||
- **Destructive controls ask first.** Signing every other device out,
|
||||
revoking a session, and revoking a trusted device fired on a single
|
||||
tap. A sweep found two more with neither a confirmation nor an undo: a
|
||||
Coach reminder and a travel entry. Deleting a document still does not
|
||||
ask — it can be undone, which covers the mis-tap.
|
||||
- **Delete confirmations stop overclaiming.** Four of them promised the
|
||||
deletion could not be undone while the same screen offered an undo.
|
||||
Warnings that are wrong make the ones that are right easier to ignore.
|
||||
- **The sleep-debt figure is described correctly again.** The calculation
|
||||
changed to a running balance that credits surplus in v1.19.0; the label
|
||||
still said "cumulative shortfall". The wrong description had spread to
|
||||
the Coach, which was reasoning about the number on the old definition,
|
||||
and to the published API description.
|
||||
- **The glucose reference band can be chosen on the web.** The setting
|
||||
already worked and already fed the Coach — nothing on the web set it.
|
||||
It selects a reference range; it is not a diagnosis and is never
|
||||
inferred from a reading.
|
||||
|
||||
One migration (0256), additive. No breaking changes.
|
||||
|
||||
## [1.30.33] — 2026-07-19
|
||||
|
||||
Security release. It ships on its own because it changes how a browser
|
||||
session is recognised.
|
||||
|
||||
- **The session cookie is a purpose-generated secret.** It used to be the
|
||||
session record's own database key — an identifier built to avoid
|
||||
collisions, not to be unguessable, and one that appears wherever that
|
||||
record is referenced. The cookie now carries 32 bytes from the system's
|
||||
random source, and only its hash is stored, so the value in your
|
||||
browser exists nowhere on the server.
|
||||
- **You are not signed out by this.** Sessions from before the change
|
||||
keep working and are simply not renewed, so they run out their normal
|
||||
30 days and disappear. Every sign-in from this release onward is on the
|
||||
new scheme.
|
||||
- **The device list no longer hands out working credentials.** It
|
||||
identified each device by that same record key — which, for a session
|
||||
predating this release, is that device's cookie. It now shows an opaque
|
||||
handle that is useless as a login, and the same handle is what signs a
|
||||
device out.
|
||||
- **A refresh token cannot be rotated from a different device**, and an
|
||||
expired one is refused. The test that was supposed to prove the expiry
|
||||
passed a token that had never been issued, so it proved nothing: with
|
||||
the expiry check deleted, the suite still went green. It is a real test
|
||||
now.
|
||||
- **A Nightscout address is checked when you save it**, not only when it
|
||||
happens to be probed first. The check itself already refused private,
|
||||
loopback and cloud-metadata addresses.
|
||||
|
||||
Operators: rotating `API_TOKEN_HMAC_KEY` now also signs out every browser
|
||||
session, not just API tokens. One migration (0255), additive.
|
||||
|
||||
## [1.30.32] — 2026-07-19
|
||||
|
||||
- **A withdrawn AI consent stays withdrawn.** Opening the AI settings
|
||||
granted consent again if you had taken it back, and recorded it as
|
||||
though you had done it yourself. A withdrawal is now a standing
|
||||
decision that only an explicit act can lift.
|
||||
- **The web can withdraw AI consent at all.** Until now only the app
|
||||
could; on the web, consent could be given and never taken back. The AI
|
||||
settings show what currently stands and offer the control that changes
|
||||
it. Withdrawing takes effect immediately and deletes nothing you have
|
||||
recorded.
|
||||
- **Documents get read after you switch automatic reading on.** Reading
|
||||
was only ever started at upload, so documents already in your vault
|
||||
stayed unread no matter what the setting said. Turning it on now works
|
||||
through what is already there, in batches, asking the same permission
|
||||
as any other read.
|
||||
- **A quoted passage in a document review is the document's own text.**
|
||||
The quote shown for a finding was the wording that came back from the
|
||||
read, not the line it was taken from — so it could be a paraphrase of
|
||||
your own record presented as a verbatim quote. The passage is now
|
||||
looked up in the document itself, and a finding whose source cannot be
|
||||
located says so instead of showing a quote you cannot check.
|
||||
- **Pages that arrive with your data pre-loaded now say so on the wire.**
|
||||
They were never cacheable in practice, but nothing stated it. Anything
|
||||
behind the sign-in wall is now marked private and uncacheable at the
|
||||
edge, so no proxy or future change can hold one person's record and
|
||||
hand it to someone else.
|
||||
- **Deleting your last reading of a kind no longer leaves stale weekly,
|
||||
monthly and yearly figures.** The daily figure went; the longer spans
|
||||
kept the old aggregate indefinitely.
|
||||
- **The documented API matches what the endpoints return.** Twenty
|
||||
corrections, including a total that the measurements list reports in a
|
||||
different place than the specification claimed, four medication figures
|
||||
named wrongly, a missing intake source, and eleven error codes that
|
||||
were never written down.
|
||||
- **A retry after an unusable AI answer asks for the right shape.** The
|
||||
correction prompt demanded fields that do not exist and named none of
|
||||
the ones actually required, so the one corrective attempt steered away
|
||||
from the contract instead of towards it.
|
||||
|
||||
No migrations. No breaking changes.
|
||||
|
||||
## [1.30.31] — 2026-07-19
|
||||
|
||||
- **Today shows a dose before it is late.** The card only surfaced a
|
||||
medication once it was already overdue, so it could report nothing
|
||||
needing attention while a dose was due. A due dose now reads as a calm
|
||||
note, an overdue one as a warning, and a day with everything taken
|
||||
still shows the all-clear. Reminders are unchanged — they still only
|
||||
fire once a dose is late.
|
||||
- **The daily briefing stops disappearing.** The check that verifies
|
||||
every figure against your own data was looking in fewer places than the
|
||||
briefing draws from, so a briefing quoting your medication adherence or
|
||||
a change since last month was thrown away as unverifiable. It now
|
||||
checks against everything the briefing is built from. And when a figure
|
||||
genuinely cannot be verified, yesterday's briefing stays up instead of
|
||||
the section going blank.
|
||||
- **The "read the full briefing" link is gone from Today.** Tapping the
|
||||
health-score ring already opens the same page.
|
||||
- **The close button on a document sits flush with the edge again.**
|
||||
- **A document no longer says its summary is being generated forever.**
|
||||
Summaries are only started when a document is uploaded while automatic
|
||||
reading is on — there is no catch-up for documents you already had, so
|
||||
the view promised a summary that was never coming, and hid the button
|
||||
that would have made one. It now says what actually happened — not
|
||||
attempted, unavailable, or held back — and offers to generate one in
|
||||
every case. A summary you generate that way is kept.
|
||||
- **Removed the "read by AI" note** at the bottom of a document.
|
||||
|
||||
One migration (0254). No breaking changes.
|
||||
|
||||
## [1.30.30] — 2026-07-19
|
||||
|
||||
Four languages get their own words; a failed read stops claiming you have no data.
|
||||
|
||||
- **Reminder notifications, the assistant's reply language, chart and calendar labels, and durations were English or German for French, Spanish, Italian and Polish.** The translations existed; the code that picked between them only knew two languages. It now uses the account's own.
|
||||
- **Polish counts correctly.** Polish has a third plural form that a two-form scheme cannot express, so counted phrases read wrong. The other five languages resolve exactly as before.
|
||||
- **A screening history that failed to load said "not taken yet".** On a mental-health surface that is a false statement about your own record — you could reasonably conclude you never completed something you did complete. That surface, and a nutrient card with the same flaw, now say the reading failed and offer to try again. Nothing retries silently: a failure you cannot see is worse than one you can.
|
||||
- Three sleep-rhythm cards that share one reading each printed the same failure notice; the page now shows it once.
|
||||
|
||||
## [1.30.29] — 2026-07-19
|
||||
|
||||
- **A connector token that may only read is now refused at the write itself**, not only by never being offered the write. The protection was real but rested entirely on how the connection is assembled, so a future change to that assembly could have opened writing with no test noticing. Any write added later inherits the check.
|
||||
- **Names and text read out of your records now carry an explicit boundary when handed to a connected assistant**, matching what the app already does for its own assistant.
|
||||
- **Reading a medication list from a photo measured its cost against the server-wide limit even when you use your own key.** It now uses your own limit. The older way of counting that caused this — the third surface to have it — is removed entirely rather than fixed in place, so nothing can reach for it again.
|
||||
- Removed a module that read as a check on generated recommendations but had not run for some time. It could not simply be switched on: it required fields the current format does not have, so enabling it would have rejected nearly every generation. The measurement it fed is unaffected and still runs.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.28] — 2026-07-19
|
||||
|
||||
- **Daily briefings count against the AI budget.** They were the one
|
||||
remaining kind of generated text that did not, so a morning's briefings
|
||||
could run past the limit you set. On your own key they count against
|
||||
your limit, not the server's.
|
||||
|
||||
## [1.30.27] — 2026-07-19
|
||||
|
||||
- **The resting heart-rate chart no longer stops partway.** Older
|
||||
readings get condensed after a while, and the chart treated the
|
||||
condensed days as the whole story — so recent days went missing. It now
|
||||
shows both, and says when a day is an estimate.
|
||||
- **Summaries recompute for the whole day or month.** Recomputing from a
|
||||
point in the middle replaced the full period with a partial figure.
|
||||
Affected summaries correct themselves the next time that day is written
|
||||
to, or on the next nightly pass.
|
||||
- **A repeated daily total in one upload keeps the newer value.** It kept
|
||||
the older one.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.26] — 2026-07-19
|
||||
|
||||
- **Text read out of your documents is treated as data.** Lab field
|
||||
names were passed to the assistant unchecked, so a document whose
|
||||
labels were written to look like instructions could influence later
|
||||
answers. Those names are cleaned now, and document text is marked as
|
||||
yours rather than as something addressed to the assistant.
|
||||
- **A reminder the assistant picks up from a chat is proposed, not
|
||||
active.** It waits for you to confirm it, like its other suggestions.
|
||||
Reminders from before this release keep working unchanged.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.25] — 2026-07-19
|
||||
|
||||
- **Generated cards count against the AI budget.** Metric cards,
|
||||
biomarker cards, scores and the period narrative were not counted at
|
||||
all. On your own key they count against your limit, not the server's.
|
||||
- **Switching language no longer regenerates everything twice.** The same
|
||||
card in six languages counted as six separate jobs.
|
||||
- **Long assistant conversations get cheaper again.** Past a certain
|
||||
length the full context was re-sent on every turn instead of once.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.24] — 2026-07-19
|
||||
|
||||
The safety check on generated text now covers every surface and every language.
|
||||
|
||||
- **The check that stops a generated text from suggesting a dose change or naming a risk score ran on three surfaces out of roughly forty.** The same sentence the assistant refuses to send in chat could be written onto a metric card and shown for a day, or returned as a document summary. Every place a generated text reaches you now goes through one check.
|
||||
- **It could not see your language.** The check took the text alone, so its wording lists were English and German only — for French, Spanish, Italian and Polish the rule existed in the prompt and was enforced nowhere. The reader's language is now part of the check, and the lists cover all six alongside English, because a provider may answer in English regardless of what it was asked.
|
||||
- **The rule against stating that one thing caused another is now enforced, not just written.** It shipped in all six languages and only one surface checked it.
|
||||
- What happens on a violation depends on the surface: something you asked for right now is replaced with an honest short text; something generated in the background is withheld and the plain non-generated version is kept instead. A document transcription is deliberately never filtered — it reproduces your own document, and a letter from your prescriber saying to increase a dose is a record, not advice from us.
|
||||
- A check on whether generated recommendations cite the data they rest on had been unreachable for some time and is now wired into the nightly run.
|
||||
- **The safety check on generated text covers every card, in every
|
||||
language.** It ran on the assistant's replies but not on the metric
|
||||
cards, biomarker cards or document summaries — so a sentence the
|
||||
assistant would refuse to send could still appear on a card. It also
|
||||
only knew English and German wording, leaving four languages
|
||||
unprotected. Both are fixed.
|
||||
- A document transcription is deliberately never filtered: it reproduces
|
||||
your own document, and a letter from your prescriber is a record, not
|
||||
advice from us.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.23] — 2026-07-19
|
||||
|
||||
Switching a module off holds on the connector wire too.
|
||||
|
||||
- **Assistant connectors honour your module switches.** Three read paths reached the data directly instead of through the gated builder, so with a module off a connected assistant could still read the whole-record doctor summary, and could still get baselines, comparisons and level-shift detection for a metric whose own series correctly reported no data. Correlations were reachable the same way — and through the in-app assistant as well, which had the identical gap. All of them now check first.
|
||||
- **A shared clinician link served the whole-record summary without checking that module** — the same aggregate, reachable by someone who is not signed in at all. It now degrades to the documents-only view instead.
|
||||
- **The function that assembles that summary now refuses on its own** when the module is off, rather than trusting each of its five callers to remember. Three of them had not.
|
||||
- **The confirmation that background queues were migrated is now visible.** The previous release reported it at a log level that is deliberately discarded, so there was no way to tell from the outside whether it had taken effect. It is now a boot entry that appears on every start, including one where nothing needed migrating.
|
||||
- **Switching a module off now also applies to connected assistants.**
|
||||
With a module off, a connected assistant could still read that data —
|
||||
including the full doctor summary — and so could a shared clinician
|
||||
link. Both now honour the switch.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.22] — 2026-07-19
|
||||
|
||||
Background work stops piling up on itself.
|
||||
|
||||
- **Duplicate background jobs are now actually suppressed.** Jobs were queued with a de-duplication key, but the queues were configured in a way that made that key have no effect — so the key looked right in the code and did nothing at run time. A large sync could fan out thousands of identical recompute jobs where tens were intended; a restart during a long history import could append another full import per restart; and the once-per-morning refresh had no debounce, so several paths could each trigger their own run. Each queue now carries an explicit policy chosen for what that queue does, recorded with its reason.
|
||||
- Existing installations are migrated on the next start — the setting could not be changed after a queue was created, so setting it at creation alone would have fixed nothing on any running instance. The first start after this release logs one line per migrated queue, which is how you can confirm it took effect.
|
||||
- One queue is deliberately left as it was: the explicit-range environment backfill sends without a key on purpose, and any de-duplication there would merge two different requested date ranges into one.
|
||||
- **Background jobs stop piling up on themselves.** Duplicate jobs were
|
||||
meant to be collapsed but were not, so a large sync could queue
|
||||
thousands of identical recomputations and a restart could re-queue a
|
||||
running history import. Existing installations are updated on the next
|
||||
start.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.21] — 2026-07-19
|
||||
|
||||
Dose reminders land on the right hour across a clock change.
|
||||
|
||||
- **A reminder on a clock-change day now fires at the time you set.** The conversion from your local time to a real instant read the timezone offset at the moment the scheduler happened to run, not at the time the dose was actually due. On the autumn change in a timezone that observes one, a 20:00 dose could resolve to 19:00 when the scheduler ticked before the change. Two days a year, and only in timezones that change their clocks — but a dose reminder should never be an hour off. Nothing stored changed; the same schedule now produces the correct instant.
|
||||
- **A duplicate pending dose was possible on those same days.** The reminder projector, the worker and the intake write derived the instant separately and could disagree on the change day, which broke the matching that keeps them on one row.
|
||||
- **The next-dose day label could show the wrong day** when your device timezone differs from the one in your profile, because the offset was applied twice. The two copies of that label — in the card and in the table — are now one.
|
||||
- The tests could not have caught any of this: they used times with no timezone attached, so the day boundary could not be distinguished from a UTC one, and the suite ran only in one host timezone. They now state their timezone explicitly, cover both clock changes, and the suite is pinned to match the build.
|
||||
- **Dose reminders land on the right hour across a clock change.** On the
|
||||
day the clocks change, a reminder could resolve an hour off, and the
|
||||
same dose could end up recorded twice. Nothing stored changed.
|
||||
- **The next-dose day label was wrong** when your phone's timezone
|
||||
differed from the one in your profile.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.20] — 2026-07-19
|
||||
|
||||
- **A restore now refuses when the backup file names a different account than the backup record.** The restore target was read from inside the encrypted file rather than from the record the operator selected, so a file claiming a different account would have been written into that account instead — while the audit entry recorded the one the operator picked. Both values were already available at that point; they are now compared, and a mismatch is refused and recorded with both.
|
||||
- **A restore refuses when the backup file names a different account**
|
||||
than the backup you selected.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.19] — 2026-07-19
|
||||
|
||||
Switching a module off now holds everywhere.
|
||||
- **Switching a module off now holds everywhere.** The sync feed still
|
||||
sent cycle data, the FHIR export still returned the full record, and
|
||||
the dashboard summary still built glucose and sleep cards regardless of
|
||||
the switch. Nothing is deleted — turning a module back on finds the
|
||||
history complete.
|
||||
- **Two assistant features sent data out without checking your consent
|
||||
first.** Both now check, and fall back to their plain text when consent
|
||||
is absent.
|
||||
|
||||
- **Three surfaces ignored your module switches.** The per-topic endpoints have always honoured them, but the sync feed still sent cycle data, the FHIR export still returned the full record including the insurance number, and the dashboard summary still built glucose and sleep cards — all regardless of whether those modules were on. Each now honours the switch: the sync feed leaves the cycle rows out, the FHIR export refuses the same way the health-record export already did, and the summary omits the cards. Nothing is deleted — turning a module back on finds the history complete, and the sync feed picks up where it left off rather than skipping what changed while it was hidden.
|
||||
- **Two assistant features reached a provider without a consent record.** The follow-up questions after saving your profile, and the assistant's own nudges, both sent data to a provider without checking whether consent was on file — and could fall back to the server-wide key. Both now check first and fall back to their non-generated text when consent is absent. The follow-up questions also moved to the same reservation-based spend accounting everything else uses; a self-hoster on their own key is no longer measured against the operator's daily ceiling.
|
||||
- The check that was supposed to catch the module gaps accepted a helper being imported as proof that a gate existed. It now pins which module each surface must check and names the tests that exercise a disabled one.
|
||||
|
||||
No breaking changes to the web app. The native client may see a 403 from the FHIR endpoints and absent cards where a module is off — see the coordination note.
|
||||
No breaking changes. The native app may see a 403 from the FHIR
|
||||
endpoints where a module is off.
|
||||
|
||||
## [1.30.18] — 2026-07-19
|
||||
|
||||
Health data and credentials no longer reach the logs or the backups.
|
||||
- **Health data no longer reaches the logs or the backups.** Cached
|
||||
responses are encrypted at rest — they echo what was just saved, so
|
||||
cycle notes and mood text sat unencrypted in every backup for a day.
|
||||
Diagnostic log entries are now scrubbed where they are written, and a
|
||||
failed AI request no longer records what came back with it.
|
||||
|
||||
- **Cached responses are encrypted at rest.** A write endpoint's response is kept for 24 hours so a retried request cannot run twice. Those responses echo what was just saved — cycle notes, mood text, allergy reactions — and were stored unencrypted, in a column that lands in every backup. They are now encrypted like every other stored health field. If encryption is unavailable the response is simply not cached, rather than being written in the clear.
|
||||
- **Diagnostic log entries are scrubbed at the point they are written.** Two of the places that build a log entry did not run the redaction the others did, so an outbound request that timed out could put a notification bot's credential into the logs verbatim.
|
||||
- **A failed AI request no longer quotes what the provider sent back.** Some providers echo the request they rejected, which on this path contains the prompt and the health figures in it. The log entry now names the kind of rejection instead of quoting the response, which answers the same diagnostic question without carrying the content.
|
||||
|
||||
Operator note: no action required, and no configuration change. Log lines and backups written before this release may contain the values described above.
|
||||
|
||||
No breaking changes.
|
||||
Log lines and backups written before this release may still contain those
|
||||
values. No breaking changes.
|
||||
|
||||
## [1.30.17] — 2026-07-19
|
||||
|
||||
API tokens now enforce their scope.
|
||||
- **API tokens enforce their scope.** A token issued for medication
|
||||
intake reached every endpoint, including the full backup export. Routes
|
||||
that accept a narrow token now have to say so.
|
||||
- **Revoke and re-issue any token you gave to a third party.** Treat it
|
||||
as having had that reach for its whole lifetime.
|
||||
- **The generic "create API token" button is gone.** The token it made
|
||||
never worked for the medication intake it advertised, while reaching
|
||||
everything else. Tokens are issued per medication from that
|
||||
medication's own toggle. Existing tokens stay listed and revocable.
|
||||
|
||||
- **A token minted for medication intake is refused outside the ingest endpoint.** Previously any valid token reached every authenticated route regardless of the scope it carried, so a token handed to a third-party automation could read the full-account backup export, lab results, measurements and the assistant. A route that accepts a narrow-scope token now has to name that scope; a route that names none accepts browser sessions and the app's own full-access tokens only. Tokens issued to the web session, the native client and the MCP connector are unaffected.
|
||||
- **Revoke and re-issue any token you handed to a third party.** A token that could reach the backup export should be treated as having had that reach for its whole lifetime, not only from the moment it was noticed.
|
||||
- **The generic "create API token" button is gone from settings.** The token it minted never worked for the medication intake it advertised — the ingest endpoint also requires a per-medication grant that this button never issued — while reaching everything else. Tokens are issued per medication from the medication's own API-endpoint toggle, which grants exactly that one medication. Existing tokens stay listed and revocable.
|
||||
- **MCP connector tokens are now bound to the MCP endpoint alone.** They previously also passed on read-only REST requests. No connector flow used that.
|
||||
|
||||
No breaking changes to the web session, the native client, or the MCP connector.
|
||||
No breaking changes to the web app, the native client or the MCP
|
||||
connector.
|
||||
|
||||
## [1.30.16] — 2026-07-19
|
||||
|
||||
Stops an ongoing loss of resting heart-rate history.
|
||||
|
||||
- **Resting heart rate is preserved again for accounts without a device-supplied resting figure.** Older heart-rate readings are condensed on a schedule to keep the database small, and a resting value is derived and stored before the raw readings go. The check that decides whether an account already has its own resting data was matching the derived values it had written itself, so from the second run onward it concluded the data was already there and stopped deriving — while the raw readings it derives from were still being cleared. Each run lost another day's resting figure permanently. The check now looks only at readings the account actually supplied.
|
||||
- Days already affected cannot be reconstructed; from this release forward, nothing further is lost.
|
||||
- **Resting heart rate stops being lost.** Older heart-rate readings are
|
||||
condensed on a schedule, and a resting value is derived before the raw
|
||||
readings go. A faulty check stopped that derivation after the first
|
||||
run while the raw readings were still cleared, so each run lost another
|
||||
day's resting figure for good. Days already affected cannot be
|
||||
recovered; from here nothing further is lost.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
## [1.30.15] — 2026-07-18
|
||||
|
||||
Generated health texts now come in the language you set.
|
||||
|
||||
- **French, Spanish, Italian and Polish accounts get their assessments in their own language.** Every locale other than English previously fell back to the German instruction set, so those four either read German text or — where the pipeline narrowed them earlier — English text. Insight cards, the per-metric assessments, derived scores, the biomarker card, the period narrative and the assistant's tone settings all now follow the account's own language, with the wording rules that were already translated for each of the six languages. German and English output is unchanged, character for character.
|
||||
- **The language instruction is the last thing the model reads**, rather than being buried before the metric-specific section that follows it.
|
||||
- Under the hood: one shared resolver replaces the scattered English-or-else-German branches, and the locale now survives the whole generation path instead of being narrowed on the way in. Stored texts for the four languages regenerate on their next scheduled run; German and English keep reading exactly the rows they already had.
|
||||
- **Assessments come in the language you set.** French, Spanish, Italian
|
||||
and Polish accounts got German or English text instead of their own.
|
||||
Insight cards, the per-metric assessments, scores, the biomarker card
|
||||
and the period narrative now follow your language. German and English
|
||||
are unchanged. Stored texts in the four languages refresh on their next
|
||||
nightly run.
|
||||
|
||||
No breaking changes.
|
||||
|
||||
|
||||
+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
@@ -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
|
||||
|
||||
+1056
-201
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -47,11 +47,11 @@ echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
| Variable | Purpose | Rotation cost |
|
||||
| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
|
||||
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
|
||||
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token |
|
||||
| Variable | Purpose | Rotation cost |
|
||||
| -------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
|
||||
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
|
||||
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens and web session cookies before storage | Invalidates every issued `hlk_*` token and signs out every browser session |
|
||||
|
||||
You do **not** edit `DATABASE_URL` on the bundled `docker-compose.yml`
|
||||
stack: compose constructs the connection string inside the `app`
|
||||
|
||||
+11
-11
@@ -351,24 +351,24 @@ test.describe("document vault — AI assist + content search", () => {
|
||||
expect(indexHadJsonBody).toBe(false);
|
||||
});
|
||||
|
||||
// ── (h) Provenance: a provider-read document is marked "Read by AI" ──────
|
||||
// ── (h) The detail view does not label how the text was extracted ────────
|
||||
|
||||
test("a vision-indexed document surfaces the AI-read provenance", async ({
|
||||
test("a vision-indexed document carries no extraction-provenance line", async ({
|
||||
page,
|
||||
}) => {
|
||||
// CONTENT_DOC_ID is seeded with source "vision" (an AI provider read it).
|
||||
// No mocks: the real detail GET threads the provenance through. v1.28.38 —
|
||||
// the vault card no longer carries the AI-read glyph (it looked wrong on
|
||||
// the card face); the provenance now lives as a calm muted meta line in the
|
||||
// document DETAIL view. Deep-link straight to the document so the sheet
|
||||
// opens deterministically (the vault grid is virtualized).
|
||||
// CONTENT_DOC_ID is seeded with source "vision". The detail view used to
|
||||
// print a "Read by AI" meta line under the summary; it told the reader
|
||||
// nothing they could act on — how the text came out of the page is our
|
||||
// plumbing, not their business — so it is gone. This test keeps it gone:
|
||||
// the sheet still opens on the deep link, and the line stays absent.
|
||||
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
const marker = sheet.locator('[data-slot="document-detail-ai-read"]');
|
||||
await expect(marker).toBeVisible();
|
||||
await expect(marker).toHaveText("Read by AI");
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-detail-ai-read"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(sheet.getByText("Read by AI")).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ── (f) Text-mode refuses a non-image before any OCR/upload ──────────────
|
||||
|
||||
@@ -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
|
||||
@@ -141,7 +142,11 @@ export async function openCreateWizard(page: Page): Promise<void> {
|
||||
: "http://localhost:3000",
|
||||
},
|
||||
]);
|
||||
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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}"]`),
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.31.0 — Today reacts in place.
|
||||
*
|
||||
* The milestone's actual moment: new data lands, and the ALREADY-OPEN
|
||||
* dashboard starts reading differently on the cadence it already runs. No new
|
||||
* poll, no push, no socket — a state change, not a notification.
|
||||
*
|
||||
* The spec drives that end-to-end against the real read path:
|
||||
*
|
||||
* 1. load the dashboard with nothing fresh — no chip, day is provisional;
|
||||
* 2. land last night's sleep through the REAL batch endpoint, and the
|
||||
* arrival marker the spine's worker would have written;
|
||||
* 3. surface the tab's real visibility-change signal — the browser event
|
||||
* TanStack Query's focus manager consumes for
|
||||
* `refetchOnWindowFocus: "always"` (alongside the 120 s foreground poll);
|
||||
* using that existing trigger keeps the test inside the real cadence
|
||||
* while staying well under the poll interval;
|
||||
* 4. assert the chip appeared and the phase flipped — in place, no reload.
|
||||
*
|
||||
* The marker is seeded directly rather than waited for from pg-boss: whether
|
||||
* the queue drains inside the e2e web server is the SPINE's contract and has
|
||||
* its own tests. What this spec owns is everything downstream of a marker
|
||||
* existing — the read path, the DTO, and the surface.
|
||||
*
|
||||
* Assertions anchor on `data-slot` / `data-phase`, never on visible text:
|
||||
* responsive `sm:hidden` classes have broken `getByText` in this repo before.
|
||||
*/
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
/** The user's local day, in the same profile-tz space the digest files under. */
|
||||
function localDayKey(at: Date, timeZone: string): string {
|
||||
// en-CA renders ISO-ordered YYYY-MM-DD, which is exactly the key shape.
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(at);
|
||||
}
|
||||
|
||||
test("a fresh arrival surfaces the just-in chip and flips the day in place", async ({
|
||||
page,
|
||||
}) => {
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
test.skip(!dbUrl, "DATABASE_URL is required to seed the arrival marker");
|
||||
|
||||
const pool = new pg.Pool({ connectionString: dbUrl });
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<{ id: string; timezone: string | null }>(
|
||||
"SELECT id, timezone FROM users WHERE username = $1",
|
||||
[E2E_USER.username],
|
||||
);
|
||||
expect(rows.length, "seeded e2e user must exist").toBe(1);
|
||||
const userId = rows[0].id;
|
||||
const timezone = rows[0].timezone ?? "UTC";
|
||||
|
||||
// A clean slate: no marker, and last night's sleep not yet in. Both are
|
||||
// preconditions for the "before" assertion to mean anything.
|
||||
await pool.query("DELETE FROM arrival_reactions WHERE user_id = $1", [
|
||||
userId,
|
||||
]);
|
||||
await pool.query(
|
||||
"UPDATE users SET morning_digest_refreshed_on = NULL WHERE id = $1",
|
||||
[userId],
|
||||
);
|
||||
|
||||
// ── BEFORE ────────────────────────────────────────────────────────────
|
||||
// The globally-seeded account is intentionally empty. Wait until the
|
||||
// digest settles, then prove there is no stale hero or arrival chip.
|
||||
await page.goto("/");
|
||||
await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
const hero = page.locator('[data-slot="today-hero"]');
|
||||
await expect(hero).toHaveCount(0);
|
||||
|
||||
// ── NEW DATA LANDS ────────────────────────────────────────────────────
|
||||
// Last night's sleep through the real ingest route, on the session the
|
||||
// storage state already carries.
|
||||
const now = new Date();
|
||||
const wokeAt = new Date(now.getTime() - 60 * 60_000);
|
||||
const fellAsleepAt = new Date(wokeAt.getTime() - 7 * 60 * 60_000);
|
||||
|
||||
const batch = await page.request.post("/api/measurements/batch", {
|
||||
data: {
|
||||
entries: [
|
||||
{
|
||||
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
|
||||
value: 420,
|
||||
unit: "min",
|
||||
startDate: fellAsleepAt.toISOString(),
|
||||
endDate: wokeAt.toISOString(),
|
||||
externalId: `e2e-just-in-${now.getTime()}`,
|
||||
sleepStage: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
batch.ok(),
|
||||
`sleep batch must be accepted (got ${batch.status()})`,
|
||||
).toBe(true);
|
||||
|
||||
// The marker the spine's worker writes on a salient arrival, plus the
|
||||
// morning-refresh stamp it rides alongside — the two rows that together
|
||||
// make the day final and the arrival news.
|
||||
const localDate = localDayKey(now, timezone);
|
||||
await pool.query(
|
||||
`INSERT INTO arrival_reactions
|
||||
(id, user_id, kind, local_date, occurred_at, arrived_at, created_at)
|
||||
VALUES ($1, $2, 'sleep_night', $3, $4, NOW(), NOW())
|
||||
ON CONFLICT (user_id, kind, local_date) DO UPDATE
|
||||
SET occurred_at = EXCLUDED.occurred_at,
|
||||
arrived_at = NOW()`,
|
||||
[`c${now.getTime()}justin000000`, userId, localDate, wokeAt],
|
||||
);
|
||||
await pool.query(
|
||||
"UPDATE users SET morning_digest_refreshed_on = $2 WHERE id = $1",
|
||||
[userId, localDate],
|
||||
);
|
||||
|
||||
// The tab becomes visible — the browser signal TanStack Query's focus
|
||||
// manager consumes. The digest refetches in place; the page never reloads.
|
||||
await page.evaluate(() =>
|
||||
window.dispatchEvent(new Event("visibilitychange")),
|
||||
);
|
||||
|
||||
await expect(hero.locator('[data-slot="today-hero-just-in"]')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(hero).toHaveAttribute("data-phase", "final", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
// Sleep is in, so the provisional freshness note is gone with it.
|
||||
await expect(
|
||||
hero.locator('[data-slot="today-hero-sleep-pending"]'),
|
||||
).toHaveCount(0);
|
||||
// The chip names the arrival kind — the stable attribute, not the copy.
|
||||
await expect(
|
||||
hero.locator('[data-slot="today-hero-just-in"]'),
|
||||
).toHaveAttribute("data-just-in-kind", "sleep_night");
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
});
|
||||
+4
-3
@@ -13,10 +13,11 @@ 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.
|
||||
|
||||
+89
-27
@@ -42,7 +42,9 @@
|
||||
"hidePassword": "Passwort verbergen",
|
||||
"learnMore": "Mehr erfahren",
|
||||
"dismiss": "Schließen",
|
||||
"moreInfo": "Mehr Informationen"
|
||||
"moreInfo": "Mehr Informationen",
|
||||
"durationHoursMinutes": "{hours} Std. {minutes} Min.",
|
||||
"durationMinutes": "{minutes} Min."
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Gesundheitsbericht",
|
||||
@@ -400,8 +402,10 @@
|
||||
"staleHint": "Letzter Wert vor {count} T.",
|
||||
"staleHintWeeksOne": "Letzter Wert vor 1 Woche",
|
||||
"staleHintWeeksOther": "Letzter Wert vor {count} Wochen",
|
||||
"staleHintWeeksFew": "Letzter Wert vor {count} Wochen",
|
||||
"staleHintMonthsOne": "Letzter Wert vor 1 Monat",
|
||||
"staleHintMonthsOther": "Letzter Wert vor {count} Monaten",
|
||||
"staleHintMonthsFew": "Letzter Wert vor {count} Monaten",
|
||||
"recentWorkouts": {
|
||||
"title": "Letzte Workouts",
|
||||
"viewAll": "Alle anzeigen",
|
||||
@@ -421,7 +425,9 @@
|
||||
"steps": "Schritte",
|
||||
"totalBodyWater": "Gesamtkörperwasser",
|
||||
"boneMass": "Knochenmasse",
|
||||
"oxygenSaturation": "Sauerstoffsättigung"
|
||||
"oxygenSaturation": "Sauerstoffsättigung",
|
||||
"mood": "Stimmung",
|
||||
"bmi": "BMI"
|
||||
},
|
||||
"unit": {
|
||||
"weight": "kg",
|
||||
@@ -433,7 +439,9 @@
|
||||
"steps": "Schritte",
|
||||
"totalBodyWater": "kg",
|
||||
"boneMass": "kg",
|
||||
"oxygenSaturation": "%"
|
||||
"oxygenSaturation": "%",
|
||||
"mood": "/5",
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -574,7 +582,7 @@
|
||||
"bulkDeleteSuccess": "{count} Messungen gelöscht",
|
||||
"bulkDeleteError": "Fehler beim Löschen der ausgewählten Messungen",
|
||||
"bulkDeleteConfirmTitle": "{count} Messungen löschen?",
|
||||
"bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden endgültig gelöscht.",
|
||||
"bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden gelöscht. Direkt danach kannst du das kurz rückgängig machen.",
|
||||
"typePhq9Score": "PHQ-9-Wert",
|
||||
"typeGad7Score": "GAD-7-Wert",
|
||||
"typeGripStrength": "Griffstärke",
|
||||
@@ -722,7 +730,7 @@
|
||||
"bulkDeleteSuccess": "{count} Einträge gelöscht",
|
||||
"bulkDeleteError": "Fehler beim Löschen der ausgewählten Einträge",
|
||||
"bulkDeleteConfirmTitle": "{count} Einträge löschen?",
|
||||
"bulkDeleteConfirmBody": "Die {count} ausgewählten Stimmungseinträge werden endgültig gelöscht.",
|
||||
"bulkDeleteConfirmBody": "Die {count} ausgewählten Stimmungseinträge werden gelöscht. Direkt danach kannst du das kurz rückgängig machen.",
|
||||
"customize": "Stimmungs-Tags anpassen",
|
||||
"addTagInline": "Tag hinzufügen",
|
||||
"manage": {
|
||||
@@ -1679,7 +1687,10 @@
|
||||
"packagingDefaultPackLabel": "Standard-Packungsgröße (Einheiten)",
|
||||
"packagingDefaultPackHelper": "Vorbefüllung beim Erfassen neuer Packungen (1–1000). Leer lassen, wenn keine üblich ist.",
|
||||
"packagingSuccess": "Packungsangaben gespeichert",
|
||||
"packagingFailed": "Speichern fehlgeschlagen"
|
||||
"packagingFailed": "Speichern fehlgeschlagen",
|
||||
"addManufacturerLabel": "Hersteller",
|
||||
"addDoseStrengthLabel": "Wirkstärke",
|
||||
"addDoseStrengthHint": "Wie auf dem Behälter aufgedruckt, z. B. 5 mg/0,5 ml."
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -2458,10 +2469,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",
|
||||
@@ -2558,10 +2565,13 @@
|
||||
"relativeJustNow": "gerade eben",
|
||||
"relativeMinutesAgoOne": "vor {count} Minute",
|
||||
"relativeMinutesAgoOther": "vor {count} Minuten",
|
||||
"relativeMinutesAgoFew": "vor {count} Minuten",
|
||||
"relativeHoursAgoOne": "vor {count} Stunde",
|
||||
"relativeHoursAgoOther": "vor {count} Stunden",
|
||||
"relativeHoursAgoFew": "vor {count} Stunden",
|
||||
"relativeDaysAgoOne": "vor {count} Tag",
|
||||
"relativeDaysAgoOther": "vor {count} Tagen",
|
||||
"relativeDaysAgoFew": "vor {count} Tagen",
|
||||
"navOverview": "Übersicht",
|
||||
"navCatalog": "Alle Messwerte",
|
||||
"navBloodPressure": "Blutdruck",
|
||||
@@ -2855,9 +2865,9 @@
|
||||
"debt": {
|
||||
"title": "Schlafschuld",
|
||||
"value": "{hours} Std. {minutes} Min. zu wenig",
|
||||
"caption": "Kumuliertes Defizit der letzten {nights} Nächte",
|
||||
"caption": "Offene Bilanz der letzten {nights} Nächte",
|
||||
"clearValue": "Ausgeglichen",
|
||||
"clearCaption": "Kein aufgelaufenes Defizit über die letzten {nights} Nächte",
|
||||
"clearCaption": "Nichts offen über die letzten {nights} Nächte",
|
||||
"learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für eine verlässliche Bilanz.",
|
||||
"computedInfo": "Das ist HealthLogs eigene Schätzung: eine gleitende Bilanz aus Bedarf minus Schlaf über die letzten Nächte, die Aufholschlaf wieder abbaut. Sie kann vom nativen Schlafdefizit eines Wearables abweichen, das ein eigenes Bedarfsmodell nutzt."
|
||||
},
|
||||
@@ -2961,6 +2971,8 @@
|
||||
"navRecovery": "Erholung",
|
||||
"workouts": {
|
||||
"title": "Workouts",
|
||||
"askWhy": "Warum?",
|
||||
"askWhyPrompt": "Warum ist diese Einheit so ausgefallen? Erkläre mir meine eigenen Werte dazu.",
|
||||
"description": "Letzte Läufe, Touren, Spaziergänge und Krafteinheiten — quellenübergreifend entdoppelt zwischen Apple Watch und Withings.",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Workouts",
|
||||
@@ -3029,7 +3041,8 @@
|
||||
"crossTraining": "Cross-Training",
|
||||
"mixedCardio": "Mixed Cardio",
|
||||
"other": "Sonstiges"
|
||||
}
|
||||
},
|
||||
"loadError": "Deine Workouts konnten nicht geladen werden"
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Blutzucker",
|
||||
@@ -4506,6 +4519,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…",
|
||||
@@ -4664,7 +4678,9 @@
|
||||
"description": "Füge einen Ort und einen Zeitraum hinzu – eine Reise oder einen früheren Abschnitt deiner Verlaufsdaten. Der Zeitraum überschreibt für seine Tage deinen Wohnort, damit das Wetter zu deinem tatsächlichen Aufenthalt passt. So korrigierst du die Vergangenheit.",
|
||||
"none": "Noch keine Ortszeiträume.",
|
||||
"add": "{label} hinzufügen",
|
||||
"remove": "Ortszeitraum entfernen"
|
||||
"remove": "Ortszeitraum entfernen",
|
||||
"removeConfirmTitle": "Diese Reise entfernen?",
|
||||
"removeConfirmBody": "Messwerte aus diesem Zeitraum bleiben erhalten; nur der Reise-Eintrag verschwindet."
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Vergangene Tage nacherfassen",
|
||||
@@ -4945,7 +4961,10 @@
|
||||
"surfaced": "Angezeigt",
|
||||
"done": "Erledigt",
|
||||
"dismissed": "Verworfen"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "Diese Erinnerung löschen?",
|
||||
"deleteConfirmBody": "Sie wird nicht mehr ausgelöst. An bereits erfassten Daten ändert sich nichts.",
|
||||
"deleteConfirmAction": "Löschen"
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "Über mich",
|
||||
@@ -4991,6 +5010,16 @@
|
||||
"honesty": "Der gemeinsame Zugang ist ein einzelnes angemeldetes KI-Konto, das der Betreiber für alle auf diesem Server verbunden hat — nicht dein eigener Schlüssel. Anfragen darüber werden vom Anbieter standardmäßig zum Training genutzt und von keiner Auftragsverarbeitungsvereinbarung gedeckt, die Stunden- und Wochenlimits des Kontos teilst du dir mit allen anderen, die zugestimmt haben, und deine eigenen Anbieter werden weiterhin zuerst versucht. Aktiviere es nur, wenn du diesen Kompromiss akzeptierst.",
|
||||
"confirm": "Verstanden — gemeinsame Verbindung nutzen",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"consent": {
|
||||
"title": "KI-Einwilligung",
|
||||
"activeSince": "Erteilt am {date}. Deine Gesundheitsdaten dürfen für die KI-Funktionen an den eingestellten Anbieter gesendet werden.",
|
||||
"withdrawn": "Widerrufen. Es werden keine Gesundheitsdaten an einen KI-Anbieter gesendet; die KI-Funktionen bleiben aus.",
|
||||
"withdraw": "Einwilligung widerrufen",
|
||||
"confirmBody": "Die KI-Funktionen sind sofort abgeschaltet. Bereits erfasste Daten werden nicht gelöscht — dafür gibt es Daten & Privatsphäre.",
|
||||
"confirmWithdraw": "Widerrufen",
|
||||
"regrantHint": "Um die KI-Funktionen wieder zu nutzen, schalte das automatische Lesen von Dokumenten ein oder erteile die Einwilligung in der App.",
|
||||
"error": "Der Widerruf konnte nicht gespeichert werden. Bitte versuche es erneut."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -5610,8 +5639,16 @@
|
||||
"revokeAllDone": "{count} vertrauenswürdige(s) Gerät(e) entfernt.",
|
||||
"unnamed": "Unbekanntes Gerät",
|
||||
"current": "Dieses Gerät",
|
||||
"expires": "Vertraut bis {date}"
|
||||
}
|
||||
"expires": "Vertraut bis {date}",
|
||||
"revokeConfirmTitle": "Diesem Gerät nicht mehr vertrauen?",
|
||||
"revokeConfirmBody": "Es fragt beim nächsten Anmelden wieder nach deinem zweiten Faktor.",
|
||||
"revokeAllConfirmTitle": "Keinem Gerät mehr vertrauen?",
|
||||
"revokeAllConfirmBody": "Jedes Gerät fragt beim nächsten Anmelden wieder nach deinem zweiten Faktor, auch dieses."
|
||||
},
|
||||
"revokeSessionConfirmTitle": "Dieses Gerät abmelden?",
|
||||
"revokeSessionConfirmBody": "{device} muss sich neu anmelden. An den dort erfassten Daten ändert sich nichts.",
|
||||
"signOutEverywhereConfirmTitle": "Alle anderen Geräte abmelden?",
|
||||
"signOutEverywhereConfirmBody": "Jedes Gerät außer diesem muss sich neu anmelden. Dieses Gerät bleibt angemeldet. An deinen erfassten Daten ändert sich nichts."
|
||||
},
|
||||
"privacy": {
|
||||
"intro": {
|
||||
@@ -5715,6 +5752,14 @@
|
||||
"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.",
|
||||
"docsLink": "Zur Anleitung"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Glukose-Referenzband",
|
||||
"description": "Gegen welches Band deine Blutzuckerwerte bewertet werden.",
|
||||
"enable": "Diabetes-Zielband verwenden",
|
||||
"explainer": "Aus werden Werte gegen das allgemeine nicht-diabetische Referenzband bewertet. An werden sie gegen das engere glykämische Zielband der ADA Standards of Care bewertet, und der Coach begründet seine Glukose-Aussagen auf diesem Band. Deine eigenen Zielwert-Überschreibungen unten haben weiterhin Vorrang.",
|
||||
"disclaimer": "Das wählt ein Referenzband aus. Es ist keine Diagnose, wird nie aus einem Messwert abgeleitet, und die App handelt klinisch nicht danach. Welche Zielwerte für dich gelten, klärst du mit deiner Ärztin oder deinem Arzt.",
|
||||
"error": "Die Einstellung konnte nicht gespeichert werden."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -6195,7 +6240,7 @@
|
||||
"restore": "Wiederherstellen",
|
||||
"restoreAria": "Backup für {username} wiederherstellen",
|
||||
"restoreTitle": "Aus Backup wiederherstellen",
|
||||
"restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt.",
|
||||
"restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt. Falls im Snapshot enthalten, überschreiben instanzweite Einstellungen die Konfiguration der gesamten Installation.",
|
||||
"restorePromptLabel": "Tippe RESTORE zum Fortfahren",
|
||||
"restoreConfirm": "Jetzt wiederherstellen",
|
||||
"restoreInProgress": "Wiederherstellung läuft…",
|
||||
@@ -7453,7 +7498,7 @@
|
||||
"referenceLabel": "Referenz",
|
||||
"readingsCount": "{count} Messungen",
|
||||
"deleteConfirmTitle": "Laborwert löschen?",
|
||||
"deleteConfirmDescription": "Dieser Laborwert wird dauerhaft gelöscht.",
|
||||
"deleteConfirmDescription": "Dieser Laborwert wird gelöscht. Direkt danach kannst du das kurz rückgängig machen.",
|
||||
"deletedToast": "Wert gelöscht",
|
||||
"deleteError": "Wert konnte nicht gelöscht werden",
|
||||
"loadError": "Laborwerte konnten nicht geladen werden",
|
||||
@@ -7932,7 +7977,7 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Diese Episode löschen?",
|
||||
"body": "„{label}“ und alle zugehörigen Tageseinträge werden entfernt. Dies kann nicht rückgängig gemacht werden."
|
||||
"body": "„{label}“ und alle zugehörigen Tageseinträge werden entfernt. Direkt danach kannst du das kurz rückgängig machen."
|
||||
},
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Ruheherzfrequenz",
|
||||
@@ -8015,6 +8060,11 @@
|
||||
"review_due": "Check-in fällig",
|
||||
"reviewed": "Ausgewertet"
|
||||
}
|
||||
},
|
||||
"refusal": {
|
||||
"outOfScope": "Ich kann nur bei den Gesundheitsdaten in deinem Log helfen — Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.",
|
||||
"promptInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen — formuliere deine Frage bitte in diesem Rahmen neu.",
|
||||
"conversationPoisoned": "Eine frühere Nachricht in dieser Unterhaltung enthält Anweisungen, die meine Vorgaben überschreiben sollen. Beginne bitte eine neue Unterhaltung."
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
@@ -8267,7 +8317,9 @@
|
||||
"finally": "Finally …"
|
||||
}
|
||||
},
|
||||
"openDetail": "Details anzeigen"
|
||||
"openDetail": "Details anzeigen",
|
||||
"historyLoadError": "Dein Testverlauf konnte nicht geladen werden",
|
||||
"historyLoadErrorHint": "Das heißt nicht, dass keine Ergebnisse vorliegen — die Liste konnte nicht abgerufen werden. Versuche es erneut."
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Das sind deine eigenen Angaben, keine ärztliche Diagnose. HealthLog stellt keine Diagnose, behandelt nicht und deutet nichts.",
|
||||
@@ -8476,7 +8528,13 @@
|
||||
"title": "Dokumentdetails",
|
||||
"summary": {
|
||||
"title": "Zusammenfassung",
|
||||
"pending": "Die Zusammenfassung wird erstellt."
|
||||
"pending": "Die Zusammenfassung wird erstellt.",
|
||||
"none": "Für dieses Dokument wurde noch keine Zusammenfassung erstellt.",
|
||||
"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.",
|
||||
"saveError": "Die Änderung konnte nicht gespeichert werden.",
|
||||
@@ -8558,7 +8616,6 @@
|
||||
"close": "Ausblenden"
|
||||
},
|
||||
"ai": {
|
||||
"statusAiRead": "Von KI gelesen",
|
||||
"readDone": "Die KI hat dein Dokument gelesen.",
|
||||
"read": "Mit KI auslesen",
|
||||
"reread": "Erneut lesen",
|
||||
@@ -8724,7 +8781,8 @@
|
||||
"rowMeta": "{days} von {window} Tagen · Richtwert {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{days} von {window} Tagen",
|
||||
"footer": "{tracked} von {total} erfassten Mikronährstoffen haben Daten in diesem Zeitraum.",
|
||||
"emptyTitle": "Noch keine Mikronährstoffdaten synchronisiert"
|
||||
"emptyTitle": "Noch keine Mikronährstoffdaten synchronisiert",
|
||||
"loadError": "Deine Nährstoffdaten konnten nicht geladen werden"
|
||||
},
|
||||
"sparseNote": "Ein niedriger Wert bedeutet hier meist lückenhafte Erfassung, keinen Mangel — die Tagessummen aus Apple Health spiegeln wider, was deine Apps erfasst haben, nicht die gesamte Nahrungsaufnahme."
|
||||
},
|
||||
@@ -8740,8 +8798,11 @@
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
"title": "Medikament fällig",
|
||||
"body": "Heute ist eine Dosis fällig.",
|
||||
"bodyNamed": "{name} ist heute fällig."
|
||||
"body": "Eine Dosis ist bald fällig.",
|
||||
"bodyNamed": "{name} ist bald fällig.",
|
||||
"overdueTitle": "Medikament überfällig",
|
||||
"overdueBody": "Eine Dosis ist überfällig.",
|
||||
"overdueBodyNamed": "{name} ist überfällig."
|
||||
},
|
||||
"preventiveCare": {
|
||||
"title": "Vorsorge fällig",
|
||||
@@ -8795,7 +8856,8 @@
|
||||
"deltaVsBaseline": "{delta} ggü. deinem Ausgangswert",
|
||||
"deltaFlat": "Im Bereich deines Ausgangswerts",
|
||||
"sleepPending": "Der Schlaf von letzter Nacht fehlt noch – der heutige Wert ist vorläufig.",
|
||||
"readFullBriefing": "Vollständiges Briefing lesen",
|
||||
"justIn": "Gerade eingetroffen",
|
||||
"justInAt": "Gerade eingetroffen · {time}",
|
||||
"worthALook": "Einen Blick wert",
|
||||
"allClear": "Heute braucht nichts deine Aufmerksamkeit.",
|
||||
"ringLink": "{metric}-Details öffnen"
|
||||
|
||||
+89
-27
@@ -42,7 +42,9 @@
|
||||
"hidePassword": "Hide password",
|
||||
"learnMore": "Learn more",
|
||||
"dismiss": "Dismiss",
|
||||
"moreInfo": "More information"
|
||||
"moreInfo": "More information",
|
||||
"durationHoursMinutes": "{hours}h {minutes}m",
|
||||
"durationMinutes": "{minutes}m"
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Health Report",
|
||||
@@ -400,8 +402,10 @@
|
||||
"staleHint": "Last reading {count} d ago",
|
||||
"staleHintWeeksOne": "Last reading 1 week ago",
|
||||
"staleHintWeeksOther": "Last reading {count} weeks ago",
|
||||
"staleHintWeeksFew": "Last reading {count} weeks ago",
|
||||
"staleHintMonthsOne": "Last reading 1 month ago",
|
||||
"staleHintMonthsOther": "Last reading {count} months ago",
|
||||
"staleHintMonthsFew": "Last reading {count} months ago",
|
||||
"recentWorkouts": {
|
||||
"title": "Recent workouts",
|
||||
"viewAll": "View all",
|
||||
@@ -421,7 +425,9 @@
|
||||
"steps": "Steps",
|
||||
"totalBodyWater": "Total Body Water",
|
||||
"boneMass": "Bone Mass",
|
||||
"oxygenSaturation": "Oxygen Saturation"
|
||||
"oxygenSaturation": "Oxygen Saturation",
|
||||
"mood": "Mood",
|
||||
"bmi": "BMI"
|
||||
},
|
||||
"unit": {
|
||||
"weight": "kg",
|
||||
@@ -433,7 +439,9 @@
|
||||
"steps": "steps",
|
||||
"totalBodyWater": "kg",
|
||||
"boneMass": "kg",
|
||||
"oxygenSaturation": "%"
|
||||
"oxygenSaturation": "%",
|
||||
"mood": "/5",
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -574,7 +582,7 @@
|
||||
"bulkDeleteSuccess": "{count} measurements deleted",
|
||||
"bulkDeleteError": "Error deleting the selected measurements",
|
||||
"bulkDeleteConfirmTitle": "Delete {count} measurements?",
|
||||
"bulkDeleteConfirmBody": "The {count} selected measurements will be permanently deleted.",
|
||||
"bulkDeleteConfirmBody": "The {count} selected measurements will be deleted. You can undo this for a short moment afterwards.",
|
||||
"typePhq9Score": "PHQ-9 score",
|
||||
"typeGad7Score": "GAD-7 score",
|
||||
"typeGripStrength": "Grip strength",
|
||||
@@ -722,7 +730,7 @@
|
||||
"bulkDeleteSuccess": "{count} entries deleted",
|
||||
"bulkDeleteError": "Error deleting the selected entries",
|
||||
"bulkDeleteConfirmTitle": "Delete {count} entries?",
|
||||
"bulkDeleteConfirmBody": "The {count} selected mood entries will be permanently deleted.",
|
||||
"bulkDeleteConfirmBody": "The {count} selected mood entries will be deleted. You can undo this for a short moment afterwards.",
|
||||
"customize": "Customize mood tags",
|
||||
"addTagInline": "Add tag",
|
||||
"manage": {
|
||||
@@ -1679,7 +1687,10 @@
|
||||
"packagingDefaultPackLabel": "Default pack size (units)",
|
||||
"packagingDefaultPackHelper": "Prefills the quantity when registering new packs (1–1000). Leave empty if none applies.",
|
||||
"packagingSuccess": "Packaging details saved",
|
||||
"packagingFailed": "Saving failed"
|
||||
"packagingFailed": "Saving failed",
|
||||
"addManufacturerLabel": "Manufacturer",
|
||||
"addDoseStrengthLabel": "Strength",
|
||||
"addDoseStrengthHint": "As printed on the container, e.g. 5 mg/0.5 ml."
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -2458,10 +2469,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",
|
||||
@@ -2558,10 +2565,13 @@
|
||||
"relativeJustNow": "just now",
|
||||
"relativeMinutesAgoOne": "{count} minute ago",
|
||||
"relativeMinutesAgoOther": "{count} minutes ago",
|
||||
"relativeMinutesAgoFew": "{count} minutes ago",
|
||||
"relativeHoursAgoOne": "{count} hour ago",
|
||||
"relativeHoursAgoOther": "{count} hours ago",
|
||||
"relativeHoursAgoFew": "{count} hours ago",
|
||||
"relativeDaysAgoOne": "{count} day ago",
|
||||
"relativeDaysAgoOther": "{count} days ago",
|
||||
"relativeDaysAgoFew": "{count} days ago",
|
||||
"navOverview": "Overview",
|
||||
"navCatalog": "All metrics",
|
||||
"navBloodPressure": "Blood Pressure",
|
||||
@@ -2855,9 +2865,9 @@
|
||||
"debt": {
|
||||
"title": "Sleep debt",
|
||||
"value": "{hours}h {minutes}m short",
|
||||
"caption": "Cumulative shortfall over the last {nights} nights",
|
||||
"caption": "Outstanding balance over the last {nights} nights",
|
||||
"clearValue": "All caught up",
|
||||
"clearCaption": "No accumulated shortfall over the last {nights} nights",
|
||||
"clearCaption": "Nothing outstanding over the last {nights} nights",
|
||||
"learning": "Still learning your sleep — {counted} nights so far, {remaining} more to read a reliable balance.",
|
||||
"computedInfo": "This is HealthLog's own estimate: a rolling balance of need minus sleep over recent nights, with catch-up sleep paying it down. It can differ from a wearable's native sleep-debt figure, which uses its own need model."
|
||||
},
|
||||
@@ -2961,6 +2971,8 @@
|
||||
"navRecovery": "Recovery",
|
||||
"workouts": {
|
||||
"title": "Workouts",
|
||||
"askWhy": "Ask why",
|
||||
"askWhyPrompt": "Why did this session read the way it did? Walk me through my own numbers from it.",
|
||||
"description": "Recent runs, rides, walks and strength sessions, deduped across Apple Watch and Withings sources.",
|
||||
"emptyState": {
|
||||
"title": "No workouts yet",
|
||||
@@ -3029,7 +3041,8 @@
|
||||
"crossTraining": "Cross training",
|
||||
"mixedCardio": "Mixed cardio",
|
||||
"other": "Other"
|
||||
}
|
||||
},
|
||||
"loadError": "Your workouts could not be loaded"
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Blood Glucose",
|
||||
@@ -4506,6 +4519,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…",
|
||||
@@ -4664,7 +4678,9 @@
|
||||
"description": "Add a place and a date range — a trip, or a stretch of earlier history. The period overrides your home for the days it covers, so weather matches where you actually were. This is how you correct the past.",
|
||||
"none": "No location periods yet.",
|
||||
"add": "Add {label}",
|
||||
"remove": "Remove location period"
|
||||
"remove": "Remove location period",
|
||||
"removeConfirmTitle": "Remove this trip?",
|
||||
"removeConfirmBody": "Readings taken during it stay in your record; only the trip entry goes."
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Backfill past days",
|
||||
@@ -4945,7 +4961,10 @@
|
||||
"surfaced": "Surfaced",
|
||||
"done": "Done",
|
||||
"dismissed": "Dismissed"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "Delete this reminder?",
|
||||
"deleteConfirmBody": "It stops being raised. Anything you have already recorded is untouched.",
|
||||
"deleteConfirmAction": "Delete"
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "About me",
|
||||
@@ -4991,6 +5010,16 @@
|
||||
"honesty": "The shared access is a single signed-in AI account the operator connected for everyone on this server — not your own key. Requests you make on it are trained on by the provider by default and no data-processing agreement covers them, the account's hourly and weekly limits are shared with every other user who opted in, and your own configured providers are still tried first. Turn it on only if you accept this trade.",
|
||||
"confirm": "I understand — use the shared connection",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"consent": {
|
||||
"title": "AI consent",
|
||||
"activeSince": "Given on {date}. Your health data may be sent to the configured provider for the AI features.",
|
||||
"withdrawn": "Withdrawn. No health data is sent to an AI provider; the AI features stay switched off.",
|
||||
"withdraw": "Withdraw consent",
|
||||
"confirmBody": "The AI features stop working straight away. Nothing already recorded is deleted — use Data & privacy to export or erase your record.",
|
||||
"confirmWithdraw": "Withdraw",
|
||||
"regrantHint": "To use the AI features again, switch on automatic document reading or grant consent from the app.",
|
||||
"error": "The withdrawal could not be saved. Please try again."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -5610,8 +5639,16 @@
|
||||
"revokeAllDone": "Forgot {count} trusted device(s).",
|
||||
"unnamed": "Unknown device",
|
||||
"current": "This device",
|
||||
"expires": "Trusted until {date}"
|
||||
}
|
||||
"expires": "Trusted until {date}",
|
||||
"revokeConfirmTitle": "Stop trusting this device?",
|
||||
"revokeConfirmBody": "It will ask for your second factor again the next time you sign in from it.",
|
||||
"revokeAllConfirmTitle": "Stop trusting every device?",
|
||||
"revokeAllConfirmBody": "Every device will ask for your second factor again the next time you sign in from it, including this one."
|
||||
},
|
||||
"revokeSessionConfirmTitle": "Sign this device out?",
|
||||
"revokeSessionConfirmBody": "{device} will need to sign in again. Nothing recorded on it is affected.",
|
||||
"signOutEverywhereConfirmTitle": "Sign out every other device?",
|
||||
"signOutEverywhereConfirmBody": "Every device except this one will need to sign in again. This device stays signed in. Nothing you have recorded is affected."
|
||||
},
|
||||
"privacy": {
|
||||
"intro": {
|
||||
@@ -5715,6 +5752,14 @@
|
||||
"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.",
|
||||
"docsLink": "Read the guide"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Glucose reference band",
|
||||
"description": "Which band your blood-glucose readings are judged against.",
|
||||
"enable": "Use the diabetes goal band",
|
||||
"explainer": "Off, readings are judged against the general non-diabetic reference band. On, they are judged against the tighter glycaemic goal band from the ADA Standards of Care, and the Coach grounds its glucose remarks on that band instead. Your own target overrides below still take precedence.",
|
||||
"disclaimer": "This selects a reference band. It is not a diagnosis, it is never inferred from a reading, and the app takes no clinical action on it. Which targets apply to you is a question for your clinician.",
|
||||
"error": "Could not save the setting."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -6195,7 +6240,7 @@
|
||||
"restore": "Restore",
|
||||
"restoreAria": "Restore from backup for {username}",
|
||||
"restoreTitle": "Restore from backup",
|
||||
"restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted.",
|
||||
"restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted. If included in the snapshot, instance-wide settings overwrite the configuration for the entire installation.",
|
||||
"restorePromptLabel": "Type RESTORE to continue",
|
||||
"restoreConfirm": "Restore now",
|
||||
"restoreInProgress": "Restoring…",
|
||||
@@ -7453,7 +7498,7 @@
|
||||
"referenceLabel": "Reference",
|
||||
"readingsCount": "{count} readings",
|
||||
"deleteConfirmTitle": "Delete lab result?",
|
||||
"deleteConfirmDescription": "This lab result will be permanently deleted.",
|
||||
"deleteConfirmDescription": "This lab result will be deleted. You can undo this for a short moment afterwards.",
|
||||
"deletedToast": "Result deleted",
|
||||
"deleteError": "Could not delete the result",
|
||||
"loadError": "Could not load lab results",
|
||||
@@ -7932,7 +7977,7 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Delete this episode?",
|
||||
"body": "“{label}” and all its day-logs will be removed. This cannot be undone."
|
||||
"body": "“{label}” and all its day-logs will be removed. You can undo this for a short moment afterwards."
|
||||
},
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Resting heart rate",
|
||||
@@ -8015,6 +8060,11 @@
|
||||
"review_due": "Check-in due",
|
||||
"reviewed": "Reviewed"
|
||||
}
|
||||
},
|
||||
"refusal": {
|
||||
"outOfScope": "I can only help with the health metrics in your log — measurements, medications, mood, trends. I cannot answer questions outside that scope.",
|
||||
"promptInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
|
||||
"conversationPoisoned": "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation."
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
@@ -8267,7 +8317,9 @@
|
||||
"finally": "Finally …"
|
||||
}
|
||||
},
|
||||
"openDetail": "Show details"
|
||||
"openDetail": "Show details",
|
||||
"historyLoadError": "Your assessment history could not be loaded",
|
||||
"historyLoadErrorHint": "This does not mean you have no results — the list could not be fetched. Try again."
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "These are your own records, not a medical diagnosis. HealthLog does not diagnose, treat, or interpret them.",
|
||||
@@ -8476,7 +8528,13 @@
|
||||
"title": "Document details",
|
||||
"summary": {
|
||||
"title": "Summary",
|
||||
"pending": "The summary is being prepared."
|
||||
"pending": "The summary is being prepared.",
|
||||
"none": "No summary has been generated for this document yet.",
|
||||
"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.",
|
||||
"saveError": "Couldn't save the change.",
|
||||
@@ -8558,7 +8616,6 @@
|
||||
"close": "Hide"
|
||||
},
|
||||
"ai": {
|
||||
"statusAiRead": "Read by AI",
|
||||
"readDone": "The AI read your document.",
|
||||
"read": "Read with AI",
|
||||
"reread": "Read again",
|
||||
@@ -8724,7 +8781,8 @@
|
||||
"rowMeta": "{days} of {window} days · ref {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{days} of {window} days",
|
||||
"footer": "{tracked} of {total} tracked micronutrients have data in this window.",
|
||||
"emptyTitle": "No micronutrient data synced yet"
|
||||
"emptyTitle": "No micronutrient data synced yet",
|
||||
"loadError": "Your nutrient data could not be loaded"
|
||||
},
|
||||
"sparseNote": "A low number here usually means sparse logging, not a deficiency — Apple Health day totals reflect what your apps logged, not total dietary intake."
|
||||
},
|
||||
@@ -8740,8 +8798,11 @@
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
"title": "Medication due",
|
||||
"body": "A dose is due today.",
|
||||
"bodyNamed": "{name} is due today."
|
||||
"body": "A dose is due soon.",
|
||||
"bodyNamed": "{name} is due soon.",
|
||||
"overdueTitle": "Medication overdue",
|
||||
"overdueBody": "A dose is past due.",
|
||||
"overdueBodyNamed": "{name} is past due."
|
||||
},
|
||||
"preventiveCare": {
|
||||
"title": "Check-up due",
|
||||
@@ -8795,7 +8856,8 @@
|
||||
"deltaVsBaseline": "{delta} vs your baseline",
|
||||
"deltaFlat": "In line with your baseline",
|
||||
"sleepPending": "Last night's sleep isn't in yet — today's read is provisional.",
|
||||
"readFullBriefing": "Read the full briefing",
|
||||
"justIn": "Just in",
|
||||
"justInAt": "Just in · {time}",
|
||||
"worthALook": "Worth a look",
|
||||
"allClear": "Nothing needs your attention today.",
|
||||
"ringLink": "Open {metric} details"
|
||||
|
||||
+88
-26
@@ -42,7 +42,9 @@
|
||||
"hidePassword": "Ocultar contraseña",
|
||||
"learnMore": "Más información",
|
||||
"dismiss": "Descartar",
|
||||
"moreInfo": "Más información"
|
||||
"moreInfo": "Más información",
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationMinutes": "{minutes} min"
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Informe de salud",
|
||||
@@ -400,8 +402,10 @@
|
||||
"staleHint": "Último valor hace {count} d",
|
||||
"staleHintWeeksOne": "Último valor hace 1 semana",
|
||||
"staleHintWeeksOther": "Último valor hace {count} semanas",
|
||||
"staleHintWeeksFew": "Último valor hace {count} semanas",
|
||||
"staleHintMonthsOne": "Último valor hace 1 mes",
|
||||
"staleHintMonthsOther": "Último valor hace {count} meses",
|
||||
"staleHintMonthsFew": "Último valor hace {count} meses",
|
||||
"recentWorkouts": {
|
||||
"title": "Entrenamientos recientes",
|
||||
"viewAll": "Ver todos",
|
||||
@@ -421,7 +425,9 @@
|
||||
"steps": "Pasos",
|
||||
"totalBodyWater": "Agua corporal total",
|
||||
"boneMass": "Masa ósea",
|
||||
"oxygenSaturation": "Saturación de oxígeno"
|
||||
"oxygenSaturation": "Saturación de oxígeno",
|
||||
"mood": "Estado de ánimo",
|
||||
"bmi": "IMC"
|
||||
},
|
||||
"unit": {
|
||||
"weight": "kg",
|
||||
@@ -433,7 +439,9 @@
|
||||
"steps": "pasos",
|
||||
"totalBodyWater": "kg",
|
||||
"boneMass": "kg",
|
||||
"oxygenSaturation": "%"
|
||||
"oxygenSaturation": "%",
|
||||
"mood": "/5",
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -574,7 +582,7 @@
|
||||
"bulkDeleteSuccess": "{count} mediciones eliminadas",
|
||||
"bulkDeleteError": "Error al eliminar las mediciones seleccionadas",
|
||||
"bulkDeleteConfirmTitle": "¿Eliminar {count} mediciones?",
|
||||
"bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán de forma permanente.",
|
||||
"bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán. Justo después podrás deshacerlo durante un breve momento.",
|
||||
"typePhq9Score": "Puntuación PHQ-9",
|
||||
"typeGad7Score": "Puntuación GAD-7",
|
||||
"typeGripStrength": "Fuerza de agarre",
|
||||
@@ -722,7 +730,7 @@
|
||||
"bulkDeleteSuccess": "{count} entradas eliminadas",
|
||||
"bulkDeleteError": "Error al eliminar las entradas seleccionadas",
|
||||
"bulkDeleteConfirmTitle": "¿Eliminar {count} entradas?",
|
||||
"bulkDeleteConfirmBody": "Las {count} entradas de estado de ánimo seleccionadas se eliminarán de forma permanente.",
|
||||
"bulkDeleteConfirmBody": "Las {count} entradas de estado de ánimo seleccionadas se eliminarán. Justo después podrás deshacerlo durante un breve momento.",
|
||||
"customize": "Personalizar etiquetas de ánimo",
|
||||
"addTagInline": "Añadir etiqueta",
|
||||
"manage": {
|
||||
@@ -1679,7 +1687,10 @@
|
||||
"packagingDefaultPackLabel": "Tamaño de envase predeterminado (unidades)",
|
||||
"packagingDefaultPackHelper": "Rellena la cantidad al registrar envases nuevos (1–1000). Déjalo vacío si no aplica.",
|
||||
"packagingSuccess": "Datos del envase guardados",
|
||||
"packagingFailed": "Error al guardar"
|
||||
"packagingFailed": "Error al guardar",
|
||||
"addManufacturerLabel": "Fabricante",
|
||||
"addDoseStrengthLabel": "Concentración",
|
||||
"addDoseStrengthHint": "Tal como aparece impreso en el envase, p. ej. 5 mg/0,5 ml."
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -2458,10 +2469,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",
|
||||
@@ -2558,10 +2565,13 @@
|
||||
"relativeJustNow": "ahora mismo",
|
||||
"relativeMinutesAgoOne": "hace {count} minuto",
|
||||
"relativeMinutesAgoOther": "hace {count} minutos",
|
||||
"relativeMinutesAgoFew": "hace {count} minutos",
|
||||
"relativeHoursAgoOne": "hace {count} hora",
|
||||
"relativeHoursAgoOther": "hace {count} horas",
|
||||
"relativeHoursAgoFew": "hace {count} horas",
|
||||
"relativeDaysAgoOne": "hace {count} día",
|
||||
"relativeDaysAgoOther": "hace {count} días",
|
||||
"relativeDaysAgoFew": "hace {count} días",
|
||||
"navOverview": "Resumen",
|
||||
"navCatalog": "Todas las métricas",
|
||||
"navBloodPressure": "Presión arterial",
|
||||
@@ -2855,9 +2865,9 @@
|
||||
"debt": {
|
||||
"title": "Deuda de sueño",
|
||||
"value": "{hours} h {minutes} min de déficit",
|
||||
"caption": "Déficit acumulado de las últimas {nights} noches",
|
||||
"caption": "Balance pendiente de las últimas {nights} noches",
|
||||
"clearValue": "Al día",
|
||||
"clearCaption": "Sin déficit acumulado en las últimas {nights} noches",
|
||||
"clearCaption": "Nada pendiente en las últimas {nights} noches",
|
||||
"learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para un balance fiable.",
|
||||
"computedInfo": "Esta es la estimación propia de HealthLog: un balance móvil de la necesidad menos el sueño de las últimas noches, que el sueño de recuperación va reduciendo. Puede diferir de la cifra de deuda de sueño nativa de un wearable, que usa su propio modelo de necesidad."
|
||||
},
|
||||
@@ -2961,6 +2971,8 @@
|
||||
"navRecovery": "Recuperación",
|
||||
"workouts": {
|
||||
"title": "Entrenamientos",
|
||||
"askWhy": "Por qué",
|
||||
"askWhyPrompt": "¿Por qué salió así esta sesión? Explícame mis propios datos de ella.",
|
||||
"description": "Carreras, salidas en bici, paseos y sesiones de fuerza recientes, deduplicados entre Apple Watch y Withings.",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay entrenamientos",
|
||||
@@ -3029,7 +3041,8 @@
|
||||
"crossTraining": "Entreno cruzado",
|
||||
"mixedCardio": "Cardio mixto",
|
||||
"other": "Otro"
|
||||
}
|
||||
},
|
||||
"loadError": "No se pudieron cargar tus entrenamientos"
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Glucosa en sangre",
|
||||
@@ -4506,6 +4519,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…",
|
||||
@@ -4664,7 +4678,9 @@
|
||||
"description": "Añade un lugar y un rango de fechas: un viaje o un tramo de tu historial anterior. El periodo anula tu hogar en los días que cubre, para que el tiempo coincida con donde estuviste realmente. Así corriges el pasado.",
|
||||
"none": "Aún no hay periodos de ubicación.",
|
||||
"add": "Añadir {label}",
|
||||
"remove": "Eliminar periodo de ubicación"
|
||||
"remove": "Eliminar periodo de ubicación",
|
||||
"removeConfirmTitle": "¿Quitar este viaje?",
|
||||
"removeConfirmBody": "Las mediciones de ese periodo permanecen; solo desaparece la entrada del viaje."
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Rellenar días pasados",
|
||||
@@ -4945,7 +4961,10 @@
|
||||
"surfaced": "Mostrado",
|
||||
"done": "Hecho",
|
||||
"dismissed": "Descartado"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "¿Eliminar este recordatorio?",
|
||||
"deleteConfirmBody": "Deja de aparecer. Lo que ya has registrado no cambia.",
|
||||
"deleteConfirmAction": "Eliminar"
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "Sobre mí",
|
||||
@@ -4991,6 +5010,16 @@
|
||||
"honesty": "El acceso compartido es una única cuenta de IA con sesión iniciada que el operador conectó para todos en este servidor, no tu propia clave. Las solicitudes que hagas se usan de forma predeterminada para entrenar los modelos del proveedor y ningún acuerdo de tratamiento de datos las cubre, los límites por hora y semanales de la cuenta se comparten con todos los demás que se han unido, y tus propios proveedores se prueban primero. Actívalo solo si aceptas este compromiso.",
|
||||
"confirm": "Entendido: usar la conexión compartida",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"consent": {
|
||||
"title": "Consentimiento de IA",
|
||||
"activeSince": "Otorgado el {date}. Tus datos de salud pueden enviarse al proveedor configurado para las funciones de IA.",
|
||||
"withdrawn": "Retirado. No se envían datos de salud a ningún proveedor de IA; las funciones de IA permanecen desactivadas.",
|
||||
"withdraw": "Retirar el consentimiento",
|
||||
"confirmBody": "Las funciones de IA dejan de funcionar de inmediato. No se borra nada de lo ya registrado: usa Datos y privacidad para exportar o eliminar tu historial.",
|
||||
"confirmWithdraw": "Retirar",
|
||||
"regrantHint": "Para volver a usar las funciones de IA, activa la lectura automática de documentos u otorga el consentimiento desde la aplicación.",
|
||||
"error": "No se pudo guardar la retirada. Inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -5610,8 +5639,16 @@
|
||||
"revokeAllDone": "Se olvidaron {count} dispositivo(s) de confianza.",
|
||||
"unnamed": "Dispositivo desconocido",
|
||||
"current": "Este dispositivo",
|
||||
"expires": "De confianza hasta el {date}"
|
||||
}
|
||||
"expires": "De confianza hasta el {date}",
|
||||
"revokeConfirmTitle": "¿Dejar de confiar en este dispositivo?",
|
||||
"revokeConfirmBody": "Volverá a pedir tu segundo factor la próxima vez que inicies sesión desde él.",
|
||||
"revokeAllConfirmTitle": "¿Dejar de confiar en todos los dispositivos?",
|
||||
"revokeAllConfirmBody": "Todos los dispositivos volverán a pedir tu segundo factor la próxima vez que inicies sesión, incluido este."
|
||||
},
|
||||
"revokeSessionConfirmTitle": "¿Cerrar la sesión de este dispositivo?",
|
||||
"revokeSessionConfirmBody": "{device} tendrá que iniciar sesión de nuevo. Lo registrado en él no se ve afectado.",
|
||||
"signOutEverywhereConfirmTitle": "¿Cerrar la sesión en los demás dispositivos?",
|
||||
"signOutEverywhereConfirmBody": "Todos los dispositivos salvo este tendrán que iniciar sesión de nuevo. Este seguirá con la sesión abierta. Nada de lo que has registrado se ve afectado."
|
||||
},
|
||||
"privacy": {
|
||||
"intro": {
|
||||
@@ -5715,6 +5752,14 @@
|
||||
"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.",
|
||||
"docsLink": "Leer la guía"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Banda de referencia de glucosa",
|
||||
"description": "Con qué banda se evalúan tus valores de glucosa en sangre.",
|
||||
"enable": "Usar la banda objetivo para diabetes",
|
||||
"explainer": "Desactivado, los valores se evalúan con la banda de referencia general no diabética. Activado, se evalúan con la banda objetivo glucémica más estrecha de los ADA Standards of Care, y el Coach fundamenta sus comentarios sobre glucosa en esa banda. Tus propios valores objetivo personalizados de abajo siguen teniendo prioridad.",
|
||||
"disclaimer": "Esto selecciona una banda de referencia. No es un diagnóstico, nunca se deduce de una medición y la aplicación no actúa clínicamente en consecuencia. Qué objetivos se aplican a ti es una pregunta para tu profesional sanitario.",
|
||||
"error": "No se pudo guardar el ajuste."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -7453,7 +7498,7 @@
|
||||
"referenceLabel": "Referencia",
|
||||
"readingsCount": "{count} mediciones",
|
||||
"deleteConfirmTitle": "¿Eliminar resultado de laboratorio?",
|
||||
"deleteConfirmDescription": "Este resultado de laboratorio se eliminará de forma permanente.",
|
||||
"deleteConfirmDescription": "Este resultado de laboratorio se eliminará. Justo después podrás deshacerlo durante un breve momento.",
|
||||
"deletedToast": "Resultado eliminado",
|
||||
"deleteError": "No se pudo eliminar el resultado",
|
||||
"loadError": "No se pudieron cargar los resultados",
|
||||
@@ -7932,7 +7977,7 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "¿Eliminar este episodio?",
|
||||
"body": "«{label}» y todos sus registros diarios se eliminarán. Esta acción no se puede deshacer."
|
||||
"body": "«{label}» y todos sus registros diarios se eliminarán. Justo después podrás deshacerlo durante un breve momento."
|
||||
},
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Frecuencia cardíaca en reposo",
|
||||
@@ -8015,6 +8060,11 @@
|
||||
"review_due": "Revisión pendiente",
|
||||
"reviewed": "Revisado"
|
||||
}
|
||||
},
|
||||
"refusal": {
|
||||
"outOfScope": "Solo puedo ayudarte con los datos de salud de tu registro: mediciones, medicamentos, estado de ánimo, tendencias. No puedo responder preguntas fuera de ese ámbito.",
|
||||
"promptInjection": "He detectado instrucciones que intentan anular mis directrices. Soy el Coach de HealthLog y solo resumo tus propios datos de salud; reformula tu pregunta en esos términos, por favor.",
|
||||
"conversationPoisoned": "Un mensaje anterior de esta conversación contiene instrucciones que intentan anular mis directrices. Empieza una conversación nueva, por favor."
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
@@ -8267,7 +8317,9 @@
|
||||
"finally": "Finally …"
|
||||
}
|
||||
},
|
||||
"openDetail": "Ver detalles"
|
||||
"openDetail": "Ver detalles",
|
||||
"historyLoadError": "No se pudo cargar tu historial de evaluaciones",
|
||||
"historyLoadErrorHint": "Esto no significa que no tengas resultados: no se pudo obtener la lista. Vuelve a intentarlo."
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Son tus propios registros, no un diagnóstico médico. HealthLog no diagnostica, no trata ni los interpreta.",
|
||||
@@ -8476,7 +8528,13 @@
|
||||
"title": "Detalles del documento",
|
||||
"summary": {
|
||||
"title": "Resumen",
|
||||
"pending": "Se está preparando el resumen."
|
||||
"pending": "Se está preparando el resumen.",
|
||||
"none": "Todavía no se ha generado ningún resumen para este documento.",
|
||||
"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.",
|
||||
"saveError": "No se pudo guardar el cambio.",
|
||||
@@ -8558,7 +8616,6 @@
|
||||
"close": "Ocultar"
|
||||
},
|
||||
"ai": {
|
||||
"statusAiRead": "Leído por IA",
|
||||
"readDone": "La IA leyó tu documento.",
|
||||
"read": "Leer con IA",
|
||||
"reread": "Leer de nuevo",
|
||||
@@ -8724,7 +8781,8 @@
|
||||
"rowMeta": "{days} de {window} días · ref. {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{days} de {window} días",
|
||||
"footer": "{tracked} de {total} micronutrientes registrados tienen datos en este periodo.",
|
||||
"emptyTitle": "Aún no hay datos de micronutrientes sincronizados"
|
||||
"emptyTitle": "Aún no hay datos de micronutrientes sincronizados",
|
||||
"loadError": "No se pudieron cargar tus datos de nutrientes"
|
||||
},
|
||||
"sparseNote": "Un valor bajo aquí suele significar un registro escaso, no una deficiencia: los totales diarios de Apple Health reflejan lo que registraron tus apps, no la ingesta dietética total."
|
||||
},
|
||||
@@ -8740,8 +8798,11 @@
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
"title": "Medicación pendiente",
|
||||
"body": "Hoy toca una dosis.",
|
||||
"bodyNamed": "{name} toca hoy."
|
||||
"body": "Pronto toca una dosis.",
|
||||
"bodyNamed": "Pronto toca {name}.",
|
||||
"overdueTitle": "Medicación atrasada",
|
||||
"overdueBody": "Una dosis está atrasada.",
|
||||
"overdueBodyNamed": "{name} está atrasado."
|
||||
},
|
||||
"preventiveCare": {
|
||||
"title": "Revisión pendiente",
|
||||
@@ -8795,7 +8856,8 @@
|
||||
"deltaVsBaseline": "{delta} frente a tu valor de referencia",
|
||||
"deltaFlat": "En línea con tu valor de referencia",
|
||||
"sleepPending": "El sueño de anoche aún no ha llegado; la lectura de hoy es provisional.",
|
||||
"readFullBriefing": "Leer el informe completo",
|
||||
"justIn": "Recién llegado",
|
||||
"justInAt": "Recién llegado · {time}",
|
||||
"worthALook": "Merece un vistazo",
|
||||
"allClear": "Hoy nada necesita tu atención.",
|
||||
"ringLink": "Abrir detalles de {metric}"
|
||||
|
||||
+88
-26
@@ -42,7 +42,9 @@
|
||||
"hidePassword": "Masquer le mot de passe",
|
||||
"learnMore": "En savoir plus",
|
||||
"dismiss": "Ignorer",
|
||||
"moreInfo": "Plus d'informations"
|
||||
"moreInfo": "Plus d'informations",
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationMinutes": "{minutes} min"
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Rapport de santé",
|
||||
@@ -400,8 +402,10 @@
|
||||
"staleHint": "Dernière valeur il y a {count} j",
|
||||
"staleHintWeeksOne": "Dernière valeur il y a 1 semaine",
|
||||
"staleHintWeeksOther": "Dernière valeur il y a {count} semaines",
|
||||
"staleHintWeeksFew": "Dernière valeur il y a {count} semaines",
|
||||
"staleHintMonthsOne": "Dernière valeur il y a 1 mois",
|
||||
"staleHintMonthsOther": "Dernière valeur il y a {count} mois",
|
||||
"staleHintMonthsFew": "Dernière valeur il y a {count} mois",
|
||||
"recentWorkouts": {
|
||||
"title": "Entraînements récents",
|
||||
"viewAll": "Tout voir",
|
||||
@@ -421,7 +425,9 @@
|
||||
"steps": "Pas",
|
||||
"totalBodyWater": "Eau corporelle totale",
|
||||
"boneMass": "Masse osseuse",
|
||||
"oxygenSaturation": "Saturation en oxygène"
|
||||
"oxygenSaturation": "Saturation en oxygène",
|
||||
"mood": "Humeur",
|
||||
"bmi": "IMC"
|
||||
},
|
||||
"unit": {
|
||||
"weight": "kg",
|
||||
@@ -433,7 +439,9 @@
|
||||
"steps": "pas",
|
||||
"totalBodyWater": "kg",
|
||||
"boneMass": "kg",
|
||||
"oxygenSaturation": "%"
|
||||
"oxygenSaturation": "%",
|
||||
"mood": "/5",
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -574,7 +582,7 @@
|
||||
"bulkDeleteSuccess": "{count} mesures supprimées",
|
||||
"bulkDeleteError": "Erreur lors de la suppression des mesures sélectionnées",
|
||||
"bulkDeleteConfirmTitle": "Supprimer {count} mesures ?",
|
||||
"bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront définitivement supprimées.",
|
||||
"bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront supprimées. Vous pourrez annuler cette action pendant un court instant juste après.",
|
||||
"typePhq9Score": "Score PHQ-9",
|
||||
"typeGad7Score": "Score GAD-7",
|
||||
"typeGripStrength": "Force de préhension",
|
||||
@@ -722,7 +730,7 @@
|
||||
"bulkDeleteSuccess": "{count} entrées supprimées",
|
||||
"bulkDeleteError": "Erreur lors de la suppression des entrées sélectionnées",
|
||||
"bulkDeleteConfirmTitle": "Supprimer {count} entrées ?",
|
||||
"bulkDeleteConfirmBody": "Les {count} entrées d'humeur sélectionnées seront définitivement supprimées.",
|
||||
"bulkDeleteConfirmBody": "Les {count} entrées d'humeur sélectionnées seront supprimées. Vous pourrez annuler cette action pendant un court instant juste après.",
|
||||
"customize": "Personnaliser les étiquettes d’humeur",
|
||||
"addTagInline": "Ajouter une étiquette",
|
||||
"manage": {
|
||||
@@ -1679,7 +1687,10 @@
|
||||
"packagingDefaultPackLabel": "Taille de boîte par défaut (unités)",
|
||||
"packagingDefaultPackHelper": "Préremplit la quantité lors de l’enregistrement de nouvelles boîtes (1–1000). Laisse vide si aucune ne s’applique.",
|
||||
"packagingSuccess": "Conditionnement enregistré",
|
||||
"packagingFailed": "Échec de l’enregistrement"
|
||||
"packagingFailed": "Échec de l’enregistrement",
|
||||
"addManufacturerLabel": "Fabricant",
|
||||
"addDoseStrengthLabel": "Dosage",
|
||||
"addDoseStrengthHint": "Tel qu'imprimé sur le contenant, par ex. 5 mg/0,5 ml."
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -2458,10 +2469,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",
|
||||
@@ -2558,10 +2565,13 @@
|
||||
"relativeJustNow": "à l’instant",
|
||||
"relativeMinutesAgoOne": "il y a {count} minute",
|
||||
"relativeMinutesAgoOther": "il y a {count} minutes",
|
||||
"relativeMinutesAgoFew": "il y a {count} minutes",
|
||||
"relativeHoursAgoOne": "il y a {count} heure",
|
||||
"relativeHoursAgoOther": "il y a {count} heures",
|
||||
"relativeHoursAgoFew": "il y a {count} heures",
|
||||
"relativeDaysAgoOne": "il y a {count} jour",
|
||||
"relativeDaysAgoOther": "il y a {count} jours",
|
||||
"relativeDaysAgoFew": "il y a {count} jours",
|
||||
"navOverview": "Vue d'ensemble",
|
||||
"navCatalog": "Toutes les mesures",
|
||||
"navBloodPressure": "Tension artérielle",
|
||||
@@ -2855,9 +2865,9 @@
|
||||
"debt": {
|
||||
"title": "Dette de sommeil",
|
||||
"value": "{hours} h {minutes} min de déficit",
|
||||
"caption": "Déficit cumulé sur les {nights} dernières nuits",
|
||||
"caption": "Solde restant sur les {nights} dernières nuits",
|
||||
"clearValue": "À jour",
|
||||
"clearCaption": "Aucun déficit cumulé sur les {nights} dernières nuits",
|
||||
"clearCaption": "Rien en attente sur les {nights} dernières nuits",
|
||||
"learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour un bilan fiable.",
|
||||
"computedInfo": "Il s'agit de l'estimation propre à HealthLog : un solde glissant entre le besoin et le sommeil des dernières nuits, que le sommeil de récupération réduit. Elle peut différer de la dette de sommeil native d'un wearable, qui utilise son propre modèle de besoin."
|
||||
},
|
||||
@@ -2961,6 +2971,8 @@
|
||||
"navRecovery": "Récupération",
|
||||
"workouts": {
|
||||
"title": "Entraînements",
|
||||
"askWhy": "Pourquoi",
|
||||
"askWhyPrompt": "Pourquoi cette séance s'est-elle passée ainsi ? Explique-moi mes propres chiffres.",
|
||||
"description": "Courses, sorties à vélo, marches et séances de musculation récentes, dédupliquées entre Apple Watch et Withings.",
|
||||
"emptyState": {
|
||||
"title": "Aucun entraînement",
|
||||
@@ -3029,7 +3041,8 @@
|
||||
"crossTraining": "Cross-training",
|
||||
"mixedCardio": "Cardio mixte",
|
||||
"other": "Autre"
|
||||
}
|
||||
},
|
||||
"loadError": "Tes séances n'ont pas pu être chargées"
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Glycémie",
|
||||
@@ -4506,6 +4519,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…",
|
||||
@@ -4664,7 +4678,9 @@
|
||||
"description": "Ajoutez un lieu et une plage de dates : un voyage ou une portion de votre historique passé. La période remplace votre domicile pour les jours qu'elle couvre, afin que la météo corresponde à l'endroit où vous étiez réellement. C'est ainsi que vous corrigez le passé.",
|
||||
"none": "Aucune période de localisation pour l'instant.",
|
||||
"add": "Ajouter {label}",
|
||||
"remove": "Supprimer la période de localisation"
|
||||
"remove": "Supprimer la période de localisation",
|
||||
"removeConfirmTitle": "Retirer ce voyage ?",
|
||||
"removeConfirmBody": "Les mesures de cette période restent dans ton dossier ; seule l'entrée du voyage disparaît."
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Récupérer les jours passés",
|
||||
@@ -4945,7 +4961,10 @@
|
||||
"surfaced": "Affiché",
|
||||
"done": "Terminé",
|
||||
"dismissed": "Ignoré"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "Supprimer ce rappel ?",
|
||||
"deleteConfirmBody": "Il ne se déclenchera plus. Ce que tu as déjà enregistré reste intact.",
|
||||
"deleteConfirmAction": "Supprimer"
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "À propos de moi",
|
||||
@@ -4991,6 +5010,16 @@
|
||||
"honesty": "L'accès partagé est un unique compte IA connecté que l'opérateur a relié pour tout le monde sur ce serveur, et non votre propre clé. Les requêtes que vous y faites servent par défaut à entraîner les modèles du fournisseur et aucun accord de traitement des données ne les couvre, les limites horaires et hebdomadaires du compte sont partagées avec tous les autres qui ont accepté, et vos propres fournisseurs sont essayés en premier. Ne l'activez que si vous acceptez ce compromis.",
|
||||
"confirm": "J'ai compris — utiliser la connexion partagée",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"consent": {
|
||||
"title": "Consentement IA",
|
||||
"activeSince": "Donné le {date}. Tes données de santé peuvent être transmises au fournisseur configuré pour les fonctions IA.",
|
||||
"withdrawn": "Retiré. Aucune donnée de santé n'est transmise à un fournisseur IA ; les fonctions IA restent désactivées.",
|
||||
"withdraw": "Retirer le consentement",
|
||||
"confirmBody": "Les fonctions IA s'arrêtent immédiatement. Rien de ce qui est déjà enregistré n'est supprimé — utilise Données et confidentialité pour exporter ou effacer ton dossier.",
|
||||
"confirmWithdraw": "Retirer",
|
||||
"regrantHint": "Pour réutiliser les fonctions IA, active la lecture automatique des documents ou donne ton consentement depuis l'application.",
|
||||
"error": "Le retrait n'a pas pu être enregistré. Réessaie."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -5610,8 +5639,16 @@
|
||||
"revokeAllDone": "{count} appareil(s) de confiance oublié(s).",
|
||||
"unnamed": "Appareil inconnu",
|
||||
"current": "Cet appareil",
|
||||
"expires": "De confiance jusqu'au {date}"
|
||||
}
|
||||
"expires": "De confiance jusqu'au {date}",
|
||||
"revokeConfirmTitle": "Ne plus faire confiance à cet appareil ?",
|
||||
"revokeConfirmBody": "Il redemandera ton deuxième facteur à la prochaine connexion depuis cet appareil.",
|
||||
"revokeAllConfirmTitle": "Ne plus faire confiance à aucun appareil ?",
|
||||
"revokeAllConfirmBody": "Chaque appareil redemandera ton deuxième facteur à la prochaine connexion, celui-ci compris."
|
||||
},
|
||||
"revokeSessionConfirmTitle": "Déconnecter cet appareil ?",
|
||||
"revokeSessionConfirmBody": "{device} devra se reconnecter. Ce qui y est enregistré n'est pas affecté.",
|
||||
"signOutEverywhereConfirmTitle": "Déconnecter tous les autres appareils ?",
|
||||
"signOutEverywhereConfirmBody": "Chaque appareil sauf celui-ci devra se reconnecter. Celui-ci reste connecté. Rien de ce que tu as enregistré n'est affecté."
|
||||
},
|
||||
"privacy": {
|
||||
"intro": {
|
||||
@@ -5715,6 +5752,14 @@
|
||||
"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.",
|
||||
"docsLink": "Lire le guide"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Plage de référence du glucose",
|
||||
"description": "La plage à laquelle vos glycémies sont comparées.",
|
||||
"enable": "Utiliser la plage cible diabète",
|
||||
"explainer": "Désactivé, les valeurs sont comparées à la plage de référence générale non diabétique. Activé, elles sont comparées à la plage cible glycémique plus stricte des ADA Standards of Care, et le Coach fonde ses remarques sur le glucose sur cette plage. Vos propres valeurs cibles personnalisées ci-dessous restent prioritaires.",
|
||||
"disclaimer": "Ceci sélectionne une plage de référence. Ce n'est pas un diagnostic, ce n'est jamais déduit d'une mesure, et l'application n'en tire aucune conséquence clinique. Les cibles qui s'appliquent à vous relèvent de votre médecin.",
|
||||
"error": "Le réglage n'a pas pu être enregistré."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -7453,7 +7498,7 @@
|
||||
"referenceLabel": "Référence",
|
||||
"readingsCount": "{count} mesures",
|
||||
"deleteConfirmTitle": "Supprimer le résultat d'analyse ?",
|
||||
"deleteConfirmDescription": "Ce résultat d'analyse sera définitivement supprimé.",
|
||||
"deleteConfirmDescription": "Ce résultat d'analyse sera supprimé. Vous pourrez annuler cette action pendant un court instant juste après.",
|
||||
"deletedToast": "Résultat supprimé",
|
||||
"deleteError": "Impossible de supprimer le résultat",
|
||||
"loadError": "Impossible de charger les résultats",
|
||||
@@ -7932,7 +7977,7 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Supprimer cet épisode ?",
|
||||
"body": "« {label} » et tous ses relevés quotidiens seront supprimés. Cette action est irréversible."
|
||||
"body": "« {label} » et tous ses relevés quotidiens seront supprimés. Vous pourrez annuler cette action pendant un court instant juste après."
|
||||
},
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Fréquence cardiaque au repos",
|
||||
@@ -8015,6 +8060,11 @@
|
||||
"review_due": "Point d'étape dû",
|
||||
"reviewed": "Évalué"
|
||||
}
|
||||
},
|
||||
"refusal": {
|
||||
"outOfScope": "Je peux uniquement t'aider avec les données de santé de ton journal : mesures, médicaments, humeur, tendances. Je ne réponds pas aux questions en dehors de ce cadre.",
|
||||
"promptInjection": "J'ai repéré des instructions qui cherchent à contourner mes consignes. Je suis le Coach HealthLog et je résume uniquement tes propres données de santé — reformule ta question dans ce cadre, s'il te plaît.",
|
||||
"conversationPoisoned": "Un message précédent de cette conversation contient des instructions qui cherchent à contourner mes consignes. Démarre une nouvelle conversation, s'il te plaît."
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
@@ -8267,7 +8317,9 @@
|
||||
"finally": "Finally …"
|
||||
}
|
||||
},
|
||||
"openDetail": "Voir les détails"
|
||||
"openDetail": "Voir les détails",
|
||||
"historyLoadError": "Ton historique d'évaluations n'a pas pu être chargé",
|
||||
"historyLoadErrorHint": "Cela ne veut pas dire que tu n'as aucun résultat : la liste n'a pas pu être récupérée. Réessaie."
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Ce sont vos propres informations, pas un diagnostic médical. HealthLog ne diagnostique pas, ne traite pas et ne les interprète pas.",
|
||||
@@ -8476,7 +8528,13 @@
|
||||
"title": "Détails du document",
|
||||
"summary": {
|
||||
"title": "Résumé",
|
||||
"pending": "Le résumé est en cours de préparation."
|
||||
"pending": "Le résumé est en cours de préparation.",
|
||||
"none": "Aucun résumé n’a encore été généré pour ce document.",
|
||||
"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.",
|
||||
"saveError": "Impossible d'enregistrer la modification.",
|
||||
@@ -8558,7 +8616,6 @@
|
||||
"close": "Masquer"
|
||||
},
|
||||
"ai": {
|
||||
"statusAiRead": "Lu par l’IA",
|
||||
"readDone": "L’IA a lu votre document.",
|
||||
"read": "Lire avec l’IA",
|
||||
"reread": "Relire",
|
||||
@@ -8724,7 +8781,8 @@
|
||||
"rowMeta": "{days} sur {window} jours · réf. {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{days} sur {window} jours",
|
||||
"footer": "{tracked} des {total} micronutriments suivis ont des données sur cette période.",
|
||||
"emptyTitle": "Aucune donnée de micronutriments synchronisée pour l'instant"
|
||||
"emptyTitle": "Aucune donnée de micronutriments synchronisée pour l'instant",
|
||||
"loadError": "Tes données de nutriments n'ont pas pu être chargées"
|
||||
},
|
||||
"sparseNote": "Une valeur basse ici signifie généralement un suivi partiel, pas une carence — les totaux journaliers Apple Health reflètent ce que vos applis ont enregistré, pas l'apport alimentaire total."
|
||||
},
|
||||
@@ -8740,8 +8798,11 @@
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
"title": "Médicament à prendre",
|
||||
"body": "Une dose est prévue aujourd'hui.",
|
||||
"bodyNamed": "{name} est prévu aujourd'hui."
|
||||
"body": "Une dose est bientôt à prendre.",
|
||||
"bodyNamed": "{name} est bientôt à prendre.",
|
||||
"overdueTitle": "Médicament en retard",
|
||||
"overdueBody": "Une dose est en retard.",
|
||||
"overdueBodyNamed": "{name} est en retard."
|
||||
},
|
||||
"preventiveCare": {
|
||||
"title": "Examen à faire",
|
||||
@@ -8795,7 +8856,8 @@
|
||||
"deltaVsBaseline": "{delta} par rapport à ta référence",
|
||||
"deltaFlat": "Conforme à ta référence",
|
||||
"sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé — la lecture du jour est provisoire.",
|
||||
"readFullBriefing": "Lire le briefing complet",
|
||||
"justIn": "Vient d'arriver",
|
||||
"justInAt": "Vient d'arriver · {time}",
|
||||
"worthALook": "À regarder",
|
||||
"allClear": "Rien ne requiert ton attention aujourd'hui.",
|
||||
"ringLink": "Ouvrir les détails de {metric}"
|
||||
|
||||
+88
-26
@@ -42,7 +42,9 @@
|
||||
"hidePassword": "Nascondi password",
|
||||
"learnMore": "Scopri di più",
|
||||
"dismiss": "Ignora",
|
||||
"moreInfo": "Maggiori informazioni"
|
||||
"moreInfo": "Maggiori informazioni",
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationMinutes": "{minutes} min"
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Rapporto sanitario",
|
||||
@@ -400,8 +402,10 @@
|
||||
"staleHint": "Ultimo valore {count} g fa",
|
||||
"staleHintWeeksOne": "Ultimo valore 1 settimana fa",
|
||||
"staleHintWeeksOther": "Ultimo valore {count} settimane fa",
|
||||
"staleHintWeeksFew": "Ultimo valore {count} settimane fa",
|
||||
"staleHintMonthsOne": "Ultimo valore 1 mese fa",
|
||||
"staleHintMonthsOther": "Ultimo valore {count} mesi fa",
|
||||
"staleHintMonthsFew": "Ultimo valore {count} mesi fa",
|
||||
"recentWorkouts": {
|
||||
"title": "Allenamenti recenti",
|
||||
"viewAll": "Vedi tutti",
|
||||
@@ -421,7 +425,9 @@
|
||||
"steps": "Passi",
|
||||
"totalBodyWater": "Acqua corporea totale",
|
||||
"boneMass": "Massa ossea",
|
||||
"oxygenSaturation": "Saturazione di ossigeno"
|
||||
"oxygenSaturation": "Saturazione di ossigeno",
|
||||
"mood": "Umore",
|
||||
"bmi": "IMC"
|
||||
},
|
||||
"unit": {
|
||||
"weight": "kg",
|
||||
@@ -433,7 +439,9 @@
|
||||
"steps": "passi",
|
||||
"totalBodyWater": "kg",
|
||||
"boneMass": "kg",
|
||||
"oxygenSaturation": "%"
|
||||
"oxygenSaturation": "%",
|
||||
"mood": "/5",
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -574,7 +582,7 @@
|
||||
"bulkDeleteSuccess": "{count} misurazioni eliminate",
|
||||
"bulkDeleteError": "Errore durante l'eliminazione delle misurazioni selezionate",
|
||||
"bulkDeleteConfirmTitle": "Eliminare {count} misurazioni?",
|
||||
"bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate definitivamente.",
|
||||
"bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate. Subito dopo potrai annullare l’operazione per un breve momento.",
|
||||
"typePhq9Score": "Punteggio PHQ-9",
|
||||
"typeGad7Score": "Punteggio GAD-7",
|
||||
"typeGripStrength": "Forza di presa",
|
||||
@@ -722,7 +730,7 @@
|
||||
"bulkDeleteSuccess": "{count} voci eliminate",
|
||||
"bulkDeleteError": "Errore durante l'eliminazione delle voci selezionate",
|
||||
"bulkDeleteConfirmTitle": "Eliminare {count} voci?",
|
||||
"bulkDeleteConfirmBody": "Le {count} voci dell'umore selezionate verranno eliminate definitivamente.",
|
||||
"bulkDeleteConfirmBody": "Le {count} voci dell'umore selezionate verranno eliminate. Subito dopo potrai annullare l’operazione per un breve momento.",
|
||||
"customize": "Personalizza i tag dell’umore",
|
||||
"addTagInline": "Aggiungi tag",
|
||||
"manage": {
|
||||
@@ -1679,7 +1687,10 @@
|
||||
"packagingDefaultPackLabel": "Dimensione confezione predefinita (unità)",
|
||||
"packagingDefaultPackHelper": "Precompila la quantità quando registri nuove confezioni (1–1000). Lascia vuoto se non applicabile.",
|
||||
"packagingSuccess": "Dati confezione salvati",
|
||||
"packagingFailed": "Salvataggio non riuscito"
|
||||
"packagingFailed": "Salvataggio non riuscito",
|
||||
"addManufacturerLabel": "Produttore",
|
||||
"addDoseStrengthLabel": "Dosaggio",
|
||||
"addDoseStrengthHint": "Come stampato sul contenitore, ad es. 5 mg/0,5 ml."
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -2458,10 +2469,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",
|
||||
@@ -2558,10 +2565,13 @@
|
||||
"relativeJustNow": "proprio ora",
|
||||
"relativeMinutesAgoOne": "{count} minuto fa",
|
||||
"relativeMinutesAgoOther": "{count} minuti fa",
|
||||
"relativeMinutesAgoFew": "{count} minuti fa",
|
||||
"relativeHoursAgoOne": "{count} ora fa",
|
||||
"relativeHoursAgoOther": "{count} ore fa",
|
||||
"relativeHoursAgoFew": "{count} ore fa",
|
||||
"relativeDaysAgoOne": "{count} giorno fa",
|
||||
"relativeDaysAgoOther": "{count} giorni fa",
|
||||
"relativeDaysAgoFew": "{count} giorni fa",
|
||||
"navOverview": "Panoramica",
|
||||
"navCatalog": "Tutte le metriche",
|
||||
"navBloodPressure": "Pressione arteriosa",
|
||||
@@ -2855,9 +2865,9 @@
|
||||
"debt": {
|
||||
"title": "Debito di sonno",
|
||||
"value": "{hours} h {minutes} min in meno",
|
||||
"caption": "Deficit cumulato nelle ultime {nights} notti",
|
||||
"caption": "Bilancio in sospeso nelle ultime {nights} notti",
|
||||
"clearValue": "In pari",
|
||||
"clearCaption": "Nessun deficit accumulato nelle ultime {nights} notti",
|
||||
"clearCaption": "Niente in sospeso nelle ultime {nights} notti",
|
||||
"learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per un bilancio affidabile.",
|
||||
"computedInfo": "Questa è la stima propria di HealthLog: un bilancio mobile tra fabbisogno e sonno delle ultime notti, che il sonno di recupero riduce. Può differire dal dato di debito di sonno nativo di un dispositivo indossabile, che usa un proprio modello di fabbisogno."
|
||||
},
|
||||
@@ -2961,6 +2971,8 @@
|
||||
"navRecovery": "Recupero",
|
||||
"workouts": {
|
||||
"title": "Allenamenti",
|
||||
"askWhy": "Perché",
|
||||
"askWhyPrompt": "Perché questa sessione è andata così? Spiegami i miei dati di questa sessione.",
|
||||
"description": "Corse, uscite in bici, camminate e sessioni di forza recenti, deduplicati tra Apple Watch e Withings.",
|
||||
"emptyState": {
|
||||
"title": "Nessun allenamento",
|
||||
@@ -3029,7 +3041,8 @@
|
||||
"crossTraining": "Cross-training",
|
||||
"mixedCardio": "Cardio misto",
|
||||
"other": "Altro"
|
||||
}
|
||||
},
|
||||
"loadError": "Non è stato possibile caricare i tuoi allenamenti"
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Glicemia",
|
||||
@@ -4506,6 +4519,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…",
|
||||
@@ -4664,7 +4678,9 @@
|
||||
"description": "Aggiungi un luogo e un intervallo di date: un viaggio o un tratto della tua cronologia passata. Il periodo prevale sulla tua residenza nei giorni che copre, così il meteo corrisponde a dove ti trovavi davvero. È così che correggi il passato.",
|
||||
"none": "Nessun periodo di posizione.",
|
||||
"add": "Aggiungi {label}",
|
||||
"remove": "Rimuovi periodo di posizione"
|
||||
"remove": "Rimuovi periodo di posizione",
|
||||
"removeConfirmTitle": "Rimuovere questo viaggio?",
|
||||
"removeConfirmBody": "Le misurazioni di quel periodo restano nel tuo archivio; sparisce solo la voce del viaggio."
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Recupera i giorni passati",
|
||||
@@ -4945,7 +4961,10 @@
|
||||
"surfaced": "Mostrato",
|
||||
"done": "Fatto",
|
||||
"dismissed": "Ignorato"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "Eliminare questo promemoria?",
|
||||
"deleteConfirmBody": "Non verrà più mostrato. Quanto hai già registrato resta invariato.",
|
||||
"deleteConfirmAction": "Elimina"
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "Su di me",
|
||||
@@ -4991,6 +5010,16 @@
|
||||
"honesty": "L'accesso condiviso è un unico account IA con accesso eseguito che l'operatore ha collegato per tutti su questo server, non la tua chiave. Le richieste che vi effettui vengono usate per impostazione predefinita per addestrare i modelli del provider e nessun accordo sul trattamento dei dati le copre, i limiti orari e settimanali dell'account sono condivisi con tutti gli altri che hanno aderito, e i tuoi provider vengono comunque provati per primi. Attivalo solo se accetti questo compromesso.",
|
||||
"confirm": "Ho capito — usa la connessione condivisa",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"consent": {
|
||||
"title": "Consenso IA",
|
||||
"activeSince": "Concesso il {date}. I tuoi dati sanitari possono essere inviati al provider configurato per le funzioni IA.",
|
||||
"withdrawn": "Revocato. Nessun dato sanitario viene inviato a un provider IA; le funzioni IA restano disattivate.",
|
||||
"withdraw": "Revoca il consenso",
|
||||
"confirmBody": "Le funzioni IA si fermano subito. Nulla di quanto già registrato viene eliminato — usa Dati e privacy per esportare o cancellare il tuo archivio.",
|
||||
"confirmWithdraw": "Revoca",
|
||||
"regrantHint": "Per usare di nuovo le funzioni IA, attiva la lettura automatica dei documenti o concedi il consenso dall'app.",
|
||||
"error": "Non è stato possibile salvare la revoca. Riprova."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -5610,8 +5639,16 @@
|
||||
"revokeAllDone": "Dimenticati {count} dispositivo/i attendibile/i.",
|
||||
"unnamed": "Dispositivo sconosciuto",
|
||||
"current": "Questo dispositivo",
|
||||
"expires": "Attendibile fino al {date}"
|
||||
}
|
||||
"expires": "Attendibile fino al {date}",
|
||||
"revokeConfirmTitle": "Non fidarsi più di questo dispositivo?",
|
||||
"revokeConfirmBody": "Al prossimo accesso da questo dispositivo chiederà di nuovo il secondo fattore.",
|
||||
"revokeAllConfirmTitle": "Non fidarsi più di alcun dispositivo?",
|
||||
"revokeAllConfirmBody": "Ogni dispositivo chiederà di nuovo il secondo fattore al prossimo accesso, questo compreso."
|
||||
},
|
||||
"revokeSessionConfirmTitle": "Disconnettere questo dispositivo?",
|
||||
"revokeSessionConfirmBody": "{device} dovrà accedere di nuovo. Quanto registrato su di esso non viene toccato.",
|
||||
"signOutEverywhereConfirmTitle": "Disconnettere tutti gli altri dispositivi?",
|
||||
"signOutEverywhereConfirmBody": "Ogni dispositivo tranne questo dovrà accedere di nuovo. Questo resta connesso. Nulla di ciò che hai registrato viene toccato."
|
||||
},
|
||||
"privacy": {
|
||||
"intro": {
|
||||
@@ -5715,6 +5752,14 @@
|
||||
"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.",
|
||||
"docsLink": "Leggi la guida"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Banda di riferimento della glicemia",
|
||||
"description": "La banda con cui vengono valutati i tuoi valori glicemici.",
|
||||
"enable": "Usa la banda obiettivo per il diabete",
|
||||
"explainer": "Disattivato, i valori vengono valutati con la banda di riferimento generale non diabetica. Attivato, vengono valutati con la banda obiettivo glicemica più stretta degli ADA Standards of Care, e il Coach fonda su quella banda le sue osservazioni sulla glicemia. I tuoi valori obiettivo personalizzati qui sotto hanno comunque la precedenza.",
|
||||
"disclaimer": "Questo seleziona una banda di riferimento. Non è una diagnosi, non viene mai dedotto da una misurazione e l'app non agisce clinicamente di conseguenza. Quali obiettivi valgano per te è una domanda per il tuo medico.",
|
||||
"error": "Non è stato possibile salvare l'impostazione."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -7453,7 +7498,7 @@
|
||||
"referenceLabel": "Riferimento",
|
||||
"readingsCount": "{count} misurazioni",
|
||||
"deleteConfirmTitle": "Eliminare il risultato di laboratorio?",
|
||||
"deleteConfirmDescription": "Questo risultato di laboratorio verrà eliminato definitivamente.",
|
||||
"deleteConfirmDescription": "Questo risultato di laboratorio verrà eliminato. Subito dopo potrai annullare l’operazione per un breve momento.",
|
||||
"deletedToast": "Risultato eliminato",
|
||||
"deleteError": "Impossibile eliminare il risultato",
|
||||
"loadError": "Impossibile caricare i risultati",
|
||||
@@ -7932,7 +7977,7 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Eliminare questo episodio?",
|
||||
"body": "«{label}» e tutti i relativi registri giornalieri verranno rimossi. L'operazione non può essere annullata."
|
||||
"body": "«{label}» e tutti i relativi registri giornalieri verranno rimossi. Subito dopo potrai annullare l’operazione per un breve momento."
|
||||
},
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Frequenza cardiaca a riposo",
|
||||
@@ -8015,6 +8060,11 @@
|
||||
"review_due": "Verifica in scadenza",
|
||||
"reviewed": "Valutato"
|
||||
}
|
||||
},
|
||||
"refusal": {
|
||||
"outOfScope": "Posso aiutarti solo con i dati di salute del tuo registro: misurazioni, farmaci, umore, tendenze. Non rispondo a domande al di fuori di questo ambito.",
|
||||
"promptInjection": "Ho notato istruzioni che cercano di aggirare le mie direttive. Sono il Coach di HealthLog e riassumo soltanto i tuoi dati di salute — riformula la domanda in questi termini, per favore.",
|
||||
"conversationPoisoned": "Un messaggio precedente di questa conversazione contiene istruzioni che cercano di aggirare le mie direttive. Inizia una nuova conversazione, per favore."
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
@@ -8267,7 +8317,9 @@
|
||||
"finally": "Finally …"
|
||||
}
|
||||
},
|
||||
"openDetail": "Mostra dettagli"
|
||||
"openDetail": "Mostra dettagli",
|
||||
"historyLoadError": "Non è stato possibile caricare la cronologia dei test",
|
||||
"historyLoadErrorHint": "Questo non significa che non ci siano risultati: non è stato possibile recuperare l'elenco. Riprova."
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Sono i tuoi dati, non una diagnosi medica. HealthLog non diagnostica, non cura e non li interpreta.",
|
||||
@@ -8476,7 +8528,13 @@
|
||||
"title": "Dettagli del documento",
|
||||
"summary": {
|
||||
"title": "Riepilogo",
|
||||
"pending": "Il riepilogo è in preparazione."
|
||||
"pending": "Il riepilogo è in preparazione.",
|
||||
"none": "Non è ancora stato generato alcun riepilogo per questo documento.",
|
||||
"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.",
|
||||
"saveError": "Impossibile salvare la modifica.",
|
||||
@@ -8558,7 +8616,6 @@
|
||||
"close": "Nascondi"
|
||||
},
|
||||
"ai": {
|
||||
"statusAiRead": "Letto dall’IA",
|
||||
"readDone": "L’IA ha letto il tuo documento.",
|
||||
"read": "Leggi con l’IA",
|
||||
"reread": "Leggi di nuovo",
|
||||
@@ -8724,7 +8781,8 @@
|
||||
"rowMeta": "{days} di {window} giorni · rif. {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{days} di {window} giorni",
|
||||
"footer": "{tracked} di {total} micronutrienti monitorati hanno dati in questo periodo.",
|
||||
"emptyTitle": "Nessun dato sui micronutrienti ancora sincronizzato"
|
||||
"emptyTitle": "Nessun dato sui micronutrienti ancora sincronizzato",
|
||||
"loadError": "Non è stato possibile caricare i tuoi dati sui nutrienti"
|
||||
},
|
||||
"sparseNote": "Un valore basso qui di solito indica una registrazione poco frequente, non una carenza — i totali giornalieri di Apple Health riflettono ciò che le tue app hanno registrato, non l'apporto alimentare totale."
|
||||
},
|
||||
@@ -8740,8 +8798,11 @@
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
"title": "Farmaco da assumere",
|
||||
"body": "Oggi è prevista una dose.",
|
||||
"bodyNamed": "{name} è previsto oggi."
|
||||
"body": "Una dose è prevista a breve.",
|
||||
"bodyNamed": "{name} è previsto a breve.",
|
||||
"overdueTitle": "Farmaco in ritardo",
|
||||
"overdueBody": "Una dose è in ritardo.",
|
||||
"overdueBodyNamed": "{name} è in ritardo."
|
||||
},
|
||||
"preventiveCare": {
|
||||
"title": "Controllo da fare",
|
||||
@@ -8795,7 +8856,8 @@
|
||||
"deltaVsBaseline": "{delta} rispetto al tuo valore di riferimento",
|
||||
"deltaFlat": "In linea con il tuo valore di riferimento",
|
||||
"sleepPending": "Il sonno di stanotte non è ancora arrivato: la lettura di oggi è provvisoria.",
|
||||
"readFullBriefing": "Leggi il briefing completo",
|
||||
"justIn": "Appena arrivato",
|
||||
"justInAt": "Appena arrivato · {time}",
|
||||
"worthALook": "Da tenere d'occhio",
|
||||
"allClear": "Oggi nulla richiede la tua attenzione.",
|
||||
"ringLink": "Apri i dettagli di {metric}"
|
||||
|
||||
+88
-26
@@ -42,7 +42,9 @@
|
||||
"hidePassword": "Ukryj hasło",
|
||||
"learnMore": "Dowiedz się więcej",
|
||||
"dismiss": "Odrzuć",
|
||||
"moreInfo": "Więcej informacji"
|
||||
"moreInfo": "Więcej informacji",
|
||||
"durationHoursMinutes": "{hours} godz. {minutes} min",
|
||||
"durationMinutes": "{minutes} min"
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Raport zdrowotny",
|
||||
@@ -400,8 +402,10 @@
|
||||
"staleHint": "Ostatni odczyt {count} d temu",
|
||||
"staleHintWeeksOne": "Ostatni odczyt 1 tydzień temu",
|
||||
"staleHintWeeksOther": "Ostatni odczyt {count} tygodni temu",
|
||||
"staleHintWeeksFew": "Ostatni odczyt {count} tygodnie temu",
|
||||
"staleHintMonthsOne": "Ostatni odczyt 1 miesiąc temu",
|
||||
"staleHintMonthsOther": "Ostatni odczyt {count} miesięcy temu",
|
||||
"staleHintMonthsFew": "Ostatni odczyt {count} miesiące temu",
|
||||
"recentWorkouts": {
|
||||
"title": "Ostatnie treningi",
|
||||
"viewAll": "Zobacz wszystkie",
|
||||
@@ -421,7 +425,9 @@
|
||||
"steps": "Kroki",
|
||||
"totalBodyWater": "Całkowita woda w organizmie",
|
||||
"boneMass": "Masa kostna",
|
||||
"oxygenSaturation": "Saturacja tlenem"
|
||||
"oxygenSaturation": "Saturacja tlenem",
|
||||
"mood": "Nastrój",
|
||||
"bmi": "BMI"
|
||||
},
|
||||
"unit": {
|
||||
"weight": "kg",
|
||||
@@ -433,7 +439,9 @@
|
||||
"steps": "kroki",
|
||||
"totalBodyWater": "kg",
|
||||
"boneMass": "kg",
|
||||
"oxygenSaturation": "%"
|
||||
"oxygenSaturation": "%",
|
||||
"mood": "/5",
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -574,7 +582,7 @@
|
||||
"bulkDeleteSuccess": "Usunięto pomiary: {count}",
|
||||
"bulkDeleteError": "Błąd podczas usuwania wybranych pomiarów",
|
||||
"bulkDeleteConfirmTitle": "Usunąć pomiary ({count})?",
|
||||
"bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną trwale usunięte.",
|
||||
"bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną usunięte. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
|
||||
"typePhq9Score": "Wynik PHQ-9",
|
||||
"typeGad7Score": "Wynik GAD-7",
|
||||
"typeGripStrength": "Siła chwytu",
|
||||
@@ -722,7 +730,7 @@
|
||||
"bulkDeleteSuccess": "Usunięto wpisy: {count}",
|
||||
"bulkDeleteError": "Błąd podczas usuwania wybranych wpisów",
|
||||
"bulkDeleteConfirmTitle": "Usunąć wpisy ({count})?",
|
||||
"bulkDeleteConfirmBody": "Wybrane wpisy nastroju ({count}) zostaną trwale usunięte.",
|
||||
"bulkDeleteConfirmBody": "Wybrane wpisy nastroju ({count}) zostaną usunięte. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
|
||||
"customize": "Dostosuj tagi nastroju",
|
||||
"addTagInline": "Dodaj tag",
|
||||
"manage": {
|
||||
@@ -1679,7 +1687,10 @@
|
||||
"packagingDefaultPackLabel": "Domyślny rozmiar opakowania (jednostki)",
|
||||
"packagingDefaultPackHelper": "Wstępnie wypełnia ilość przy dodawaniu nowych opakowań (1–1000). Zostaw puste, jeśli nie dotyczy.",
|
||||
"packagingSuccess": "Zapisano dane opakowania",
|
||||
"packagingFailed": "Zapis nie powiódł się"
|
||||
"packagingFailed": "Zapis nie powiódł się",
|
||||
"addManufacturerLabel": "Producent",
|
||||
"addDoseStrengthLabel": "Moc",
|
||||
"addDoseStrengthHint": "Zgodnie z nadrukiem na opakowaniu, np. 5 mg/0,5 ml."
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -2458,10 +2469,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",
|
||||
@@ -2558,10 +2565,13 @@
|
||||
"relativeJustNow": "przed chwilą",
|
||||
"relativeMinutesAgoOne": "{count} minutę temu",
|
||||
"relativeMinutesAgoOther": "{count} minut temu",
|
||||
"relativeMinutesAgoFew": "{count} minuty temu",
|
||||
"relativeHoursAgoOne": "{count} godzinę temu",
|
||||
"relativeHoursAgoOther": "{count} godzin temu",
|
||||
"relativeHoursAgoFew": "{count} godziny temu",
|
||||
"relativeDaysAgoOne": "{count} dzień temu",
|
||||
"relativeDaysAgoOther": "{count} dni temu",
|
||||
"relativeDaysAgoFew": "{count} dni temu",
|
||||
"navOverview": "Przegląd",
|
||||
"navCatalog": "Wszystkie metryki",
|
||||
"navBloodPressure": "Ciśnienie krwi",
|
||||
@@ -2855,9 +2865,9 @@
|
||||
"debt": {
|
||||
"title": "Dług snu",
|
||||
"value": "{hours} godz. {minutes} min niedoboru",
|
||||
"caption": "Skumulowany niedobór z ostatnich {nights} nocy",
|
||||
"caption": "Pozostały bilans z ostatnich {nights} nocy",
|
||||
"clearValue": "Na bieżąco",
|
||||
"clearCaption": "Brak skumulowanego niedoboru w ostatnich {nights} nocach",
|
||||
"clearCaption": "Nic do nadrobienia w ostatnich {nights} nocach",
|
||||
"learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodny bilans.",
|
||||
"computedInfo": "To własne oszacowanie HealthLog: ruchomy bilans potrzeby snu minus sen z ostatnich nocy, który sen nadrabiający zmniejsza. Może różnić się od natywnego wskaźnika długu snu z urządzenia, które korzysta z własnego modelu zapotrzebowania."
|
||||
},
|
||||
@@ -2961,6 +2971,8 @@
|
||||
"navRecovery": "Regeneracja",
|
||||
"workouts": {
|
||||
"title": "Treningi",
|
||||
"askWhy": "Dlaczego",
|
||||
"askWhyPrompt": "Dlaczego ta sesja wypadła w ten sposób? Wyjaśnij mi moje własne wyniki.",
|
||||
"description": "Ostatnie biegi, jazdy, marsze i treningi siłowe — zdeduplikowane między Apple Watch a Withings.",
|
||||
"emptyState": {
|
||||
"title": "Brak treningów",
|
||||
@@ -3029,7 +3041,8 @@
|
||||
"crossTraining": "Cross-trening",
|
||||
"mixedCardio": "Cardio mieszane",
|
||||
"other": "Inne"
|
||||
}
|
||||
},
|
||||
"loadError": "Nie udało się wczytać treningów"
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Poziom glukozy",
|
||||
@@ -4506,6 +4519,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…",
|
||||
@@ -4664,7 +4678,9 @@
|
||||
"description": "Dodaj miejsce i zakres dat: podróż lub fragment wcześniejszej historii. Okres zastępuje Twój dom w dniach, które obejmuje, aby pogoda odpowiadała miejscu, w którym naprawdę byłeś. W ten sposób korygujesz przeszłość.",
|
||||
"none": "Brak okresów lokalizacji.",
|
||||
"add": "Dodaj {label}",
|
||||
"remove": "Usuń okres lokalizacji"
|
||||
"remove": "Usuń okres lokalizacji",
|
||||
"removeConfirmTitle": "Usunąć tę podróż?",
|
||||
"removeConfirmBody": "Pomiary z tego okresu pozostają w historii; znika tylko wpis podróży."
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Uzupełnij minione dni",
|
||||
@@ -4945,7 +4961,10 @@
|
||||
"surfaced": "Wyświetlone",
|
||||
"done": "Gotowe",
|
||||
"dismissed": "Odrzucone"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "Usunąć to przypomnienie?",
|
||||
"deleteConfirmBody": "Przestanie się pojawiać. Zapisane wcześniej dane pozostają bez zmian.",
|
||||
"deleteConfirmAction": "Usuń"
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "O mnie",
|
||||
@@ -4991,6 +5010,16 @@
|
||||
"honesty": "Wspólny dostęp to jedno zalogowane konto AI, które operator podłączył dla wszystkich na tym serwerze — nie Twój własny klucz. Zapytania, które przez nie wysyłasz, są domyślnie wykorzystywane do trenowania modeli dostawcy i nie obejmuje ich żadna umowa powierzenia przetwarzania danych, godzinowe i tygodniowe limity konta dzielisz z wszystkimi innymi, którzy się zgodzili, a Twoi właśni dostawcy są próbowani jako pierwsi. Włącz to tylko, jeśli akceptujesz ten kompromis.",
|
||||
"confirm": "Rozumiem — użyj wspólnego połączenia",
|
||||
"cancel": "Anuluj"
|
||||
},
|
||||
"consent": {
|
||||
"title": "Zgoda na AI",
|
||||
"activeSince": "Udzielona {date}. Twoje dane zdrowotne mogą być wysyłane do skonfigurowanego dostawcy na potrzeby funkcji AI.",
|
||||
"withdrawn": "Wycofana. Żadne dane zdrowotne nie są wysyłane do dostawcy AI; funkcje AI pozostają wyłączone.",
|
||||
"withdraw": "Wycofaj zgodę",
|
||||
"confirmBody": "Funkcje AI przestają działać natychmiast. Nic z już zapisanych danych nie zostaje usunięte — użyj sekcji Dane i prywatność, aby wyeksportować lub usunąć swoją historię.",
|
||||
"confirmWithdraw": "Wycofaj",
|
||||
"regrantHint": "Aby ponownie korzystać z funkcji AI, włącz automatyczne czytanie dokumentów lub udziel zgody w aplikacji.",
|
||||
"error": "Nie udało się zapisać wycofania. Spróbuj ponownie."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -5610,8 +5639,16 @@
|
||||
"revokeAllDone": "Zapomniano {count} zaufane urządzenie/a.",
|
||||
"unnamed": "Nieznane urządzenie",
|
||||
"current": "To urządzenie",
|
||||
"expires": "Zaufane do {date}"
|
||||
}
|
||||
"expires": "Zaufane do {date}",
|
||||
"revokeConfirmTitle": "Przestać ufać temu urządzeniu?",
|
||||
"revokeConfirmBody": "Przy następnym logowaniu z tego urządzenia ponownie poprosi o drugi składnik.",
|
||||
"revokeAllConfirmTitle": "Przestać ufać wszystkim urządzeniom?",
|
||||
"revokeAllConfirmBody": "Każde urządzenie przy następnym logowaniu ponownie poprosi o drugi składnik, łącznie z tym."
|
||||
},
|
||||
"revokeSessionConfirmTitle": "Wylogować to urządzenie?",
|
||||
"revokeSessionConfirmBody": "{device} będzie musiało zalogować się ponownie. Zapisane na nim dane pozostają bez zmian.",
|
||||
"signOutEverywhereConfirmTitle": "Wylogować wszystkie pozostałe urządzenia?",
|
||||
"signOutEverywhereConfirmBody": "Każde urządzenie poza tym będzie musiało zalogować się ponownie. To urządzenie pozostaje zalogowane. Zapisane dane pozostają bez zmian."
|
||||
},
|
||||
"privacy": {
|
||||
"intro": {
|
||||
@@ -5715,6 +5752,14 @@
|
||||
"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.",
|
||||
"docsLink": "Przeczytaj przewodnik"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Zakres referencyjny glukozy",
|
||||
"description": "Do jakiego zakresu porównywane są twoje wyniki glukozy we krwi.",
|
||||
"enable": "Użyj zakresu docelowego dla cukrzycy",
|
||||
"explainer": "Wyłączone — wyniki są oceniane względem ogólnego zakresu referencyjnego dla osób bez cukrzycy. Włączone — są oceniane względem węższego zakresu docelowego glikemii z ADA Standards of Care, a Coach opiera na nim swoje uwagi o glukozie. Twoje własne wartości docelowe poniżej nadal mają pierwszeństwo.",
|
||||
"disclaimer": "To wybiera zakres referencyjny. Nie jest to diagnoza, nigdy nie jest wywnioskowane z pomiaru, a aplikacja nie podejmuje na tej podstawie działań klinicznych. O to, jakie cele dotyczą ciebie, zapytaj swojego lekarza.",
|
||||
"error": "Nie udało się zapisać ustawienia."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -7453,7 +7498,7 @@
|
||||
"referenceLabel": "Norma",
|
||||
"readingsCount": "{count} pomiarów",
|
||||
"deleteConfirmTitle": "Usunąć wynik laboratoryjny?",
|
||||
"deleteConfirmDescription": "Ten wynik laboratoryjny zostanie trwale usunięty.",
|
||||
"deleteConfirmDescription": "Ten wynik laboratoryjny zostanie usunięty. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
|
||||
"deletedToast": "Wynik usunięty",
|
||||
"deleteError": "Nie udało się usunąć wyniku",
|
||||
"loadError": "Nie udało się wczytać wyników",
|
||||
@@ -7932,7 +7977,7 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Usunąć ten epizod?",
|
||||
"body": "„{label}” oraz wszystkie powiązane wpisy dzienne zostaną usunięte. Tej operacji nie można cofnąć."
|
||||
"body": "„{label}” oraz wszystkie powiązane wpisy dzienne zostaną usunięte. Zaraz po usunięciu możesz to przez chwilę cofnąć."
|
||||
},
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Spoczynkowe tętno",
|
||||
@@ -8015,6 +8060,11 @@
|
||||
"review_due": "Przegląd do zrobienia",
|
||||
"reviewed": "Oceniony"
|
||||
}
|
||||
},
|
||||
"refusal": {
|
||||
"outOfScope": "Mogę pomóc wyłącznie z danymi zdrowotnymi z Twojego dziennika: pomiarami, lekami, nastrojem, trendami. Na pytania spoza tego zakresu nie odpowiadam.",
|
||||
"promptInjection": "Zauważyłem polecenia, które próbują obejść moje wytyczne. Jestem Coachem HealthLog i podsumowuję wyłącznie Twoje własne dane zdrowotne — sformułuj pytanie w tych ramach.",
|
||||
"conversationPoisoned": "Wcześniejsza wiadomość w tej rozmowie zawiera polecenia, które próbują obejść moje wytyczne. Rozpocznij nową rozmowę."
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
@@ -8267,7 +8317,9 @@
|
||||
"finally": "Finally …"
|
||||
}
|
||||
},
|
||||
"openDetail": "Pokaż szczegóły"
|
||||
"openDetail": "Pokaż szczegóły",
|
||||
"historyLoadError": "Nie udało się wczytać historii testów",
|
||||
"historyLoadErrorHint": "To nie znaczy, że nie masz wyników — nie udało się pobrać listy. Spróbuj ponownie."
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "To Twoje własne wpisy, a nie diagnoza lekarska. HealthLog nie diagnozuje, nie leczy i ich nie interpretuje.",
|
||||
@@ -8476,7 +8528,13 @@
|
||||
"title": "Szczegóły dokumentu",
|
||||
"summary": {
|
||||
"title": "Podsumowanie",
|
||||
"pending": "Podsumowanie jest przygotowywane."
|
||||
"pending": "Podsumowanie jest przygotowywane.",
|
||||
"none": "Dla tego dokumentu nie wygenerowano jeszcze podsumowania.",
|
||||
"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.",
|
||||
"saveError": "Nie udało się zapisać zmiany.",
|
||||
@@ -8558,7 +8616,6 @@
|
||||
"close": "Ukryj"
|
||||
},
|
||||
"ai": {
|
||||
"statusAiRead": "Odczytane przez AI",
|
||||
"readDone": "AI odczytało Twój dokument.",
|
||||
"read": "Odczytaj z AI",
|
||||
"reread": "Odczytaj ponownie",
|
||||
@@ -8724,7 +8781,8 @@
|
||||
"rowMeta": "{days} z {window} dni · zalecane {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{days} z {window} dni",
|
||||
"footer": "{tracked} z {total} śledzonych mikroskładników ma dane w tym okresie.",
|
||||
"emptyTitle": "Brak jeszcze zsynchronizowanych danych o mikroskładnikach"
|
||||
"emptyTitle": "Brak jeszcze zsynchronizowanych danych o mikroskładnikach",
|
||||
"loadError": "Nie udało się wczytać danych o składnikach odżywczych"
|
||||
},
|
||||
"sparseNote": "Niska wartość zwykle oznacza tu rzadkie zapisywanie, a nie niedobór — dzienne sumy z Apple Health odzwierciedlają to, co zapisały twoje aplikacje, a nie całkowite spożycie w diecie."
|
||||
},
|
||||
@@ -8740,8 +8798,11 @@
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
"title": "Lek do przyjęcia",
|
||||
"body": "Dziś przypada dawka.",
|
||||
"bodyNamed": "{name} przypada dziś."
|
||||
"body": "Dawka wkrótce do przyjęcia.",
|
||||
"bodyNamed": "{name} wkrótce do przyjęcia.",
|
||||
"overdueTitle": "Lek zaległy",
|
||||
"overdueBody": "Dawka jest zaległa.",
|
||||
"overdueBodyNamed": "{name} jest zaległy."
|
||||
},
|
||||
"preventiveCare": {
|
||||
"title": "Badanie do wykonania",
|
||||
@@ -8795,7 +8856,8 @@
|
||||
"deltaVsBaseline": "{delta} wzgl. Twojej wartości bazowej",
|
||||
"deltaFlat": "Zgodnie z Twoją wartością bazową",
|
||||
"sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł — dzisiejszy odczyt jest wstępny.",
|
||||
"readFullBriefing": "Przeczytaj pełny briefing",
|
||||
"justIn": "Właśnie dotarło",
|
||||
"justInAt": "Właśnie dotarło · {time}",
|
||||
"worthALook": "Warto sprawdzić",
|
||||
"allClear": "Dziś nic nie wymaga Twojej uwagi.",
|
||||
"ringLink": "Otwórz szczegóły: {metric}"
|
||||
|
||||
@@ -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
|
||||
|
||||
+7
-17
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.30.24",
|
||||
"version": "1.31.8",
|
||||
"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",
|
||||
@@ -59,7 +59,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@napi-rs/canvas": "0.1.80",
|
||||
"@napi-rs/canvas": "1.0.2",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@parse/node-apn": "^8.1.0",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
@@ -87,7 +87,7 @@
|
||||
"pg-boss": "^12.26.0",
|
||||
"prisma": "^7.8.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.6.0",
|
||||
"radix-ui": "^1.6.2",
|
||||
"react": "19.2.7",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "19.2.7",
|
||||
@@ -125,25 +125,15 @@
|
||||
"knip": "^6.16.1",
|
||||
"lefthook": "^2.1.10",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"pdfjs-dist": "6.1.200",
|
||||
"pg": "^8.22.0",
|
||||
"prettier": "^3.9.1",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"shadcn": "^4.13.0",
|
||||
"tailwindcss": "^4",
|
||||
"testcontainers": "^12.0.3",
|
||||
"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
|
||||
|
||||
Generated
+541
-423
File diff suppressed because it is too large.
Load diff
+20
-3
@@ -1,3 +1,20 @@
|
||||
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"
|
||||
|
||||
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,26 @@
|
||||
-- Record what happened to a document's summary.
|
||||
--
|
||||
-- `summary_encrypted` alone cannot tell four outcomes apart: a document nobody
|
||||
-- ever enqueued, one whose provider is missing, one the safety screen withheld,
|
||||
-- and one still in flight all leave it NULL. The detail view read that NULL as
|
||||
-- "still generating" and said so forever.
|
||||
--
|
||||
-- Additive and backfill-free: NONE is the default and is the truthful value for
|
||||
-- every pre-existing row, because none of them were ever attempted. Rows that
|
||||
-- already carry a summary are moved to READY below.
|
||||
|
||||
CREATE TYPE "document_summary_state" AS ENUM (
|
||||
'NONE',
|
||||
'PENDING',
|
||||
'READY',
|
||||
'WITHHELD',
|
||||
'UNAVAILABLE'
|
||||
);
|
||||
|
||||
ALTER TABLE "inbound_documents"
|
||||
ADD COLUMN "summary_state" "document_summary_state" NOT NULL DEFAULT 'NONE';
|
||||
|
||||
-- A document that already holds a summary is READY, not NONE.
|
||||
UPDATE "inbound_documents"
|
||||
SET "summary_state" = 'READY'
|
||||
WHERE "summary_encrypted" IS NOT NULL;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Session cookies carry a purpose-generated secret rather than the row's cuid
|
||||
-- primary key.
|
||||
--
|
||||
-- A cuid is built for collision-resistant identity, not for unguessability: it
|
||||
-- embeds a timestamp and a monotonic counter and does not come from a CSPRNG.
|
||||
-- A session cookie is a bearer credential and must. The cookie now carries a
|
||||
-- 32-byte random token (`hls_<hex>`) and only its HMAC-SHA256 hash is stored,
|
||||
-- matching how api_tokens and refresh_tokens already hold their secrets.
|
||||
--
|
||||
-- Additive and nullable ON PURPOSE: every session row that exists at deploy
|
||||
-- time keeps NULL here and stays resolvable by its id, so the release signs
|
||||
-- nobody out. Those rows cannot have their sliding expiry extended, so the
|
||||
-- id-as-credential path drains itself within the 30-day session lifetime with
|
||||
-- no operator action. Postgres permits many NULLs under a unique index, so the
|
||||
-- constraint below does not conflict with the backfill-free transition.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "sessions" ADD COLUMN "token_hash" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sessions_token_hash_key" ON "sessions"("token_hash");
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Carton labelling for an inventory container: manufacturer + printed strength.
|
||||
--
|
||||
-- The native client's pen list uses these two as its headline and subhead, so
|
||||
-- a container created on the web could not be shown there without fabricating
|
||||
-- an empty card. Nothing on the server held them, so the client kept its own
|
||||
-- copy locally and no back-fill was possible.
|
||||
--
|
||||
-- Both are additive and nullable. Every pre-existing row is truthfully NULL:
|
||||
-- the values were never collected, and a plain supply row (a blister pack, a
|
||||
-- bottle) has no use for either. No backfill, no default, no rewrite of the
|
||||
-- table's existing contents.
|
||||
ALTER TABLE "medication_inventory_items"
|
||||
ADD COLUMN "manufacturer" TEXT,
|
||||
ADD COLUMN "dose_strength" TEXT;
|
||||
@@ -0,0 +1,53 @@
|
||||
-- The data-arrival spine's durable reaction marker.
|
||||
--
|
||||
-- One row per (user, arrival kind, local day), written by the `data-arrival`
|
||||
-- worker after a SALIENT ingest. A backfill never reaches here: the seam
|
||||
-- classifier drops historical and future-dated samples before anything is
|
||||
-- enqueued, so a ten-year import writes nothing to this table.
|
||||
--
|
||||
-- The unique index is the point of the table, not decoration. It carries three
|
||||
-- separate contracts at once:
|
||||
-- 1. the "just in" marker every reaction surface reads,
|
||||
-- 2. the at-most-one-generated-line per kind per day claim — the worker
|
||||
-- inserts with ON CONFLICT DO NOTHING, so a lost race simply means
|
||||
-- another worker already owns today's line,
|
||||
-- 3. the audit trail of what the spine actually reacted to.
|
||||
--
|
||||
-- `line_encrypted` is AES-256-GCM at rest, following the
|
||||
-- `insight_narratives.encrypted_content` precedent. It is NULLABLE on purpose:
|
||||
-- the marker is the feature and the sentence is garnish, so a provider-less
|
||||
-- install fills the table normally and simply never sets this column.
|
||||
--
|
||||
-- Rows are pruned after 14 days by the daily `arrival-reaction-cleanup` job.
|
||||
|
||||
CREATE TABLE "arrival_reactions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"local_date" TEXT NOT NULL,
|
||||
"occurred_at" TIMESTAMP(3) NOT NULL,
|
||||
"ref_id" TEXT,
|
||||
"line_encrypted" BYTEA,
|
||||
"generated_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "arrival_reactions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- The once-per-kind-per-local-day claim. Do NOT widen this to include
|
||||
-- `ref_id`: the day scope IS the throttle.
|
||||
CREATE UNIQUE INDEX "arrival_reactions_user_id_kind_local_date_key"
|
||||
ON "arrival_reactions" ("user_id", "kind", "local_date");
|
||||
|
||||
-- The reaction surfaces read "today's rows for this user".
|
||||
CREATE INDEX "arrival_reactions_user_id_local_date_idx"
|
||||
ON "arrival_reactions" ("user_id", "local_date");
|
||||
|
||||
-- The nightly retention sweep deletes by age.
|
||||
CREATE INDEX "arrival_reactions_created_at_idx"
|
||||
ON "arrival_reactions" ("created_at");
|
||||
|
||||
ALTER TABLE "arrival_reactions"
|
||||
ADD CONSTRAINT "arrival_reactions_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- The per-workout Activity Insight paragraph.
|
||||
--
|
||||
-- Its own table rather than a column on `workouts` because a workout delete is
|
||||
-- a HARD delete: the FK below cascades, so the paragraph can never outlive the
|
||||
-- session it describes. Keeping it out of `workouts` also keeps the generation
|
||||
-- provenance off the row every list read selects from.
|
||||
--
|
||||
-- Rows are written ONLY by the `workout-insight-generate` worker, dispatched by
|
||||
-- the data-arrival spine when a workout genuinely lands. No read path creates
|
||||
-- one, so every workout that predates this migration stays without a row — and
|
||||
-- renders no card — forever, at zero provider cost.
|
||||
--
|
||||
-- `paragraph_encrypted` is AES-256-GCM at rest, following the
|
||||
-- `insight_narratives.encrypted_content` precedent, and is registered in
|
||||
-- `src/lib/crypto/encrypted-columns.ts` so key rotation covers it.
|
||||
--
|
||||
-- `input_hash` fingerprints the deterministic evidence the paragraph was built
|
||||
-- from together with the prompt version: a device re-post of the same session
|
||||
-- hashes identically and is refused before any provider is resolved.
|
||||
|
||||
CREATE TABLE "workout_insights" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"workout_id" TEXT NOT NULL,
|
||||
"paragraph_encrypted" BYTEA NOT NULL,
|
||||
"input_hash" TEXT NOT NULL,
|
||||
"prompt_version" TEXT NOT NULL,
|
||||
"provider_type" TEXT,
|
||||
"locale" TEXT NOT NULL,
|
||||
"generated_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "workout_insights_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- One paragraph per workout. This is also the second half of the
|
||||
-- double-post defence: the singleton queue key collapses a burst, and this
|
||||
-- constraint makes the claim durable even if two workers do run.
|
||||
CREATE UNIQUE INDEX "workout_insights_workout_id_key"
|
||||
ON "workout_insights"("workout_id");
|
||||
|
||||
-- The daily-cap count: rows for one user inside a generated_at window.
|
||||
CREATE INDEX "workout_insights_user_id_generated_at_idx"
|
||||
ON "workout_insights"("user_id", "generated_at");
|
||||
|
||||
ALTER TABLE "workout_insights"
|
||||
ADD CONSTRAINT "workout_insights_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "workout_insights"
|
||||
ADD CONSTRAINT "workout_insights_workout_id_fkey"
|
||||
FOREIGN KEY ("workout_id") REFERENCES "workouts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Durable arrival freshness plus ownership and spend linkage for
|
||||
-- arrival-reaction provider attempts.
|
||||
ALTER TABLE "arrival_reactions"
|
||||
ADD COLUMN "arrived_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "generation_claim_id" TEXT,
|
||||
ADD COLUMN "generation_claimed_at" TIMESTAMP(3),
|
||||
ADD COLUMN "generation_reserved_tokens" INTEGER,
|
||||
ADD COLUMN "generation_budget_date_key" TEXT,
|
||||
ADD COLUMN "generation_provider_invoked_at" TIMESTAMP(3);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Preserve the original landing time for markers that predate `arrived_at`.
|
||||
-- Migration 0260 must add the NOT NULL column with a default; this follow-up
|
||||
-- restores historical rows to their actual first-arrival timestamp before the
|
||||
-- application starts serving the release.
|
||||
UPDATE "arrival_reactions"
|
||||
SET "arrived_at" = "created_at";
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Durable single-spend ownership and local-day capacity reservations for
|
||||
-- per-workout Activity Insight generation. Existing insight rows remain valid
|
||||
-- and are counted by the worker until a claim row exists for their workout.
|
||||
CREATE TABLE "workout_insight_generation_claims" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"workout_id" TEXT NOT NULL,
|
||||
"local_date" TEXT NOT NULL,
|
||||
"claim_id" TEXT,
|
||||
"claimed_at" TIMESTAMP(3),
|
||||
"provider_invoked_at" TIMESTAMP(3),
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "workout_insight_generation_claims_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "workout_insight_generation_claims_workout_id_key"
|
||||
ON "workout_insight_generation_claims"("workout_id");
|
||||
|
||||
CREATE UNIQUE INDEX "workout_insight_generation_claims_claim_id_key"
|
||||
ON "workout_insight_generation_claims"("claim_id");
|
||||
|
||||
CREATE INDEX "workout_insight_generation_claims_user_id_local_date_idx"
|
||||
ON "workout_insight_generation_claims"("user_id", "local_date");
|
||||
|
||||
ALTER TABLE "workout_insight_generation_claims"
|
||||
ADD CONSTRAINT "workout_insight_generation_claims_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "workout_insight_generation_claims"
|
||||
ADD CONSTRAINT "workout_insight_generation_claims_workout_id_fkey"
|
||||
FOREIGN KEY ("workout_id") REFERENCES "workouts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -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;
|
||||
|
||||
+231
-20
@@ -792,6 +792,12 @@ model User {
|
||||
// v1.11.0 W3 — one durable period-narrative row per (period, locale).
|
||||
// Delete/regenerate-clean; the generated prose is AES-256-GCM at rest.
|
||||
insightNarratives InsightNarrative[]
|
||||
// v1.31.0 — the data-arrival spine's durable per-day reaction marker. One
|
||||
// row per (kind, local day); the unique index IS the once-per-kind-per-day
|
||||
// claim every reaction surface reads. Retained 14 days.
|
||||
arrivalReactions ArrivalReaction[]
|
||||
workoutInsights WorkoutInsight[]
|
||||
workoutInsightGenerationClaims WorkoutInsightGenerationClaim[]
|
||||
// v1.17.1 — preventive-care / measurement (Vorsorge) reminders. One row
|
||||
// per user-created reminder ("BP every 7 days", "annual blood panel").
|
||||
// Server computes the canonical `nextDueAt`; the cron fires through the
|
||||
@@ -1023,6 +1029,16 @@ model Passkey {
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
// v1.30.32 — HMAC-SHA256 of the session cookie's secret. The cookie carries
|
||||
// a 32-byte CSPRNG token (`hls_<hex>`), never this row's cuid: a cuid is
|
||||
// collision-resistant, not unguessable (embedded timestamp + counter), and a
|
||||
// session identifier is a bearer credential. Hash-at-rest mirrors the
|
||||
// ApiToken / RefreshToken posture — a database read yields no usable cookie.
|
||||
//
|
||||
// Nullable for the transition only: rows minted before this column existed
|
||||
// carry NULL and are still resolvable by id until they expire. See the
|
||||
// resolution comment in `src/lib/auth/session.ts`.
|
||||
tokenHash String? @unique @map("token_hash")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
ipAddress String? @map("ip_address")
|
||||
@@ -1576,6 +1592,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")
|
||||
@@ -1609,6 +1633,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
|
||||
@@ -1925,6 +1961,8 @@ model Workout {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
route WorkoutRoute?
|
||||
samples WorkoutSamples?
|
||||
insight WorkoutInsight?
|
||||
insightGenerationClaim WorkoutInsightGenerationClaim?
|
||||
|
||||
/// Ingest dedup. NULL externalId is distinct so two manual
|
||||
/// entries on the same day never collide here.
|
||||
@@ -2015,6 +2053,75 @@ model WorkoutSamples {
|
||||
@@map("workout_samples")
|
||||
}
|
||||
|
||||
// ─── Per-workout Activity Insight (v1.31.0) ─────────────────
|
||||
//
|
||||
// The grounded paragraph the workout-detail seam renders. Its OWN model
|
||||
// rather than a column on `Workout` for one concrete reason: workout deletes
|
||||
// are HARD deletes (see the note on the `Workout` unique above), so the row
|
||||
// has to disappear with its workout. A column would go with it too, but a
|
||||
// separate model also keeps the generation provenance (`inputHash`,
|
||||
// `promptVersion`, `providerType`) off the hot workout row that every list
|
||||
// read selects from, and gives the daily-cap count an index of its own.
|
||||
//
|
||||
// Written ONLY by the `workout-insight-generate` worker, which the arrival
|
||||
// spine dispatches when a workout genuinely LANDS. Nothing on a read path
|
||||
// ever creates one: a workout with no row renders no card, and that includes
|
||||
// every workout that predates this model.
|
||||
model WorkoutInsight {
|
||||
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
workoutId String @unique @map("workout_id")
|
||||
/// AES-256-GCM ciphertext of the generated paragraph. Follows the
|
||||
/// `InsightNarrative.encryptedContent` precedent, NOT the plaintext pattern
|
||||
/// the status cards use. Registered in `src/lib/crypto/encrypted-columns.ts`
|
||||
/// and rotated by `scripts/rotate-encryption-key.ts`.
|
||||
paragraphEncrypted Bytes @map("paragraph_encrypted")
|
||||
/// Fingerprint of the deterministic evidence the paragraph was generated
|
||||
/// from, plus the prompt version. A re-sync of the same session hashes
|
||||
/// identically and costs nothing.
|
||||
inputHash String @map("input_hash")
|
||||
promptVersion String @map("prompt_version")
|
||||
providerType String? @map("provider_type")
|
||||
/// The UI locale the paragraph was WRITTEN in. Provenance only: a later
|
||||
/// language switch does not regenerate, because a read path may never
|
||||
/// trigger a provider call. Stored so that is visible rather than guessed.
|
||||
locale String
|
||||
generatedAt DateTime @map("generated_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// The per-day cap count reads this: rows for one user in a generatedAt
|
||||
/// window.
|
||||
@@index([userId, generatedAt])
|
||||
@@map("workout_insights")
|
||||
}
|
||||
|
||||
// Durable ownership and local-day capacity reservation for workout insight
|
||||
// generation. The row is created before expensive evidence work. A live
|
||||
// `claimId` owns the pre-provider lease; `providerInvokedAt` is the terminal
|
||||
// boundary after which retries must never spend again.
|
||||
model WorkoutInsightGenerationClaim {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
workoutId String @unique @map("workout_id")
|
||||
localDate String @map("local_date")
|
||||
claimId String? @unique @map("claim_id")
|
||||
claimedAt DateTime? @map("claimed_at")
|
||||
providerInvokedAt DateTime? @map("provider_invoked_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)
|
||||
workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, localDate])
|
||||
@@map("workout_insight_generation_claims")
|
||||
}
|
||||
|
||||
// ─── Strain personal anchor cache (v1.10.3) ─────────────────
|
||||
//
|
||||
// Server-internal per-(user, day) cache that lets the nightly Strain
|
||||
@@ -2861,6 +2968,18 @@ model MedicationInventoryItem {
|
||||
/// The carton-printed expiry date the user enters at creation.
|
||||
printedExpiry DateTime? @map("printed_expiry")
|
||||
purchasedAt DateTime? @map("purchased_at")
|
||||
/// Marketing-authorisation holder / maker printed on the carton. Free
|
||||
/// text: the field is a label the user reads back, not a lookup key, and
|
||||
/// there is no authority list a self-hoster could be held to. Nullable
|
||||
/// because every pre-existing row predates it and plain supply rows
|
||||
/// (blisters, bottles) have no use for it.
|
||||
manufacturer String?
|
||||
/// Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text
|
||||
/// and deliberately NOT parsed into a number + unit: pens state strength
|
||||
/// per dose, per ml, or per cartridge depending on the product, and a
|
||||
/// forced numeric split would lose which of the three it was. Nullable
|
||||
/// for the same reason as `manufacturer`.
|
||||
doseStrength String? @map("dose_strength")
|
||||
/// Free-text note, application-layer max 200 chars.
|
||||
notes String?
|
||||
/// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column
|
||||
@@ -3232,9 +3351,9 @@ model RefreshToken {
|
||||
// the token pair the first exchange issued (refresh-token reuse-detection
|
||||
// posture). 90-second TTL enforced at read; a daily sweep prunes stale rows.
|
||||
model OidcNativeHandoff {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
codeHash String @unique @map("code_hash") // hashToken(hlh_<…>)
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
codeHash String @unique @map("code_hash") // hashToken(hlh_<…>)
|
||||
/// The app's S256 PKCE challenge, bound at mint; the exchange verifies
|
||||
/// s256(code_verifier) against it (constant-time) before consuming.
|
||||
codeChallenge String @map("code_challenge")
|
||||
@@ -3244,8 +3363,8 @@ model OidcNativeHandoff {
|
||||
/// revokes exactly this family member (+ its paired access token).
|
||||
issuedRefreshTokenHash String? @map("issued_refresh_token_hash")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
ipAddress String? @map("ip_address")
|
||||
userAgent String? @map("user_agent")
|
||||
ipAddress String? @map("ip_address")
|
||||
userAgent String? @map("user_agent")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -4185,6 +4304,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")
|
||||
@@ -5248,6 +5370,63 @@ model InsightNarrative {
|
||||
@@map("insight_narratives")
|
||||
}
|
||||
|
||||
// ─── Data-arrival spine (v1.31.0) ─────────────────────────────
|
||||
//
|
||||
// One row per (user, arrival kind, local day). The row is written by the
|
||||
// `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.
|
||||
model ArrivalReaction {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// One of `ARRIVAL_KINDS` (`@/lib/arrivals/types`).
|
||||
kind String
|
||||
/// The user's PROFILE-timezone YYYY-MM-DD this reaction is filed under.
|
||||
localDate String @map("local_date")
|
||||
/// Timestamp of the newest sample that triggered it.
|
||||
occurredAt DateTime @map("occurred_at")
|
||||
/// When the newest sample actually reached HealthLog. Unlike `createdAt`,
|
||||
/// this advances when a later same-kind arrival refreshes the daily marker.
|
||||
arrivedAt DateTime @default(now()) @map("arrived_at")
|
||||
/// Kind-scoped referent: the workout id for `workout`, the panel day for labs.
|
||||
refId String? @map("ref_id")
|
||||
/// AES-256-GCM ciphertext of the generated reaction line. Null until — and
|
||||
/// unless — a line is generated: the marker is the feature, the sentence is
|
||||
/// garnish, and a provider-less install never fills this in. Follows the
|
||||
/// `InsightNarrative.encryptedContent` precedent, NOT the plaintext pattern
|
||||
/// the status cards use. Registered in `scripts/rotate-encryption-key.ts`.
|
||||
lineEncrypted Bytes? @map("line_encrypted")
|
||||
generatedAt DateTime? @map("generated_at")
|
||||
/// Opaque owner token and lease for pre-provider setup. A stale worker may
|
||||
/// reclaim only while `generationProviderInvokedAt` is null.
|
||||
generationClaimId String? @map("generation_claim_id")
|
||||
generationClaimedAt DateTime? @map("generation_claimed_at")
|
||||
/// The token reservation is written in the same transaction as the
|
||||
/// `coach_usage` increment. A reclaimed pre-provider attempt reuses it.
|
||||
generationReservedTokens Int? @map("generation_reserved_tokens")
|
||||
generationBudgetDateKey String? @map("generation_budget_date_key")
|
||||
/// Durable provider boundary. Once set, this marker is terminal unless the
|
||||
/// encrypted line is committed; no retry may invoke a provider again.
|
||||
generationProviderInvokedAt DateTime? @map("generation_provider_invoked_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
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.
|
||||
@@unique([userId, kind, localDate])
|
||||
// The reaction surfaces read "today's rows for this user".
|
||||
@@index([userId, localDate])
|
||||
// The nightly retention sweep deletes by age.
|
||||
@@index([createdAt])
|
||||
@@map("arrival_reactions")
|
||||
}
|
||||
|
||||
// ─── Cycle Tracking (v1.15.0) ─────────────────────────────────
|
||||
//
|
||||
// Self-hosted reproductive-health tracking: period + flow, symptom
|
||||
@@ -6080,6 +6259,31 @@ enum InboundDocumentStatus {
|
||||
@@map("inbound_document_status")
|
||||
}
|
||||
|
||||
/// Outcome of a document's plain-language summary. Recorded so the detail view
|
||||
/// states what happened rather than claiming generation is in progress for a
|
||||
/// job that was never enqueued.
|
||||
///
|
||||
/// WITHHELD deserves a note: the outbound safety screen tripped, so the model's
|
||||
/// prose is discarded and NEVER shown. Only the fact of the refusal is kept, and
|
||||
/// it is deliberately not terminal — the user can ask again, which is the whole
|
||||
/// reason this is a state and not a stored refusal string.
|
||||
enum DocumentSummaryState {
|
||||
/// Never attempted — the opt-in was off at upload, or the document predates
|
||||
/// the feature. The default, so every existing row is correct.
|
||||
NONE
|
||||
/// Enqueued or running.
|
||||
PENDING
|
||||
/// A summary was produced and is stored in `summaryEncrypted`.
|
||||
READY
|
||||
/// Produced, then blocked by the outbound safety screen. Text discarded.
|
||||
WITHHELD
|
||||
/// Attempted and could not produce one — no vision provider, spent budget,
|
||||
/// withdrawn consent, an unreadable file, or a provider error.
|
||||
UNAVAILABLE
|
||||
|
||||
@@map("document_summary_state")
|
||||
}
|
||||
|
||||
/// The FHIR resource an extracted fact maps to (STATED status only): a
|
||||
/// diagnosis → Condition, a measured value → Observation, a medication the
|
||||
/// patient is/was taking → MedicationStatement.
|
||||
@@ -6166,6 +6370,13 @@ model InboundDocument {
|
||||
/// When the background summary was generated (null until it runs). Surfaced
|
||||
/// as meta beside the summary in the document detail view.
|
||||
summaryGeneratedAt DateTime? @map("summary_generated_at")
|
||||
/// What actually happened to this document's summary, so the detail view can
|
||||
/// tell the four outcomes apart instead of reading a null column as "still
|
||||
/// generating". `summaryEncrypted` alone cannot: a document nobody ever
|
||||
/// enqueued, one whose provider is missing, one the safety screen withheld,
|
||||
/// and one mid-flight all leave it null. NONE is the default, which keeps
|
||||
/// every pre-existing row correct — none of them were ever attempted.
|
||||
summaryState DocumentSummaryState @default(NONE) @map("summary_state")
|
||||
|
||||
facts ExtractedFact[]
|
||||
conditionLinks DocumentConditionLink[]
|
||||
@@ -6292,14 +6503,14 @@ model DocumentConditionLink {
|
||||
/// versioned key (no schema column), and key rotation re-tokenises from the
|
||||
/// decrypted `textEncrypted`.
|
||||
model DocumentContentIndex {
|
||||
id String @id @default(cuid())
|
||||
documentId String @unique @map("document_id")
|
||||
userId String @map("user_id")
|
||||
id String @id @default(cuid())
|
||||
documentId String @unique @map("document_id")
|
||||
userId String @map("user_id")
|
||||
/// AES-256-GCM ciphertext of the normalised extracted text (capped, first
|
||||
/// ~64 KiB), stored as the `encrypt()`-string-as-UTF-8 Bytes shape shared
|
||||
/// with the Coach columns. The plaintext is recoverable server-side so
|
||||
/// rotation can re-tokenise; the list query never selects it.
|
||||
textEncrypted Bytes @map("text_encrypted")
|
||||
textEncrypted Bytes @map("text_encrypted")
|
||||
/// v1.27.33 (Document vault P4 — chat about a document) — AES-256-GCM
|
||||
/// ciphertext of the VERBATIM extracted text (byte-capped, NOT lowercased or
|
||||
/// de-accented), stored additionally alongside `textEncrypted` so a document
|
||||
@@ -6308,22 +6519,22 @@ model DocumentContentIndex {
|
||||
/// UTF-8 Bytes shape as every other PHI column. Nullable: rows indexed before
|
||||
/// P4 carry NULL until re-indexed, and the chat route falls back to the
|
||||
/// normalised `textEncrypted` when it is absent. The list query never selects it.
|
||||
verbatimTextEncrypted Bytes? @map("verbatim_text_encrypted")
|
||||
verbatimTextEncrypted Bytes? @map("verbatim_text_encrypted")
|
||||
/// Deduped HMAC-SHA256 (truncated hex) of the normalised tokens — the blind
|
||||
/// index. One-way: opaque without the server-held index subkey. GIN-indexed
|
||||
/// (raw SQL in the migration; Prisma cannot express a GIN index).
|
||||
searchTokens String[] @default([]) @map("search_tokens")
|
||||
searchTokens String[] @default([]) @map("search_tokens")
|
||||
/// Provenance of the indexed text: "vision" (provider transcription of the
|
||||
/// stored original), "text-ocr" (browser-OCR text posted by the client),
|
||||
/// "local-pdf" (server-side text-layer extraction, no provider) or "local-ocr"
|
||||
/// (server-side OCR of a scanned document — deferred follow-up). Free String;
|
||||
/// adding a source value needs no migration.
|
||||
source String
|
||||
source String
|
||||
/// Provider type that produced the text (diagnostic only), or null on the
|
||||
/// text-ocr path.
|
||||
providerType String? @map("provider_type")
|
||||
providerType String? @map("provider_type")
|
||||
/// Tokeniser algorithm version — bump to force a re-index on a scheme change.
|
||||
tokenizerVersion String @map("tokenizer_version")
|
||||
tokenizerVersion String @map("tokenizer_version")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@@ -6344,23 +6555,23 @@ model DocumentContentIndex {
|
||||
/// source, so the preview is metadata-free by construction. Owner-scoped
|
||||
/// serve route only; cascades on delete.
|
||||
model DocumentThumbnail {
|
||||
id String @id @default(cuid())
|
||||
documentId String @unique @map("document_id")
|
||||
userId String @map("user_id")
|
||||
id String @id @default(cuid())
|
||||
documentId String @unique @map("document_id")
|
||||
userId String @map("user_id")
|
||||
/// AES-256-GCM ciphertext of the JPEG preview, stored as the
|
||||
/// `encrypt()`-string-as-UTF-8 Bytes shape (base64 of the JPEG), the same
|
||||
/// codec `DocumentContentIndex.textEncrypted` uses so the standard
|
||||
/// key-rotation walk covers it. A scanned medical preview is PHI — never
|
||||
/// stored in the clear or logged. The list query never selects it.
|
||||
thumbnailEncrypted Bytes @map("thumbnail_encrypted")
|
||||
thumbnailEncrypted Bytes @map("thumbnail_encrypted")
|
||||
/// Decoded thumbnail dimensions (px) — diagnostic + lets a future consumer
|
||||
/// size the tile without decoding the blob.
|
||||
width Int
|
||||
height Int
|
||||
/// Plaintext byte length of the JPEG (pre-encryption), for `Content-Length`.
|
||||
byteSize Int @map("byte_size")
|
||||
byteSize Int @map("byte_size")
|
||||
/// Source MIME the preview was rendered from (diagnostic only).
|
||||
sourceMime String @map("source_mime")
|
||||
sourceMime String @map("source_mime")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
+1
-1
@@ -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.30.24";
|
||||
/* @sw-version-fallback */ "v1.31.8";
|
||||
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/*`
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
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" }]]);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
{
|
||||
"name": "API_TOKEN_HMAC_KEY",
|
||||
"purpose": "HMAC-SHA256 key for hashing external Bearer tokens. 64 hex chars."
|
||||
"purpose": "HMAC-SHA256 key for hashing external Bearer tokens AND web session cookies. 64 hex chars. Rotating it signs every browser session out on top of invalidating every issued token."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -392,6 +392,30 @@ async function main() {
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.31.0 ArrivalReaction."lineEncrypted" (Bytes, nullable) ─────
|
||||
// The data-arrival spine's generated reaction line. Nullable by design — a
|
||||
// provider-less install writes markers with no line at all — and
|
||||
// `rotateBytesColumn` already skips a NULL / zero-length payload, so those
|
||||
// rows are scanned and cleanly left alone.
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"ArrivalReaction",
|
||||
"lineEncrypted",
|
||||
prisma.arrivalReaction,
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.31.0 WorkoutInsight."paragraphEncrypted" (Bytes) ─────
|
||||
// The per-workout Activity Insight paragraph. Not nullable — a row exists
|
||||
// only where a paragraph was generated — so every scanned row rotates.
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"WorkoutInsight",
|
||||
"paragraphEncrypted",
|
||||
prisma.workoutInsight,
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.18.1 clinical-spine notes (Bytes columns) ─────
|
||||
// "noteEncrypted" (LabResult / IllnessEpisode / IllnessDayLog) +
|
||||
// "contextEncrypted" (Biomarker). Mirror the CoachFact.factEncrypted block.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Copy-matches-behaviour guard.
|
||||
*
|
||||
* Three classes of copy that drifted away from the code they describe, each
|
||||
* pinned here so the pair fails loudly if either half moves again:
|
||||
*
|
||||
* 1. DELETE CONFIRMATIONS. A confirmation is the moment a user weighs the
|
||||
* consequence. Four dialogs promised permanence on surfaces that render
|
||||
* an Undo affordance (measurements bulk, mood bulk, labs, illness) — a
|
||||
* cautious user declines a safe delete, and the warning loses weight
|
||||
* where it is true. This test pins both directions: undo-able surfaces
|
||||
* must offer the undo sentence and must NOT claim finality; the
|
||||
* genuinely permanent ones must keep their warning.
|
||||
*
|
||||
* 2. SLEEP DEBT. v1.19.0 replaced the summed shortfall with a running
|
||||
* balance that credits surplus sleep. The labels still described the old
|
||||
* arithmetic, so the word contradicted the number for anyone who caught
|
||||
* up after a short night.
|
||||
*
|
||||
* 3. GLUCOSE REFERENCE BAND. The declared diabetes opt-in has a server
|
||||
* preference and a route; the copy for its web control must exist in
|
||||
* every locale, since a missing key silently degrades to the raw key on
|
||||
* a surface that makes a clinical-adjacent claim.
|
||||
*
|
||||
* Reversibility evidence for the undo-able set:
|
||||
* - measurements: `POST /api/measurements/restore`, undo toast in
|
||||
* `src/components/measurements/measurement-list.tsx`
|
||||
* - mood: `POST /api/mood-entries/restore`, undo toast in
|
||||
* `src/components/mood/mood-list.tsx`
|
||||
* - labs: `POST /api/labs/restore`, undo toast in
|
||||
* `src/components/labs/lab-history-list.tsx`
|
||||
* - illness: `POST /api/illness/episodes/[id]/restore`, undo toast in
|
||||
* `src/components/illness/episode-menu.tsx`
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const MESSAGES = join(__dirname, "../../messages");
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const;
|
||||
|
||||
function bundle(locale: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8"));
|
||||
}
|
||||
|
||||
function resolve(obj: Record<string, unknown>, path: string): string {
|
||||
let cur: unknown = obj;
|
||||
for (const seg of path.split(".")) {
|
||||
expect(cur, `${path} is missing at "${seg}"`).toBeTypeOf("object");
|
||||
cur = (cur as Record<string, unknown>)[seg];
|
||||
}
|
||||
expect(typeof cur, `${path} must resolve to a string`).toBe("string");
|
||||
return cur as string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-locale phrases that assert a deletion cannot be taken back. A dialog on
|
||||
* an undo-able surface must contain none of these.
|
||||
*/
|
||||
const FINALITY_PHRASES: Record<string, readonly string[]> = {
|
||||
en: ["cannot be undone", "can't be undone", "permanently", "permanent"],
|
||||
de: ["nicht rückgängig", "endgültig", "dauerhaft", "unwiderruflich"],
|
||||
es: ["no se puede deshacer", "permanente", "irreversible"],
|
||||
fr: [
|
||||
"irréversible",
|
||||
"définitivement",
|
||||
"définitive",
|
||||
"ne peut pas être annulé",
|
||||
],
|
||||
it: [
|
||||
"non può essere annullata",
|
||||
"definitivamente",
|
||||
"definitiva",
|
||||
"permanente",
|
||||
],
|
||||
pl: ["nie można cofnąć", "trwale", "nieodwracaln"],
|
||||
};
|
||||
|
||||
/** Per-locale phrase that promises the short undo window. */
|
||||
const UNDO_PHRASES: Record<string, string> = {
|
||||
en: "undo",
|
||||
de: "rückgängig",
|
||||
es: "deshacerlo",
|
||||
fr: "annuler cette action",
|
||||
it: "annullare",
|
||||
pl: "cofnąć",
|
||||
};
|
||||
|
||||
/** Confirmation bodies whose surface renders an Undo affordance. */
|
||||
const UNDOABLE_CONFIRM_KEYS = [
|
||||
"measurements.deleteConfirmDescription",
|
||||
"measurements.bulkDeleteConfirmBody",
|
||||
"mood.deleteConfirmDescription",
|
||||
"mood.bulkDeleteConfirmBody",
|
||||
"labs.deleteConfirmDescription",
|
||||
"illness.deleteConfirm.body",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Confirmation bodies whose delete really is unrecoverable for the user —
|
||||
* hard delete or a tombstone with no restore path. These MUST keep a
|
||||
* finality warning; softening them would be the mirror-image bug.
|
||||
*/
|
||||
const PERMANENT_CONFIRM_KEYS = [
|
||||
// hard delete + cascade of every recorded value
|
||||
"labs.biomarker.deleteConfirmDescription",
|
||||
// `?purge=true` hard-deletes the tag off every past entry
|
||||
"mood.manage.purgeBody",
|
||||
// GDPR Art. 17 account purge
|
||||
"settings.deleteAccountConfirmDescription",
|
||||
] as const;
|
||||
|
||||
describe("delete confirmations describe what the surface actually does", () => {
|
||||
for (const locale of LOCALES) {
|
||||
const msgs = bundle(locale);
|
||||
|
||||
describe(locale, () => {
|
||||
for (const key of UNDOABLE_CONFIRM_KEYS) {
|
||||
it(`${key} offers undo and claims no finality`, () => {
|
||||
const copy = resolve(msgs, key).toLowerCase();
|
||||
|
||||
for (const phrase of FINALITY_PHRASES[locale]) {
|
||||
expect(
|
||||
copy,
|
||||
`${locale}/${key} claims finality ("${phrase}") but the surface renders an Undo`,
|
||||
).not.toContain(phrase.toLowerCase());
|
||||
}
|
||||
|
||||
expect(
|
||||
copy,
|
||||
`${locale}/${key} should tell the user the delete can be undone`,
|
||||
).toContain(UNDO_PHRASES[locale].toLowerCase());
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of PERMANENT_CONFIRM_KEYS) {
|
||||
it(`${key} keeps its finality warning`, () => {
|
||||
const copy = resolve(msgs, key).toLowerCase();
|
||||
const warns = FINALITY_PHRASES[locale].some((p) =>
|
||||
copy.includes(p.toLowerCase()),
|
||||
);
|
||||
expect(
|
||||
warns,
|
||||
`${locale}/${key} deletes irrecoverably — the copy must say so`,
|
||||
).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("sleep debt is labelled as a balance, not a summed deficit", () => {
|
||||
/**
|
||||
* The figure is a running balance: a short night adds, a long night pays it
|
||||
* down. Deficit/shortfall vocabulary describes the pre-v1.19.0 sum.
|
||||
*/
|
||||
const STALE_SUM_VOCABULARY: Record<string, readonly string[]> = {
|
||||
en: ["cumulative", "shortfall", "accumulated"],
|
||||
de: ["kumuliert", "defizit", "aufgelaufen"],
|
||||
es: ["acumulado", "acumulada", "déficit"],
|
||||
fr: ["cumulé", "cumulée", "déficit"],
|
||||
it: ["cumulato", "cumulata", "accumulato", "deficit"],
|
||||
pl: ["skumulowan", "niedobór", "niedoboru"],
|
||||
};
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
const msgs = bundle(locale);
|
||||
|
||||
it(`${locale} debt captions carry no summed-deficit wording`, () => {
|
||||
for (const key of [
|
||||
"insights.sleep.debt.caption",
|
||||
"insights.sleep.debt.clearCaption",
|
||||
]) {
|
||||
const copy = resolve(msgs, key).toLowerCase();
|
||||
for (const term of STALE_SUM_VOCABULARY[locale]) {
|
||||
expect(
|
||||
copy,
|
||||
`${locale}/${key} still describes the pre-v1.19.0 summed shortfall ("${term}")`,
|
||||
).not.toContain(term);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("the explainer states that catch-up sleep pays the balance down", () => {
|
||||
const copy = resolve(bundle("en"), "insights.sleep.debt.computedInfo");
|
||||
expect(copy.toLowerCase()).toContain("balance");
|
||||
expect(copy.toLowerCase()).toContain("catch-up");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the glucose reference band control is translated everywhere", () => {
|
||||
const KEYS = [
|
||||
"settings.glucoseReference.title",
|
||||
"settings.glucoseReference.description",
|
||||
"settings.glucoseReference.enable",
|
||||
"settings.glucoseReference.explainer",
|
||||
"settings.glucoseReference.disclaimer",
|
||||
"settings.glucoseReference.error",
|
||||
] as const;
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
it(`${locale} carries every key`, () => {
|
||||
const msgs = bundle(locale);
|
||||
for (const key of KEYS) {
|
||||
expect(resolve(msgs, key).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("the disclaimer refuses the diagnosis reading (EN)", () => {
|
||||
const copy = resolve(
|
||||
bundle("en"),
|
||||
"settings.glucoseReference.disclaimer",
|
||||
).toLowerCase();
|
||||
expect(copy).toContain("not a diagnosis");
|
||||
expect(copy).toContain("never inferred");
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,8 @@ const LEGIT_IDENTICAL = new Set<string>([
|
||||
// Clinical abbreviations / SI units — identical across locales.
|
||||
"charts.vo2Max",
|
||||
"cycle.insights.crosstab.unitGlucose",
|
||||
"dashboard.metric.unit.bmi",
|
||||
"dashboard.metric.unit.mood",
|
||||
"dashboard.metric.unit.glucose",
|
||||
"dashboard.vo2Max",
|
||||
"dashboard.vo2MaxShort",
|
||||
|
||||
@@ -108,6 +108,19 @@ function collectReferences(
|
||||
/(?<![a-zA-Z0-9_])t\(\s*["']([a-zA-Z][a-zA-Z0-9_.-]+)["']/g;
|
||||
for (const m of allText.matchAll(literalKeyRe)) literalKeys.add(m[1]);
|
||||
|
||||
// Plural-tier composition: `tCount("dashboard.staleHintWeeks", n)` and the
|
||||
// underlying `pluralKey("…", n, locale)` both resolve to `<base>One` /
|
||||
// `<base>Few` / `<base>Other` at runtime, so the base literal accounts for
|
||||
// exactly those three leaves and no others. Kept as an explicit tier list
|
||||
// rather than a prefix match so a stray sibling key under the same base still
|
||||
// reports as an orphan.
|
||||
const pluralKeyRe =
|
||||
/(?:pluralKey|tCount)\(\s*["']([a-zA-Z][a-zA-Z0-9_.-]+)["']/g;
|
||||
for (const m of allText.matchAll(pluralKeyRe)) {
|
||||
for (const tier of ["One", "Few", "Other"])
|
||||
literalKeys.add(`${m[1]}${tier}`);
|
||||
}
|
||||
|
||||
const dottedBases = new Set<string>();
|
||||
const concatPrefixes = new Set<string>();
|
||||
const dynamicSuffixes = new Set<string>();
|
||||
@@ -232,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,118 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative, sep } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Structural guard over the server-prefetching pages.
|
||||
*
|
||||
* A page that calls `dehydrate()` inside a `HydrationBoundary` serialises the
|
||||
* caller's own health record into the HTML document. Two things must hold for
|
||||
* every such page, and neither is enforced by the type system:
|
||||
*
|
||||
* 1. It must be session-gated at the edge, so `src/proxy.ts` stamps
|
||||
* `Cache-Control: private, no-store` on the document. A prefetching page
|
||||
* placed on a public path would ship a record with no cache directive at
|
||||
* all.
|
||||
* 2. It must not opt into static or revalidated rendering. `export const
|
||||
* revalidate = <n>` or `dynamic = "force-static"` would let Next serve one
|
||||
* account's prefetched HTML to the next caller out of its own cache,
|
||||
* before the request ever reaches a header.
|
||||
*
|
||||
* The guard walks the app tree rather than naming files, so a NEW prefetching
|
||||
* page is covered the moment it lands.
|
||||
*/
|
||||
|
||||
const APP_DIR = join(process.cwd(), "src", "app");
|
||||
|
||||
/** Mirrors the public-path allowlist in `src/proxy.ts`. */
|
||||
const PUBLIC_ROUTE_PREFIXES = [
|
||||
"/auth/",
|
||||
"/privacy",
|
||||
"/about",
|
||||
"/c/",
|
||||
"/invite/",
|
||||
"/onboarding",
|
||||
"/mcp",
|
||||
"/i18n/",
|
||||
"/.well-known/",
|
||||
];
|
||||
|
||||
function walk(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (entry === "node_modules" || entry === "__tests__") continue;
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
out.push(...walk(full));
|
||||
} else if (entry === "page.tsx" || entry === "page.ts") {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Turn `src/app/insights/workouts/page.tsx` into `/insights/workouts`. */
|
||||
function routePathFor(file: string): string {
|
||||
const rel = relative(APP_DIR, file).split(sep).slice(0, -1);
|
||||
const segments = rel.filter(
|
||||
// Route groups `(name)` and parallel/intercepting slots contribute no
|
||||
// URL segment.
|
||||
(s) => !(s.startsWith("(") && s.endsWith(")")) && !s.startsWith("@"),
|
||||
);
|
||||
return "/" + segments.join("/");
|
||||
}
|
||||
|
||||
function isPublicRoute(routePath: string): boolean {
|
||||
return PUBLIC_ROUTE_PREFIXES.some((p) => routePath.startsWith(p));
|
||||
}
|
||||
|
||||
const prefetchingPages = walk(APP_DIR)
|
||||
.map((file) => ({ file, source: readFileSync(file, "utf8") }))
|
||||
.filter(
|
||||
({ source }) =>
|
||||
source.includes("HydrationBoundary") && source.includes("dehydrate"),
|
||||
)
|
||||
.map(({ file, source }) => ({
|
||||
file,
|
||||
source,
|
||||
routePath: routePathFor(file),
|
||||
rel: relative(process.cwd(), file),
|
||||
}));
|
||||
|
||||
describe("server-prefetching pages cannot leak a cacheable record", () => {
|
||||
it("finds the prefetching pages at all (guard is not vacuous)", () => {
|
||||
expect(prefetchingPages.length).toBeGreaterThan(0);
|
||||
// The dashboard is the canonical one; if it stops matching, the detection
|
||||
// heuristic has drifted and every assertion below is silently skipped.
|
||||
expect(prefetchingPages.map((p) => p.routePath)).toContain("/");
|
||||
});
|
||||
|
||||
it("keeps every prefetching page behind the session gate", () => {
|
||||
const publicOnes = prefetchingPages.filter((p) =>
|
||||
isPublicRoute(p.routePath),
|
||||
);
|
||||
expect(
|
||||
publicOnes.map((p) => `${p.rel} (${p.routePath})`),
|
||||
"a page that dehydrates a record onto a public path gets no no-store header from the proxy",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("lets no prefetching page opt into static or revalidated rendering", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const page of prefetchingPages) {
|
||||
if (/export\s+const\s+revalidate\s*=/.test(page.source)) {
|
||||
offenders.push(`${page.rel}: exports revalidate`);
|
||||
}
|
||||
const dynamicMatch = page.source.match(
|
||||
/export\s+const\s+dynamic\s*=\s*["']([^"']+)["']/,
|
||||
);
|
||||
if (dynamicMatch && dynamicMatch[1] !== "force-dynamic") {
|
||||
offenders.push(`${page.rel}: exports dynamic = "${dynamicMatch[1]}"`);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
offenders,
|
||||
"a prefetched page must never be served from Next's own render cache",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -130,6 +130,7 @@ describe("proxy.ts onboarding redirect (v1.4.22 C4)", () => {
|
||||
});
|
||||
|
||||
it("generates CSP nonces without relying on Node Buffer", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
const originalBuffer = (
|
||||
globalThis as typeof globalThis & { Buffer?: Buffer }
|
||||
).Buffer;
|
||||
@@ -149,6 +150,7 @@ describe("proxy.ts onboarding redirect (v1.4.22 C4)", () => {
|
||||
value: originalBuffer,
|
||||
configurable: true,
|
||||
});
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Session-gated pages serialise one account's health record into the HTML
|
||||
* document — the dashboard, Insights, the workouts list, the medications
|
||||
* list and the Coach all server-prefetch their data through a TanStack
|
||||
* `HydrationBoundary`, so the record ships inside the payload rather than
|
||||
* arriving over a later fetch.
|
||||
*
|
||||
* Those pages are dynamic today only because they read the session cookie.
|
||||
* That is a property of the page code, not a guarantee — so the proxy pins
|
||||
* `Cache-Control: private, no-store` on every session-gated page response.
|
||||
* `private` bars shared caches (a CDN, a reverse proxy); `no-store` also
|
||||
* bars the browser disk cache and the bfcache snapshot.
|
||||
*
|
||||
* These tests pin both sides: gated pages carry the header, and the public
|
||||
* surfaces that are deliberately cacheable do not.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
}));
|
||||
|
||||
import { proxy } from "../proxy";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function makeRequest(pathname: string): NextRequest {
|
||||
return new NextRequest(`http://localhost${pathname}`, {
|
||||
headers: { cookie: "healthlog_session=sess-1" },
|
||||
});
|
||||
}
|
||||
|
||||
/** The pages that server-prefetch and dehydrate a record into the HTML. */
|
||||
const PREFETCHING_PAGES = [
|
||||
"/",
|
||||
"/coach",
|
||||
"/insights",
|
||||
"/insights/workouts",
|
||||
"/medications",
|
||||
];
|
||||
|
||||
/** Other session-gated surfaces that render account data. */
|
||||
const OTHER_GATED_PAGES = [
|
||||
"/documents",
|
||||
"/settings/profile",
|
||||
"/admin/users",
|
||||
"/mood",
|
||||
];
|
||||
|
||||
describe("proxy.ts no-store on session-gated pages", () => {
|
||||
it.each(PREFETCHING_PAGES)(
|
||||
"sets private, no-store on the prefetching page %s",
|
||||
(path) => {
|
||||
const res = proxy(makeRequest(path));
|
||||
const cacheControl = res.headers.get("cache-control") ?? "";
|
||||
expect(cacheControl).toContain("no-store");
|
||||
expect(cacheControl).toContain("private");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(OTHER_GATED_PAGES)(
|
||||
"sets private, no-store on the gated page %s",
|
||||
(path) => {
|
||||
const res = proxy(makeRequest(path));
|
||||
const cacheControl = res.headers.get("cache-control") ?? "";
|
||||
expect(cacheControl).toContain("no-store");
|
||||
expect(cacheControl).toContain("private");
|
||||
},
|
||||
);
|
||||
|
||||
it("still emits no-store when the request carries no session cookie", () => {
|
||||
// The unauthenticated caller gets a redirect to /auth/login, which never
|
||||
// reaches the header block — assert the redirect rather than a header, so
|
||||
// the test states what actually happens on that arm.
|
||||
const res = proxy(new NextRequest("http://localhost/medications"));
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toContain("/auth/login");
|
||||
});
|
||||
|
||||
it("leaves the public legal + auth surfaces cacheable", () => {
|
||||
for (const path of ["/privacy", "/about", "/auth/login"]) {
|
||||
const res = proxy(makeRequest(path));
|
||||
expect(res.headers.get("cache-control"), path).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the immutable locale catalog cacheable", () => {
|
||||
const res = proxy(makeRequest("/i18n/en"));
|
||||
expect(res.headers.get("cache-control")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not blanket API routes, which manage their own caching", () => {
|
||||
const res = proxy(makeRequest("/api/user/avatar/user-1"));
|
||||
expect(res.headers.get("cache-control")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the stricter share-link posture on /c/<token>", () => {
|
||||
const token = "hls_000000000000000000000000000000000000000000000000";
|
||||
const res = proxy(makeRequest(`/c/${token}`));
|
||||
const cacheControl = res.headers.get("cache-control") ?? "";
|
||||
expect(cacheControl).toContain("no-store");
|
||||
expect(cacheControl).toContain("must-revalidate");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,12 @@ import type { AchievementProgress } from "@/lib/gamification/achievements";
|
||||
* matching the `onboarding` page's existing test pattern.
|
||||
*/
|
||||
|
||||
// The page bounces a disabled-module direct hit to /insights (it used to
|
||||
// render a blank page), so the SSR render needs a router in scope.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
|
||||
@@ -34,6 +34,8 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDate } from "@/lib/format";
|
||||
@@ -260,6 +262,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
export default function AchievementsPage() {
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
// v1.18.0 — the achievements module gate. When the account has the
|
||||
// module turned off the whole page disappears: an empty state stands in
|
||||
@@ -276,14 +279,26 @@ export default function AchievementsPage() {
|
||||
enabled: isAuthenticated && achievementsEnabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && isAuthenticated && !achievementsEnabled) {
|
||||
router.push("/insights");
|
||||
}
|
||||
}, [authLoading, isAuthenticated, achievementsEnabled, router]);
|
||||
|
||||
if (authLoading || (achievementsEnabled && isLoading)) {
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
// v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry
|
||||
// is hidden by the same gate, so a direct hit on /achievements renders
|
||||
// nothing rather than an orphaned shell. No new i18n string is minted
|
||||
// (the disabled state has no copy of its own).
|
||||
// v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry is
|
||||
// hidden by the same gate, so this only catches a direct URL hit. It used to
|
||||
// render `null` and stop there, which left a blank page with no explanation
|
||||
// and no route anywhere — a dead end rather than a gate. It now bounces to
|
||||
// /insights, matching every other module-gated page (`useModulePageGuard`);
|
||||
// the brief `null` is the pre-redirect frame, not the destination.
|
||||
//
|
||||
// The shared guard is not used wholesale here because it also redirects an
|
||||
// unauthenticated visitor to /auth/login, and this page deliberately answers
|
||||
// that case with a "sign in to see your achievements" header instead.
|
||||
if (isAuthenticated && !achievementsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn(() => "{}") }));
|
||||
vi.mock("@/lib/crypto/note-cipher", () => ({ encryptNote: vi.fn() }));
|
||||
vi.mock("@/lib/validations/backup", () => ({
|
||||
parseBackupPayload: vi.fn(),
|
||||
isCompatibleSchemaVersion: vi.fn(() => true),
|
||||
summarizeBackup: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
);
|
||||
|
||||
@@ -24,6 +24,17 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/coach/budget", () => ({
|
||||
buildDateKey: () => "2026-01-01",
|
||||
reserveBudget: vi.fn(async () => ({
|
||||
allowed: true,
|
||||
reserved: 32,
|
||||
totalAfter: 32,
|
||||
})),
|
||||
reconcileSpend: vi.fn(async () => undefined),
|
||||
resolveDailyCap: () => 200_000,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
@@ -278,3 +289,72 @@ describe("POST /api/ai/test — dropdown-aware override", () => {
|
||||
expect(body.error).toBe("Anthropic API key not configured");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Cost ceiling. The 5/min bucket bounded burst rate but nothing cumulative —
|
||||
* sustained, it permits ~7 200 probes a day, and with an empty body this route
|
||||
* resolves the SAME provider chain generation uses, which can be the
|
||||
* operator's own credential. So the surface could reach the operator's key
|
||||
* indefinitely with no daily ceiling and no ledger entry at all.
|
||||
*/
|
||||
describe("POST /api/ai/test — daily ceiling + ledger", () => {
|
||||
function okProvider() {
|
||||
vi.mocked(resolveProviderForTest).mockResolvedValue({
|
||||
type: "anthropic",
|
||||
generateCompletion: vi.fn(async () => ({
|
||||
content: '{"ok":true}',
|
||||
providerType: "anthropic",
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
tokensUsed: 5,
|
||||
})),
|
||||
} as never);
|
||||
}
|
||||
|
||||
it("refuses once the per-user daily probe limit is spent", async () => {
|
||||
const { checkRateLimit } = await import("@/lib/rate-limit");
|
||||
okProvider();
|
||||
// Burst bucket fine, cumulative daily bucket exhausted.
|
||||
vi.mocked(checkRateLimit)
|
||||
.mockResolvedValueOnce({ allowed: true } as never)
|
||||
.mockResolvedValueOnce({ allowed: false } as never);
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
// Refused before any provider egress.
|
||||
expect(resolveProviderForTest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses when the day's token budget is already exhausted", async () => {
|
||||
const { reserveBudget } = await import("@/lib/ai/coach/budget");
|
||||
const generateCompletion = vi.fn();
|
||||
vi.mocked(resolveProviderForTest).mockResolvedValue({
|
||||
type: "admin-key",
|
||||
generateCompletion,
|
||||
} as never);
|
||||
vi.mocked(reserveBudget).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
reserved: 32,
|
||||
totalAfter: 200_000,
|
||||
} as never);
|
||||
|
||||
const response = await POST(emptyRequest() as never);
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(generateCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records a successful probe on the ledger", async () => {
|
||||
const { reconcileSpend } = await import("@/lib/ai/coach/budget");
|
||||
okProvider();
|
||||
|
||||
await POST(emptyRequest() as never);
|
||||
|
||||
expect(reconcileSpend).toHaveBeenCalledWith(
|
||||
"u-1",
|
||||
32,
|
||||
expect.any(Number),
|
||||
"2026-01-01",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,32 @@ import { singleUserTurn } from "@/lib/ai/types";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
buildDateKey,
|
||||
reconcileSpend,
|
||||
reserveBudget,
|
||||
resolveDailyCap,
|
||||
} from "@/lib/ai/coach/budget";
|
||||
|
||||
/**
|
||||
* Output ceiling of the probe below. Mirrors the `maxTokens` on the completion
|
||||
* so the ledger reserves what the call can actually spend.
|
||||
*/
|
||||
const AI_TEST_MAX_TOKENS = 32;
|
||||
|
||||
/**
|
||||
* Daily ceiling on connection tests per user.
|
||||
*
|
||||
* The 5/min bucket alone bounded burst rate but nothing cumulative: sustained,
|
||||
* it permits ~7 200 probes/day, and with an empty body this route resolves the
|
||||
* SAME chain generation uses — which can be the operator's own credential. That
|
||||
* is unmetered operator spend on a surface that exists to answer "are my
|
||||
* settings right?". A person checks that a handful of times after editing a
|
||||
* provider; 50 is far above real use and far below anything that matters on the
|
||||
* bill.
|
||||
*/
|
||||
const AI_TEST_DAILY_LIMIT = 50;
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -34,6 +60,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) return apiError("Too many test requests", 429);
|
||||
|
||||
// Cumulative ceiling on top of the burst bucket — see AI_TEST_DAILY_LIMIT.
|
||||
const daily = await checkRateLimit(
|
||||
`ai-test-daily:${user.id}`,
|
||||
AI_TEST_DAILY_LIMIT,
|
||||
ONE_DAY_MS,
|
||||
);
|
||||
if (!daily.allowed) {
|
||||
annotate({ action: { name: "ai.test.daily_limit" } });
|
||||
return apiError("Too many test requests today", 429);
|
||||
}
|
||||
|
||||
// Body is optional. Empty body → behaves like before (test the saved
|
||||
// config). Non-empty body → tests the unsaved selection without
|
||||
// mutating the user row. Plaintext keys never persist.
|
||||
@@ -65,19 +102,47 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("No AI provider configured", 422);
|
||||
}
|
||||
|
||||
// Meter the probe on the same daily ledger every other AI surface writes to,
|
||||
// so operator-key spend from this route is visible rather than invisible.
|
||||
// `admin-key` is the operator's own credential and draws the operator
|
||||
// ceiling; an override names the caller's own key, and every other resolved
|
||||
// type is the user's own egress, so those draw the user-plan ceiling.
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
user.id,
|
||||
AI_TEST_MAX_TOKENS,
|
||||
dateKey,
|
||||
resolveDailyCap([
|
||||
{
|
||||
providerType: provider.type === "admin-key" ? "admin-openai" : "local",
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
annotate({ action: { name: "ai.test.budget_exceeded" } });
|
||||
return apiError("Daily AI budget exhausted", 429);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await provider.generateCompletion(
|
||||
singleUserTurn({
|
||||
system: "You are a connection-test responder.",
|
||||
user: 'Reply with the JSON object {"ok": true} and nothing else.',
|
||||
temperature: 0,
|
||||
maxTokens: 32,
|
||||
maxTokens: AI_TEST_MAX_TOKENS,
|
||||
// The probe asks for a JSON object — keep the OpenAI / Codex strict
|
||||
// JSON mode it relied on before the response_format gate landed.
|
||||
responseFormat: "json",
|
||||
}),
|
||||
);
|
||||
|
||||
await reconcileSpend(
|
||||
user.id,
|
||||
reservation.reserved,
|
||||
result.tokensUsed ?? reservation.reserved,
|
||||
dateKey,
|
||||
);
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
providerType: result.providerType,
|
||||
@@ -91,6 +156,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Log full details server-side for the operator and respond with a
|
||||
// categorised, generic reason.
|
||||
const err = e as Error & { httpStatus?: number; bodyExcerpt?: string };
|
||||
// A failed probe produced no reported usage — refund the reservation so a
|
||||
// provider that is simply misconfigured doesn't burn the caller's ledger
|
||||
// while they fix it.
|
||||
await reconcileSpend(user.id, reservation.reserved, 0, dateKey);
|
||||
annotate({
|
||||
meta: {
|
||||
ai_test_error: err.message.slice(0, 500),
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* The auto-read opt-in endpoint, and specifically the catch-up it schedules.
|
||||
*
|
||||
* The summary job is enqueued at UPLOAD time and no-ops while the flag is OFF,
|
||||
* so without a catch-up on the flip the toggle only ever applied to future
|
||||
* uploads — a user who filled the vault first saw the switch do nothing. These
|
||||
* tests pin that a genuine OFF→ON transition schedules the pass, and that a
|
||||
* no-op re-save or a flip to OFF does not.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/consent/web-grant", () => ({
|
||||
ensureWebAiConsentReceipt: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/document-summary-catchup", () => ({
|
||||
enqueueSummaryCatchUp: vi.fn().mockResolvedValue({ enqueued: true }),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 59,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
|
||||
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPatch(documentsAutoAiRead: boolean): Request {
|
||||
return new Request("http://localhost/api/auth/me/documents-auto-ai-read", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ documentsAutoAiRead }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Seed the flag's value BEFORE the PATCH under test. */
|
||||
function withPrevious(documentsAutoAiRead: boolean) {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
documentsAutoAiRead,
|
||||
} as never);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", () => {
|
||||
it("schedules a catch-up when the opt-in flips OFF→ON", async () => {
|
||||
withPrevious(false);
|
||||
|
||||
const res = await PATCH(mkPatch(true));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(enqueueSummaryCatchUp).toHaveBeenCalledWith("user-1");
|
||||
expect(enqueueSummaryCatchUp).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not re-schedule when the opt-in was already ON", async () => {
|
||||
// Idempotency at the trigger: re-saving an already-ON setting must not
|
||||
// queue a second pass over the same documents.
|
||||
withPrevious(true);
|
||||
|
||||
await PATCH(mkPatch(true));
|
||||
|
||||
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not schedule anything when the opt-in is turned OFF", async () => {
|
||||
withPrevious(true);
|
||||
|
||||
await PATCH(mkPatch(false));
|
||||
|
||||
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
|
||||
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still mints the consent receipt on the flip that schedules the pass", async () => {
|
||||
// The catch-up rides the same act of consent, never around it. The mint
|
||||
// is marked `affirmative` because switching the toggle on IS the consent
|
||||
// act: unlike the settings-mount heal, it may lift an earlier
|
||||
// withdrawal, which is the user deciding again rather than the page
|
||||
// deciding for them.
|
||||
withPrevious(false);
|
||||
|
||||
await PATCH(mkPatch(true));
|
||||
|
||||
expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"affirmative",
|
||||
);
|
||||
});
|
||||
|
||||
it("persists the flag field-by-field", async () => {
|
||||
withPrevious(false);
|
||||
|
||||
await PATCH(mkPatch(true));
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { documentsAutoAiRead: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,10 @@
|
||||
* Flipping it ON is itself the standing consent act, so the write also mints an
|
||||
* append-only `ai_full` consent receipt (`ensureWebAiConsentReceipt`) — the
|
||||
* durable audit record that sits alongside the runtime short-circuit the
|
||||
* document consent gate reads. Mirrors `auth/me/labs-local-ocr`: 60/min rate
|
||||
* document consent gate reads. An OFF→ON flip additionally schedules a bounded
|
||||
* catch-up over the documents already stored (`enqueueSummaryCatchUp`), because
|
||||
* the summary job is enqueued at upload time and would otherwise never revisit
|
||||
* a vault filled before the opt-in. Mirrors `auth/me/labs-local-ocr`: 60/min rate
|
||||
* limit, Zod `safeParse` → 422 via `returnAllZodIssues`, audit-log row,
|
||||
* field-by-field write (no mass assignment). Idempotent — always returns the
|
||||
* resolved next state so the client can hard-set the optimistic update.
|
||||
@@ -31,6 +34,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
|
||||
const patchBodySchema = z.object({
|
||||
@@ -107,7 +111,18 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
// `ai_full` receipt (idempotent) so the durable audit trail records it, in
|
||||
// addition to the runtime short-circuit the document consent gate reads.
|
||||
if (next) {
|
||||
await ensureWebAiConsentReceipt(user.id);
|
||||
await ensureWebAiConsentReceipt(user.id, "affirmative");
|
||||
}
|
||||
|
||||
// A genuine OFF→ON flip schedules a catch-up over the documents already in
|
||||
// the vault. Without it the switch only ever applied to FUTURE uploads: the
|
||||
// summary job is enqueued at upload time and no-ops while the flag is OFF, so
|
||||
// a user who uploaded first and opted in later saw the toggle do nothing.
|
||||
// Fire-and-forget and bounded; the pass only enqueues, and every consent and
|
||||
// budget gate still runs per document inside the summary job itself.
|
||||
const wasEnabled = previous?.documentsAutoAiRead ?? false;
|
||||
if (next && !wasEnabled) {
|
||||
void enqueueSummaryCatchUp(user.id);
|
||||
}
|
||||
|
||||
await auditLog("user.documentsAutoAiRead.update", {
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// The handle is an HMAC, so the route needs the key the real helper reads.
|
||||
process.env.API_TOKEN_HMAC_KEY ??= "a".repeat(64);
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { session: { findMany: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
destroyOtherSessions: vi.fn(),
|
||||
destroySessionById: vi.fn(),
|
||||
}));
|
||||
// `sessionHandle` stays REAL: the point of the handle test is that the route
|
||||
// emits the actual derivation, and a stubbed one would let the route return
|
||||
// row ids while the assertion still passed.
|
||||
vi.mock("@/lib/auth/session", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/auth/session")>();
|
||||
return {
|
||||
getSession: vi.fn(),
|
||||
destroyOtherSessions: vi.fn(),
|
||||
destroySessionById: vi.fn(),
|
||||
sessionHandle: actual.sessionHandle,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/geo", () => ({
|
||||
lookupIpLocation: vi.fn(async () => "Berlin, DE"),
|
||||
@@ -39,6 +49,7 @@ import {
|
||||
getSession,
|
||||
destroyOtherSessions,
|
||||
destroySessionById,
|
||||
sessionHandle,
|
||||
} from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -90,7 +101,6 @@ describe("GET /api/auth/me/sessions", () => {
|
||||
const list = body.data.sessions;
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0]).toMatchObject({
|
||||
id: "sess-current",
|
||||
device: "Firefox on macOS",
|
||||
ipMasked: "203.0.x.x",
|
||||
location: "Berlin, DE",
|
||||
@@ -100,6 +110,42 @@ describe("GET /api/auth/me/sessions", () => {
|
||||
// The full IP never appears in the response body.
|
||||
expect(JSON.stringify(body)).not.toContain("203.0.113.7");
|
||||
});
|
||||
|
||||
it("never puts a session row id in the response body", async () => {
|
||||
// A row id created before the secret cookie landed IS that session's
|
||||
// cookie value, so listing it hands the client working logins for the
|
||||
// account's other devices. The list carries an opaque handle instead;
|
||||
// it is what the revoke route accepts.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.session.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "sess-current",
|
||||
ipAddress: "203.0.113.7",
|
||||
userAgent: "Mozilla/5.0 (Macintosh) Firefox/120.0",
|
||||
lastActiveAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "sess-other",
|
||||
ipAddress: "198.51.100.4",
|
||||
userAgent: "Mozilla/5.0 (Windows NT 10.0) Chrome/119.0.0.0",
|
||||
lastActiveAt: null,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
const serialised = JSON.stringify(body);
|
||||
|
||||
expect(serialised).not.toContain("sess-current");
|
||||
expect(serialised).not.toContain("sess-other");
|
||||
// Still identified well enough to act on: distinct, stable handles.
|
||||
const [a, b] = body.data.sessions.map((s: { id: string }) => s.id);
|
||||
expect(a).toBeTruthy();
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).toBe(sessionHandle("sess-current"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/auth/me/sessions (sign out everywhere)", () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getClientIp } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { destroyOtherSessions } from "@/lib/auth/session";
|
||||
import { destroyOtherSessions, sessionHandle } from "@/lib/auth/session";
|
||||
import { lookupIpLocation } from "@/lib/geo";
|
||||
import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint";
|
||||
|
||||
@@ -68,7 +68,7 @@ export const GET = apiHandler(async () => {
|
||||
);
|
||||
|
||||
const result: SessionDTO[] = sessions.map((s) => ({
|
||||
id: s.id,
|
||||
id: sessionHandle(s.id),
|
||||
device: coarseDeviceLabel(s.userAgent),
|
||||
ipMasked: maskIp(s.ipAddress),
|
||||
location: s.ipAddress ? (locationByIp.get(s.ipAddress) ?? null) : null,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* `GET /api/auth/passkeys` — the passkey management list.
|
||||
*
|
||||
* The management UI renders a "last used" column per credential, and a
|
||||
* passkey that reads "never used" is something a person acts on: they may
|
||||
* delete a working credential believing it is dead. So the field is part of
|
||||
* the route's contract, not an incidental select — dropping it from the
|
||||
* projection would silently turn every row into "never used" without any
|
||||
* type or lint failure.
|
||||
*
|
||||
* The write half (the stamp on a verified assertion) is pinned in
|
||||
* `src/lib/auth/__tests__/passkey.test.ts`.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
requireAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { passkey: { findMany: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { requireAuth } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const USER = { id: "user-1", username: "u", email: "u@example.com" };
|
||||
|
||||
const ROW = {
|
||||
id: "pk-1",
|
||||
name: "Phone",
|
||||
credentialDeviceType: "multiDevice",
|
||||
credentialBackedUp: true,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
lastUsedAt: new Date("2026-07-01T10:00:00Z"),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
|
||||
vi.mocked(prisma.passkey.findMany).mockResolvedValue([ROW] as never);
|
||||
});
|
||||
|
||||
describe("GET /api/auth/passkeys", () => {
|
||||
it("selects lastUsedAt, scoped to the calling user", async () => {
|
||||
await (GET as unknown as () => Promise<Response>)();
|
||||
|
||||
expect(prisma.passkey.findMany).toHaveBeenCalledTimes(1);
|
||||
const args = vi.mocked(prisma.passkey.findMany).mock.calls[0][0];
|
||||
|
||||
expect(args?.where).toEqual({ userId: USER.id });
|
||||
expect(args?.select).toMatchObject({ lastUsedAt: true });
|
||||
});
|
||||
|
||||
it("emits lastUsedAt in the response envelope", async () => {
|
||||
const res = await (GET as unknown as () => Promise<Response>)();
|
||||
const body = await res.json();
|
||||
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0]).toHaveProperty("lastUsedAt");
|
||||
expect(new Date(body.data[0].lastUsedAt).toISOString()).toBe(
|
||||
"2026-07-01T10:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes a never-used credential through as null, not as a missing key", async () => {
|
||||
vi.mocked(prisma.passkey.findMany).mockResolvedValue([
|
||||
{ ...ROW, lastUsedAt: null },
|
||||
] as never);
|
||||
|
||||
const res = await (GET as unknown as () => Promise<Response>)();
|
||||
const body = await res.json();
|
||||
|
||||
expect(Object.keys(body.data[0])).toContain("lastUsedAt");
|
||||
expect(body.data[0].lastUsedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -90,7 +90,10 @@ describe("POST /api/consent/ai/web", () => {
|
||||
|
||||
it("is a no-op when an active receipt already exists (minted:false)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({ minted: false });
|
||||
vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({
|
||||
minted: false,
|
||||
reason: "already_active",
|
||||
});
|
||||
|
||||
const res = await POST();
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -37,7 +37,7 @@ export const POST = apiHandler(async () => {
|
||||
});
|
||||
}
|
||||
|
||||
const result = await ensureWebAiConsentReceipt(user.id);
|
||||
const result = await ensureWebAiConsentReceipt(user.id, "heal");
|
||||
|
||||
if (result.minted) {
|
||||
// Audit trail parity with the iOS-driven POST /api/consent/ai grant so
|
||||
|
||||
@@ -63,6 +63,8 @@ const DIGEST: DailyDigest = {
|
||||
briefingLead: "Blood pressure is holding steady.",
|
||||
line: "Blood pressure is holding steady.",
|
||||
worthALook: [],
|
||||
justIn: null,
|
||||
reactionLine: null,
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
|
||||
@@ -87,6 +87,8 @@ const ALL_KINDS = [
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"oxygenSaturation",
|
||||
"mood",
|
||||
"bmi",
|
||||
] as const;
|
||||
|
||||
function card(kind: string) {
|
||||
@@ -194,11 +196,28 @@ describe("GET /api/dashboard/summary — module gate", () => {
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"oxygenSaturation",
|
||||
// BMI is derived from weight + the profile height and belongs to no
|
||||
// module, so it rides through with the other core vitals.
|
||||
"bmi",
|
||||
]) {
|
||||
expect(kinds).toContain(core);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops the mood card when the mood module is off", async () => {
|
||||
// Mood is not a MeasurementType, so it gates through the synthetic
|
||||
// `MOOD_ENTRY` key rather than a measurement enum value. Without that
|
||||
// join the card would be unmapped and silently ship past a disabled
|
||||
// module.
|
||||
setDisabledModules(["mood"]);
|
||||
const kinds = await emittedKinds();
|
||||
|
||||
expect(kinds).not.toContain("mood");
|
||||
expect(kinds).toContain("weight");
|
||||
expect(kinds).toContain("bmi");
|
||||
expect(kinds).toHaveLength(ALL_KINDS.length - 1);
|
||||
});
|
||||
|
||||
it("gates the cached body, so a toggle takes effect on the next request", async () => {
|
||||
// The analytics LRU is not evicted by a module toggle. The filter must
|
||||
// therefore run on the way out, not inside the cached build.
|
||||
|
||||
@@ -68,6 +68,19 @@ vi.mock("@/lib/rollups/measurement-read", () => ({
|
||||
loadUserSourcePriority: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
// The mood card reads the canonical daily series; its own aggregation is
|
||||
// covered by `src/lib/analytics/__tests__/mood-series.test.ts`. Pinned to an
|
||||
// empty series by default so the legacy assertions keep their "no mood"
|
||||
// expectation, and overridden per test below.
|
||||
vi.mock("@/lib/analytics/mood-series", () => ({
|
||||
buildMoodDailySeries: vi.fn(async () => ({
|
||||
entries: [],
|
||||
summary: null,
|
||||
entryCount: 0,
|
||||
source: "live" as const,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
@@ -85,6 +98,7 @@ import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -1065,3 +1079,96 @@ describe("GET /api/dashboard/summary", () => {
|
||||
expect(body.data.compliance.takenToday).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Mood + BMI cards.
|
||||
*
|
||||
* Both metrics existed in the record but the summary payload emitted
|
||||
* neither, so the native home screen could not render those two tiles while
|
||||
* every other tile worked. Both are resolved server-side from the canonical
|
||||
* source — `buildMoodDailySeries` for mood, `computeBmi` for BMI — so the
|
||||
* client consumes a finished figure instead of re-deriving one.
|
||||
*/
|
||||
describe("GET /api/dashboard/summary — mood + BMI cards", () => {
|
||||
function cardsOf(body: unknown): Array<Record<string, unknown>> {
|
||||
return (body as { data: { metrics: Array<Record<string, unknown>> } }).data
|
||||
.metrics;
|
||||
}
|
||||
|
||||
it("emits a mood card from the canonical daily series", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(buildMoodDailySeries).mockResolvedValue({
|
||||
entries: [
|
||||
{ date: "2026-07-01", score: 2, samples: 1 },
|
||||
{ date: "2026-07-02", score: 3, samples: 2 },
|
||||
{ date: "2026-07-03", score: 4.5, samples: 1 },
|
||||
],
|
||||
summary: null,
|
||||
entryCount: 3,
|
||||
source: "rollup",
|
||||
} as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const mood = cardsOf(await res.json()).find((c) => c.kind === "mood");
|
||||
|
||||
expect(mood).toBeDefined();
|
||||
// Headline is the newest day's score, verbatim from the series — the
|
||||
// route must not re-average or re-round what the helper resolved.
|
||||
expect(mood?.latestValue).toBe(4.5);
|
||||
expect(mood?.sparkline).toEqual([2, 3, 4.5]);
|
||||
expect(mood?.trend).toBe("up");
|
||||
expect(mood?.allTimeCount).toBe(3);
|
||||
expect(mood?.titleKey).toBe("dashboard.metric.title.mood");
|
||||
expect(mood?.unitKey).toBe("dashboard.metric.unit.mood");
|
||||
expect(mood?.lastSeenAt).toBe("2026-07-03T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("omits the mood card when the account has never logged a mood", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
expect(cardsOf(await res.json()).find((c) => c.kind === "mood")).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits a BMI card computed from the profile height and latest weight", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// 180 cm profile.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: new Date("1986-01-01"),
|
||||
gender: null,
|
||||
heightCm: 180,
|
||||
} as never);
|
||||
// `computeBmi` reads recent weight rows through `measurement.findMany`.
|
||||
// 81 kg at 1.80 m => 25.0.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ value: 81, measuredAt: new Date("2026-07-03T08:00:00.000Z") },
|
||||
] as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const bmi = cardsOf(await res.json()).find((c) => c.kind === "bmi");
|
||||
|
||||
expect(bmi).toBeDefined();
|
||||
// The canonical helper's rounding (1 dp), not a second formula.
|
||||
expect(bmi?.latestValue).toBe(25);
|
||||
expect(bmi?.titleKey).toBe("dashboard.metric.title.bmi");
|
||||
expect(bmi?.unitKey).toBe("dashboard.metric.unit.bmi");
|
||||
});
|
||||
|
||||
it("omits the BMI card when the profile carries no height", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
// Default profile has `heightCm: null`; weight alone cannot yield a BMI.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ value: 81, measuredAt: new Date("2026-07-03T08:00:00.000Z") },
|
||||
] as never);
|
||||
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
expect(cardsOf(await res.json()).find((c) => c.kind === "bmi")).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,9 @@ import {
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import type { SleepStage } from "@/generated/prisma/client";
|
||||
import { loadBaselineProfile } from "@/lib/insights/derived/baseline";
|
||||
import { computeBmi } from "@/lib/insights/derived/bmi";
|
||||
import { isDerivedOk } from "@/lib/insights/derived/types";
|
||||
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
|
||||
import {
|
||||
reconstructNights,
|
||||
sleepNeedMinutes,
|
||||
@@ -96,7 +99,9 @@ type MetricKind =
|
||||
| "steps"
|
||||
| "totalBodyWater"
|
||||
| "boneMass"
|
||||
| "oxygenSaturation";
|
||||
| "oxygenSaturation"
|
||||
| "mood"
|
||||
| "bmi";
|
||||
|
||||
interface MetricCard {
|
||||
id: string;
|
||||
@@ -196,6 +201,8 @@ const METRIC_TITLE_KEYS: Record<MetricKind, string> = {
|
||||
totalBodyWater: "dashboard.metric.title.totalBodyWater",
|
||||
boneMass: "dashboard.metric.title.boneMass",
|
||||
oxygenSaturation: "dashboard.metric.title.oxygenSaturation",
|
||||
mood: "dashboard.metric.title.mood",
|
||||
bmi: "dashboard.metric.title.bmi",
|
||||
};
|
||||
|
||||
const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
|
||||
@@ -209,6 +216,8 @@ const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
|
||||
totalBodyWater: "dashboard.metric.unit.totalBodyWater",
|
||||
boneMass: "dashboard.metric.unit.boneMass",
|
||||
oxygenSaturation: "dashboard.metric.unit.oxygenSaturation",
|
||||
mood: "dashboard.metric.unit.mood",
|
||||
bmi: "dashboard.metric.unit.bmi",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -615,11 +624,24 @@ async function buildDashboardSummary(
|
||||
// before the per-night reconstruction sums it. v1.17.0 — the baseline
|
||||
// profile rides along (one read) so the sleep-rhythm block resolves the
|
||||
// age-based sleep need the cumulative-debt deficit uses.
|
||||
const [sleepPriorityJson, sleepProfile] = await Promise.all([
|
||||
const [sleepPriorityJson, sleepProfile, moodSeries] = await Promise.all([
|
||||
loadUserSourcePriority(userId),
|
||||
loadBaselineProfile(prisma, userId),
|
||||
// The mood card reads the SAME canonical daily series the web mood
|
||||
// analytics and the dashboard snapshot read (`buildMoodDailySeries`),
|
||||
// rollup fast-path included — never a second aggregation.
|
||||
time("moodSeries", () => buildMoodDailySeries(userId, prisma)),
|
||||
]);
|
||||
|
||||
// BMI is derived, not measured: it needs the profile height loaded above,
|
||||
// so it cannot join the round before it. `computeBmi` is the one canonical
|
||||
// implementation (the same one the web vitals dashboard renders through
|
||||
// the derived-metric dispatch) — the route resolves the figure and the
|
||||
// client consumes it, rather than every client re-deriving weight / m².
|
||||
const bmiDerived = await time("bmi", () =>
|
||||
computeBmi(userId, sleepProfile, { now }),
|
||||
);
|
||||
|
||||
// Per-type metadata lookup — typed Map so a metric with no readings
|
||||
// at all falls through `metaForType` to the `{ allTimeCount: 0,
|
||||
// lastSeenAt: null }` default. The aggregate row's `_count._all` is
|
||||
@@ -954,6 +976,69 @@ async function buildDashboardSummary(
|
||||
});
|
||||
}
|
||||
|
||||
// Mood — the one metric on this payload that is not a `Measurement`. The
|
||||
// daily series is the canonical `buildMoodDailySeries` output (rollup
|
||||
// fast-path, 1..5 scale, one entry per day with an average score), the
|
||||
// same series the web mood analytics and the dashboard snapshot render.
|
||||
// Emitted only when the account has ever logged a mood, matching the
|
||||
// `allTimeCount === 0` gate every optional card above uses.
|
||||
if (moodSeries.entryCount > 0) {
|
||||
const spark = moodSeries.entries.slice(-SPARK_DAYS).map((e) => e.score);
|
||||
const latestEntry = moodSeries.entries[moodSeries.entries.length - 1];
|
||||
// The series carries UTC day labels, not timestamps — the entry's day is
|
||||
// the only instant the source actually pins, so it is emitted as the
|
||||
// day's start rather than inventing a reading time.
|
||||
const latestAt = latestEntry
|
||||
? new Date(`${latestEntry.date}T00:00:00.000Z`).toISOString()
|
||||
: null;
|
||||
metrics.push({
|
||||
id: "mood",
|
||||
kind: "mood",
|
||||
titleKey: METRIC_TITLE_KEYS.mood,
|
||||
latestValue: latestEntry?.score ?? null,
|
||||
secondaryValue: null,
|
||||
unitKey: METRIC_UNIT_KEYS.mood,
|
||||
unit: null,
|
||||
sleepStages: null,
|
||||
sleepSourceDiscrepancy: null,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latestAt,
|
||||
allTimeCount: moodSeries.entryCount,
|
||||
lastSeenAt: latestAt,
|
||||
});
|
||||
}
|
||||
|
||||
// BMI — derived from the latest weight at the profile height by
|
||||
// `computeBmi`, the single implementation the web vitals dashboard also
|
||||
// renders. The card is omitted when the derivation is insufficient
|
||||
// (no height on the profile, or no weight inside its window): the card
|
||||
// shape has no "unknown" state to express honestly, and a card with a
|
||||
// null value would read as a logging gap rather than a missing profile
|
||||
// field. Clients that want to prompt for a height should do so from the
|
||||
// profile surface, not from an empty dashboard tile.
|
||||
if (isDerivedOk(bmiDerived)) {
|
||||
const spark = bmiDerived.value.series.slice(-SPARK_DAYS);
|
||||
metrics.push({
|
||||
id: "bmi",
|
||||
kind: "bmi",
|
||||
titleKey: METRIC_TITLE_KEYS.bmi,
|
||||
latestValue: bmiDerived.value.bmi,
|
||||
secondaryValue: null,
|
||||
unitKey: METRIC_UNIT_KEYS.bmi,
|
||||
unit: null,
|
||||
sleepStages: null,
|
||||
sleepSourceDiscrepancy: null,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
// BMI moves exactly when weight does, so the weight metadata is the
|
||||
// honest recency signal — there is no separate BMI reading.
|
||||
updatedAt: metaForType("WEIGHT").lastSeenAt,
|
||||
allTimeCount: metaForType("WEIGHT").allTimeCount,
|
||||
lastSeenAt: metaForType("WEIGHT").lastSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Shared builder carries the exact tally semantics this tile always
|
||||
// had (scheduled = every today-window row; taken = `takenAt` set and
|
||||
// not skipped). The wire shape of `compliance` is locked for iOS.
|
||||
|
||||
@@ -517,3 +517,122 @@ describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard widgets — preserve-when-absent on PUT", () => {
|
||||
it("keeps the stored comparisonBaseline when the client omits it", async () => {
|
||||
// The regression: a layout save from a client that doesn't know
|
||||
// `comparisonBaseline` (the native client documents the field as
|
||||
// web-only and never sends it) used to fall through to the serializer,
|
||||
// which clamps a missing baseline to "none". The user's web-chosen
|
||||
// comparison silently reset on every tile reorder from the phone.
|
||||
const stored: DashboardLayout = serializeDashboardLayout({
|
||||
version: 1,
|
||||
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
|
||||
comparisonBaseline: "lastYear",
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dashboardWidgetsJson: stored,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callPut(
|
||||
makeReq({
|
||||
version: 1,
|
||||
widgets: [
|
||||
{ id: "weight", visible: false, tileVisible: true, order: 0 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const updateArg = vi.mocked(prisma.user.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: { dashboardWidgetsJson: { comparisonBaseline: string } };
|
||||
};
|
||||
expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe(
|
||||
"lastYear",
|
||||
);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { comparisonBaseline: string };
|
||||
};
|
||||
expect(body.data.comparisonBaseline).toBe("lastYear");
|
||||
});
|
||||
|
||||
it("honours an explicitly sent comparisonBaseline over the stored one", async () => {
|
||||
// Preserve-when-absent must not become preserve-always: the web
|
||||
// CompareToggle sends the field, including an explicit "none" to clear.
|
||||
const stored: DashboardLayout = serializeDashboardLayout({
|
||||
version: 1,
|
||||
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
|
||||
comparisonBaseline: "lastYear",
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dashboardWidgetsJson: stored,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await callPut(
|
||||
makeReq({
|
||||
version: 1,
|
||||
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
|
||||
comparisonBaseline: "none",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const updateArg = vi.mocked(prisma.user.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: { dashboardWidgetsJson: { comparisonBaseline: string } };
|
||||
};
|
||||
expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe("none");
|
||||
});
|
||||
|
||||
it("persists the four clinical tiles the native client pins", async () => {
|
||||
// They used to hit the unknown-id filter ahead of Zod and vanish from
|
||||
// the persisted layout, so the placement was lost on every save.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dashboardWidgetsJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const clinicalIds = [
|
||||
"gripStrength",
|
||||
"painNRS",
|
||||
"waistCircumference",
|
||||
"waistToHeight",
|
||||
];
|
||||
const res = await callPut(
|
||||
makeReq({
|
||||
version: 1,
|
||||
widgets: clinicalIds.map((id, i) => ({
|
||||
id,
|
||||
visible: true,
|
||||
tileVisible: true,
|
||||
order: i,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const dropAnnotate = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(c) =>
|
||||
(c[0] as { action?: { name?: string } }).action?.name ===
|
||||
"dashboard.widgets.unknown-id-dropped",
|
||||
);
|
||||
expect(dropAnnotate).toBeUndefined();
|
||||
|
||||
const updateArg = vi.mocked(prisma.user.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: { dashboardWidgetsJson: { widgets: Array<{ id: string }> } };
|
||||
};
|
||||
const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map(
|
||||
(w) => w.id,
|
||||
);
|
||||
for (const id of clinicalIds) {
|
||||
expect(persistedIds).toContain(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -26,10 +26,9 @@ import {
|
||||
MAX_SELECTED_SCORE_RINGS,
|
||||
HERO_RING_IDS,
|
||||
MAX_HERO_RING_ORDER,
|
||||
type ChartOverlayPrefsMap,
|
||||
layoutNeedsPreserveRead,
|
||||
mergePreservedLayoutFields,
|
||||
type DashboardLayout,
|
||||
type ScoreRingId,
|
||||
type HeroRingId,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
@@ -80,7 +79,12 @@ const layoutSchema = z.object({
|
||||
// 11 iOS-only = 35) fits with headroom after the v1.10 additive
|
||||
// HealthKit signals became pinnable; the enum still bounds each id to
|
||||
// one of the catalogue ids.
|
||||
.max(40),
|
||||
//
|
||||
// Raised again to 50 when the four clinical signals joined the
|
||||
// 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),
|
||||
// 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.
|
||||
@@ -101,13 +105,11 @@ const layoutSchema = z.object({
|
||||
// Partial-record matches the original intent — overlay prefs are
|
||||
// per-chart opt-in, the resolver fills in defaults for missing keys.
|
||||
//
|
||||
// The inner object also documents `comparisonBaseline` so the
|
||||
// per-chart `<ChartOverlayControls>` popover (which calls
|
||||
// The inner object also documents `comparisonBaseline` and `rangePoints`
|
||||
// so the per-chart `<ChartOverlayControls>` popover (which calls
|
||||
// `/api/dashboard/chart-overlay-prefs`) and a full-layout PUT from
|
||||
// Settings → Dashboard can both round-trip the field. Without it Zod
|
||||
// would silently strip the per-chart `comparisonBaseline` on every
|
||||
// Save click in Settings, wiping any per-chart toggle the user had
|
||||
// set via the chart-card popover.
|
||||
// Settings → Dashboard can round-trip both fields. Without them Zod would
|
||||
// silently strip either per-chart choice on every Save click.
|
||||
chartOverlayPrefs: z
|
||||
.partialRecord(
|
||||
z.enum(CHART_OVERLAY_KEYS),
|
||||
@@ -118,6 +120,9 @@ const layoutSchema = z.object({
|
||||
comparisonBaseline: z
|
||||
.enum(["none", "lastMonth", "lastYear"])
|
||||
.optional(),
|
||||
rangePoints: z
|
||||
.union([z.literal(0), z.literal(7), z.literal(30), z.literal(90)])
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
@@ -274,24 +279,36 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// v1.4.18 — preserve any per-chart overlay prefs that the client
|
||||
// didn't send. The dashboard-layout PUT typically saves widget
|
||||
// visibility / order; chart prefs are PUT through their own route
|
||||
// (`/api/dashboard/chart-overlay-prefs`) and would otherwise be
|
||||
// wiped here on a subsequent layout save. `selectedScoreRings` and
|
||||
// `heroRingOrder` ride the same preserve-when-absent contract — an
|
||||
// older client's layout save must not reset either choice. One
|
||||
// stored-layout read covers all fallbacks.
|
||||
let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data
|
||||
.chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
|
||||
let mergedScoreRings: ScoreRingId[] | undefined =
|
||||
parsed.data.selectedScoreRings;
|
||||
let mergedHeroRingOrder: HeroRingId[] | undefined = parsed.data.heroRingOrder;
|
||||
if (
|
||||
mergedChartOverlayPrefs === undefined ||
|
||||
mergedScoreRings === undefined ||
|
||||
mergedHeroRingOrder === undefined
|
||||
) {
|
||||
// Preserve-when-absent: every field the client didn't send is carried
|
||||
// forward from the stored layout rather than clamped to its default by
|
||||
// the serializer. The dashboard-layout PUT typically saves widget
|
||||
// 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.
|
||||
//
|
||||
// The preserve set is not a list maintained here: it is derived from
|
||||
// `LAYOUT_FIELD_MERGE_DISPOSITION`, which the type system forces to cover
|
||||
// every field of `DashboardLayout`. Adding a layout field without a
|
||||
// disposition fails typecheck, so the next field cannot silently miss this
|
||||
// merge the way `comparisonBaseline` did. One stored-layout read covers
|
||||
// every fallback, and the read is skipped only when the client sent every
|
||||
// preserved top-level field and every supplied chart pref has rangePoints.
|
||||
// The Zod shape leaves the per-chart `comparisonBaseline` optional while
|
||||
// `ChartOverlayPrefs` requires it; `coerceChartOverlayPrefsMap` inside the
|
||||
// serializer fills the gap, so the assertion is safe here exactly as it was
|
||||
// on the previous per-field cast.
|
||||
let toSerialize = parsed.data as Partial<DashboardLayout>;
|
||||
const incomingChartOverlayPrefs = toSerialize.chartOverlayPrefs;
|
||||
const needsRangePointsPreserve =
|
||||
incomingChartOverlayPrefs !== undefined &&
|
||||
CHART_OVERLAY_KEYS.some((chartKey) => {
|
||||
const prefs = incomingChartOverlayPrefs[chartKey];
|
||||
return prefs !== undefined && prefs.rangePoints === undefined;
|
||||
});
|
||||
const needsPreserveRead =
|
||||
layoutNeedsPreserveRead(toSerialize) || needsRangePointsPreserve;
|
||||
if (needsPreserveRead) {
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
@@ -299,22 +316,35 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const existingLayout = resolveDashboardLayout(
|
||||
existing?.dashboardWidgetsJson,
|
||||
);
|
||||
if (mergedChartOverlayPrefs === undefined) {
|
||||
mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {};
|
||||
}
|
||||
if (mergedScoreRings === undefined) {
|
||||
mergedScoreRings = existingLayout.selectedScoreRings;
|
||||
}
|
||||
if (mergedHeroRingOrder === undefined) {
|
||||
mergedHeroRingOrder = existingLayout.heroRingOrder;
|
||||
toSerialize = mergePreservedLayoutFields(toSerialize, existingLayout);
|
||||
|
||||
if (needsRangePointsPreserve && toSerialize.chartOverlayPrefs) {
|
||||
let mergedChartOverlayPrefs = toSerialize.chartOverlayPrefs;
|
||||
for (const chartKey of CHART_OVERLAY_KEYS) {
|
||||
const incomingPrefs = toSerialize.chartOverlayPrefs[chartKey];
|
||||
const storedRangePoints =
|
||||
existingLayout.chartOverlayPrefs?.[chartKey]?.rangePoints;
|
||||
if (
|
||||
incomingPrefs !== undefined &&
|
||||
incomingPrefs.rangePoints === undefined &&
|
||||
storedRangePoints !== undefined
|
||||
) {
|
||||
if (mergedChartOverlayPrefs === toSerialize.chartOverlayPrefs) {
|
||||
mergedChartOverlayPrefs = { ...toSerialize.chartOverlayPrefs };
|
||||
}
|
||||
mergedChartOverlayPrefs[chartKey] = {
|
||||
...incomingPrefs,
|
||||
rangePoints: storedRangePoints,
|
||||
};
|
||||
}
|
||||
}
|
||||
toSerialize = {
|
||||
...toSerialize,
|
||||
chartOverlayPrefs: mergedChartOverlayPrefs,
|
||||
};
|
||||
}
|
||||
}
|
||||
const normalized = serializeDashboardLayout({
|
||||
...parsed.data,
|
||||
chartOverlayPrefs: mergedChartOverlayPrefs,
|
||||
selectedScoreRings: mergedScoreRings,
|
||||
heroRingOrder: mergedHeroRingOrder,
|
||||
} as DashboardLayout);
|
||||
const normalized = serializeDashboardLayout(toSerialize as DashboardLayout);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
|
||||
@@ -8,7 +8,13 @@ vi.mock("@/lib/db", () => ({
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
refreshToken: {
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
$queryRaw: vi.fn(),
|
||||
$transaction: vi.fn(),
|
||||
notificationChannel: {
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
@@ -76,6 +82,12 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.device.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.device.create).mockResolvedValue({ id: "dev-1" } as never);
|
||||
vi.mocked(prisma.device.update).mockResolvedValue({ id: "dev-1" } as never);
|
||||
vi.mocked(prisma.device.deleteMany).mockResolvedValue({ count: 0 });
|
||||
vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 0 });
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.$transaction).mockImplementation(async (callback) =>
|
||||
callback(prisma as never),
|
||||
);
|
||||
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({
|
||||
id: "ch-1",
|
||||
} as never);
|
||||
@@ -228,27 +240,76 @@ describe("POST /api/devices", () => {
|
||||
expect(prisma.device.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls through idempotently when the apnsToken already belongs to the same user", async () => {
|
||||
// v1.4.23 W6 reconcile (HIGH 3): the cross-user-hijack guard used
|
||||
// to filter `NOT: { userId }` so it skipped same-user collisions
|
||||
// entirely, letting `findMany({ apnsToken })` fan a single push
|
||||
// out to two Device rows. The guard now reads `userId` and only
|
||||
// rejects cross-user collisions; same-user collisions fall
|
||||
// through to the legacy-token upsert path.
|
||||
it("reconciles a changed legacy token onto the same user's APNs device", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.device.findFirst).mockResolvedValue({
|
||||
id: "self-dev",
|
||||
userId: "user-1",
|
||||
token: "original-legacy-token",
|
||||
} as never);
|
||||
const res = await POST(
|
||||
req({
|
||||
token: "abcd1234efgh5678",
|
||||
token: "changed-legacy-token",
|
||||
bundleId: "io.healthlog.app",
|
||||
apnsToken: "deadbeef".repeat(8),
|
||||
apnsEnvironment: "sandbox",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.device.create).not.toHaveBeenCalled();
|
||||
expect(prisma.device.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "self-dev", userId: "user-1" },
|
||||
data: expect.objectContaining({ token: "changed-legacy-token" }),
|
||||
}),
|
||||
);
|
||||
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", deviceId: "original-legacy-token" },
|
||||
data: { deviceId: "changed-legacy-token" },
|
||||
});
|
||||
});
|
||||
|
||||
it("merges two same-user identities and migrates owned refresh tokens", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.device.findUnique).mockResolvedValue({
|
||||
id: "legacy-dev",
|
||||
userId: "user-1",
|
||||
token: "changed-legacy-token",
|
||||
medicationDelivery: "client",
|
||||
liveActivityPushToken: "cafebabe".repeat(8),
|
||||
} as never);
|
||||
vi.mocked(prisma.device.findFirst).mockResolvedValue({
|
||||
id: "apns-dev",
|
||||
userId: "user-1",
|
||||
token: "original-legacy-token",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
req({
|
||||
token: "changed-legacy-token",
|
||||
bundleId: "io.healthlog.app",
|
||||
apnsToken: "deadbeef".repeat(8),
|
||||
apnsEnvironment: "production",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", deviceId: "original-legacy-token" },
|
||||
data: { deviceId: "changed-legacy-token" },
|
||||
});
|
||||
expect(prisma.device.deleteMany).toHaveBeenCalledWith({
|
||||
where: { id: "legacy-dev", userId: "user-1" },
|
||||
});
|
||||
expect(prisma.device.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "apns-dev", userId: "user-1" },
|
||||
data: expect.objectContaining({
|
||||
medicationDelivery: "client",
|
||||
liveActivityPushToken: "cafebabe".repeat(8),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// v1.17.1 (#22) — Live Activity push-token registration.
|
||||
|
||||
+143
-113
@@ -3,8 +3,8 @@
|
||||
*
|
||||
* Native-client device registration. The iOS app calls this on login,
|
||||
* on APNs token rotation, and whenever a fresh `apnsToken` arrives from
|
||||
* `application:didRegisterForRemoteNotificationsWithDeviceToken:`. We
|
||||
* upsert by `token` (the legacy generic identifier).
|
||||
* `application:didRegisterForRemoteNotificationsWithDeviceToken:`. Both the
|
||||
* legacy `token` and `apnsToken` resolve to one canonical device.
|
||||
*
|
||||
* Cross-user-hijack guard:
|
||||
* * A device `token` belongs to exactly one user. Re-registering the
|
||||
@@ -162,121 +162,151 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
liveActivityPushToken,
|
||||
} = parsed.data;
|
||||
|
||||
// APNs-token collision lookup. Two cases:
|
||||
// * A row for ANOTHER user owns this token → 409 hijack guard.
|
||||
// APNs tokens aren't secrets, so accepting wire input that
|
||||
// references another user's token would let anyone who learns
|
||||
// one redirect that user's pushes.
|
||||
// * A row for THIS user already owns it → idempotent fall-through.
|
||||
// The upsert below either updates the matching legacy `token`
|
||||
// row in place, or creates a fresh legacy `token` row that the
|
||||
// migration-0041 partial unique index now blocks (the upsert
|
||||
// branch will hit the unique constraint and the request will
|
||||
// fail with a 500 — but that's a programmer-error path the
|
||||
// iOS client should never trigger; same-user re-registration
|
||||
// keeps the same legacy `token`).
|
||||
// The `NOT: { userId }` clause was removed in v1.4.23 W6 reconcile —
|
||||
// it skipped the same-user collision and let `findMany({ apnsToken })`
|
||||
// fan a single push out to two Device rows, double-charging the APNs
|
||||
// quota.
|
||||
if (apnsToken) {
|
||||
const existingApns = await prisma.device.findFirst({
|
||||
where: { apnsToken },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
if (existingApns && existingApns.userId !== user.id) {
|
||||
await auditLog("device.register.denied", {
|
||||
userId: user.id,
|
||||
details: {
|
||||
reason: "apns_token_owned_by_other_user",
|
||||
deviceId: existingApns.id,
|
||||
const registration = await prisma.$transaction(async (tx) => {
|
||||
// Token ownership is decided from one database snapshot. Advisory
|
||||
// transaction locks serialize registrations that share either identity,
|
||||
// including cross-user attempts and the first-create race. Sorting avoids
|
||||
// deadlocks when concurrent requests present the same pair in reverse.
|
||||
const lockKeys = [
|
||||
`device-token:${token}`,
|
||||
...(apnsToken ? [`device-apns:${apnsToken}`] : []),
|
||||
].sort();
|
||||
for (const lockKey of lockKeys) {
|
||||
// `pg_advisory_xact_lock` returns void, which Prisma cannot deserialize
|
||||
// directly. Selecting through FROM yields a plain integer row.
|
||||
await tx.$queryRaw`
|
||||
SELECT 1 AS locked
|
||||
FROM pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))
|
||||
`;
|
||||
}
|
||||
|
||||
const [legacyDevice, apnsDevice] = await Promise.all([
|
||||
tx.device.findUnique({
|
||||
where: { token },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
token: true,
|
||||
medicationDelivery: true,
|
||||
liveActivityPushToken: true,
|
||||
},
|
||||
});
|
||||
return apiError("APNs token already registered to another account", 409);
|
||||
}),
|
||||
apnsToken
|
||||
? tx.device.findFirst({
|
||||
where: { apnsToken },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
token: true,
|
||||
medicationDelivery: true,
|
||||
liveActivityPushToken: true,
|
||||
},
|
||||
})
|
||||
: null,
|
||||
]);
|
||||
|
||||
if (apnsDevice && apnsDevice.userId !== user.id) {
|
||||
return {
|
||||
conflict: "apns_token_owned_by_other_user" as const,
|
||||
deviceId: apnsDevice.id,
|
||||
};
|
||||
}
|
||||
if (legacyDevice && legacyDevice.userId !== user.id) {
|
||||
return {
|
||||
conflict: "token_owned_by_other_user" as const,
|
||||
deviceId: legacyDevice.id,
|
||||
};
|
||||
}
|
||||
|
||||
// RefreshToken.deviceId stores the client-supplied Device.token value,
|
||||
// not the database row id. Keep this user's token family aligned when
|
||||
// APNs identifies an existing device under a changed legacy token.
|
||||
if (apnsDevice && apnsDevice.token !== token) {
|
||||
await tx.refreshToken.updateMany({
|
||||
where: { userId: user.id, deviceId: apnsDevice.token },
|
||||
data: { deviceId: token },
|
||||
});
|
||||
}
|
||||
|
||||
// An APNs match is the canonical physical-device identity. If the newly
|
||||
// presented legacy token already has a second same-user row, carry its
|
||||
// explicit per-device state (including null clears), remove it, and update
|
||||
// the APNs row with the newly presented metadata.
|
||||
let canonicalDevice = apnsDevice ?? legacyDevice;
|
||||
let duplicateDevice: typeof legacyDevice = null;
|
||||
if (apnsDevice && legacyDevice && apnsDevice.id !== legacyDevice.id) {
|
||||
duplicateDevice = legacyDevice;
|
||||
await tx.device.deleteMany({
|
||||
where: { id: legacyDevice.id, userId: user.id },
|
||||
});
|
||||
canonicalDevice = apnsDevice;
|
||||
}
|
||||
|
||||
const deviceData = {
|
||||
platform: "ios",
|
||||
token,
|
||||
bundleId,
|
||||
locale: locale ?? null,
|
||||
appVersion: appVersion ?? null,
|
||||
model: model ?? null,
|
||||
apnsToken: apnsToken ?? null,
|
||||
apnsEnvironment: apnsEnvironment ?? null,
|
||||
...(medicationDelivery !== undefined
|
||||
? { medicationDelivery }
|
||||
: duplicateDevice
|
||||
? { medicationDelivery: duplicateDevice.medicationDelivery }
|
||||
: {}),
|
||||
...(liveActivityPushToken !== undefined
|
||||
? { liveActivityPushToken }
|
||||
: duplicateDevice
|
||||
? { liveActivityPushToken: duplicateDevice.liveActivityPushToken }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const device = canonicalDevice
|
||||
? await tx.device.update({
|
||||
where: { id: canonicalDevice.id, userId: user.id },
|
||||
data: { ...deviceData, lastSeen: new Date() },
|
||||
select: { id: true },
|
||||
})
|
||||
: await tx.device.create({
|
||||
data: { userId: user.id, ...deviceData },
|
||||
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 };
|
||||
});
|
||||
|
||||
if ("conflict" in registration) {
|
||||
await auditLog("device.register.denied", {
|
||||
userId: user.id,
|
||||
details: {
|
||||
reason: registration.conflict,
|
||||
deviceId: registration.deviceId,
|
||||
},
|
||||
});
|
||||
return registration.conflict === "apns_token_owned_by_other_user"
|
||||
? apiError("APNs token already registered to another account", 409)
|
||||
: apiError("Device token already registered to another account", 409);
|
||||
}
|
||||
|
||||
const existing = await prisma.device.findUnique({ where: { token } });
|
||||
let id: string;
|
||||
if (existing) {
|
||||
// Cross-user-hijack guard: a device-token belongs to exactly one user.
|
||||
// Re-registering the same token under a different account is rejected
|
||||
// — APNs tokens aren't a secret, so trusting the wire input would let
|
||||
// anyone who learns/guesses a token redirect another user's pushes.
|
||||
if (existing.userId !== user.id) {
|
||||
await auditLog("device.register.denied", {
|
||||
userId: user.id,
|
||||
details: { reason: "token_owned_by_other_user", deviceId: existing.id },
|
||||
});
|
||||
return apiError(
|
||||
"Device token already registered to another account",
|
||||
409,
|
||||
);
|
||||
}
|
||||
const updated = await prisma.device.update({
|
||||
where: { token },
|
||||
data: {
|
||||
platform: "ios",
|
||||
bundleId,
|
||||
locale: locale ?? null,
|
||||
appVersion: appVersion ?? null,
|
||||
model: model ?? null,
|
||||
apnsToken: apnsToken ?? null,
|
||||
apnsEnvironment: apnsEnvironment ?? null,
|
||||
// v1.7.0 — only touch the override when the client sends the
|
||||
// field, so a re-register that omits it keeps the prior value.
|
||||
...(medicationDelivery !== undefined && { medicationDelivery }),
|
||||
// v1.17.1 (#22) — same omit-keeps-prior rule; `null` explicitly
|
||||
// clears the token when the Activity ends.
|
||||
...(liveActivityPushToken !== undefined && { liveActivityPushToken }),
|
||||
lastSeen: new Date(),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
id = updated.id;
|
||||
} else {
|
||||
const created = await prisma.device.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
platform: "ios",
|
||||
token,
|
||||
bundleId,
|
||||
locale: locale ?? null,
|
||||
appVersion: appVersion ?? null,
|
||||
model: model ?? null,
|
||||
apnsToken: apnsToken ?? null,
|
||||
apnsEnvironment: apnsEnvironment ?? null,
|
||||
// v1.7.0 — per-device delivery override (null = inherit default).
|
||||
...(medicationDelivery !== undefined && { medicationDelivery }),
|
||||
// v1.17.1 (#22) — Live Activity push token (null = no Activity).
|
||||
...(liveActivityPushToken !== undefined && { liveActivityPushToken }),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
id = created.id;
|
||||
}
|
||||
|
||||
// Auto-create the APNS NotificationChannel row when the device
|
||||
// registers with an apnsToken. Without this row the dispatcher's APNS
|
||||
// branch (`dispatcher.ts:41-49`) never fires for the user — every
|
||||
// production iOS install would otherwise need a manual settings
|
||||
// toggle that doesn't exist. Mirrors the legacy Telegram on-first-
|
||||
// dispatch auto-migration, but eager so the very first reminder
|
||||
// after device registration already routes through APNs. Config is
|
||||
// an encrypted empty record by design — the per-device token +
|
||||
// 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: {},
|
||||
});
|
||||
}
|
||||
const { id } = registration;
|
||||
|
||||
await auditLog("devices.register", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -2,14 +2,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* On-demand summary / extracted text (Document vault P2). Pins P2-D4 / A3:
|
||||
* session-only — the ONLY persistent side effects are the AI-budget ledger and
|
||||
* the audit log; no row, index, or fact is written or mutated.
|
||||
* On-demand summary / extracted text (Document vault P2).
|
||||
*
|
||||
* `mode=text` stays session-only (P2-D4 / A3): the only persistent side effects
|
||||
* are the AI-budget ledger and the audit log. `mode=summary` additionally stores
|
||||
* the summary on the document since v1.30.31 — the user asked for it, and it is
|
||||
* the repair path for documents the background job never ran for. Nothing else
|
||||
* (index, facts, rows) is ever written, and a WITHHELD summary is never stored
|
||||
* as text.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: { findFirst: vi.fn(), update: vi.fn() },
|
||||
inboundDocument: {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
extractedFact: { create: vi.fn(), deleteMany: vi.fn() },
|
||||
documentContentIndex: { upsert: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
@@ -18,6 +27,9 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/documents/store", () => ({
|
||||
decryptDocumentContent: vi.fn(() => Buffer.from([1, 2, 3])),
|
||||
encryptDocumentSummary: vi.fn((text: string) =>
|
||||
new TextEncoder().encode(`enc:${text}`),
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-upload", () => ({
|
||||
detectOcrMimeType: vi.fn(() => "image/png"),
|
||||
@@ -25,6 +37,7 @@ vi.mock("@/lib/labs/ocr-upload", () => ({
|
||||
vi.mock("@/lib/documents/describe", () => ({
|
||||
runDocumentSummary: vi.fn(),
|
||||
transcribeDocument: vi.fn(),
|
||||
documentSummaryBlockedCopy: () => "The summary was withheld.",
|
||||
DocumentDescribeError: class DocumentDescribeError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
@@ -80,14 +93,26 @@ 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 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();
|
||||
expect(prisma.documentContentIndex.upsert).not.toHaveBeenCalled();
|
||||
@@ -106,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: {
|
||||
@@ -117,25 +145,177 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/summary", () => {
|
||||
it("mode=summary returns { summary } and persists only budget + audit", 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);
|
||||
|
||||
const res = await POST(
|
||||
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(),
|
||||
);
|
||||
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "doc-1",
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
summaryEncrypted: null,
|
||||
},
|
||||
data: expect.objectContaining({ summaryState: "READY" }),
|
||||
});
|
||||
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",
|
||||
blocked: "dose_directive",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
req("doc-1", "summary", { persist: true }) as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// 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.
|
||||
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "doc-1",
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
summaryState: { not: "READY" },
|
||||
},
|
||||
data: { summaryState: "WITHHELD" },
|
||||
});
|
||||
const calls = vi.mocked(prisma.inboundDocument.updateMany).mock.calls;
|
||||
for (const [arg] of calls) {
|
||||
expect(arg.data).not.toHaveProperty("summaryEncrypted");
|
||||
}
|
||||
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.",
|
||||
blocked: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.inboundDocument.updateMany).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
|
||||
// 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", { 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("mode=text returns { text } session-only", async () => {
|
||||
vi.mocked(transcribeDocument).mockResolvedValue({
|
||||
text: "raw transcribed body",
|
||||
|
||||
@@ -3,11 +3,17 @@
|
||||
* extracted text.
|
||||
*
|
||||
* `?mode=summary` (default) returns a short plain-language summary of WHAT the
|
||||
* document is; `?mode=text` returns its raw transcribed text. Both are transient
|
||||
* (P2-D4): the result is returned once and NOTHING is persisted except the
|
||||
* AI-budget ledger and the audit log — never coach memory, snapshots, the
|
||||
* structured stores, or the search index. The summary is descriptive only and
|
||||
* is forbidden from diagnosing (interpretation boundary G7).
|
||||
* document is; `?mode=text` returns its raw transcribed text. The summary is
|
||||
* descriptive only and is forbidden from diagnosing (interpretation boundary
|
||||
* G7).
|
||||
*
|
||||
* `mode=text` stays transient (P2-D4) — a transcription is a read-through of the
|
||||
* user's own file and there is nothing to keep. `mode=summary` PERSISTS onto the
|
||||
* document row since v1.30.31: the user asked for it explicitly, it is the same
|
||||
* artefact the background job stores, and keeping it means a second open shows
|
||||
* the paragraph instead of buying it again. Beyond that the old rule holds —
|
||||
* nothing reaches coach memory, snapshots, the structured stores, or the search
|
||||
* index; a summary the safety screen blocked is never stored as text.
|
||||
*
|
||||
* Same VISION/TEXT dispatch and gauntlet as the extract route. The document is
|
||||
* UNTRUSTED (prompt-injection): the server never acts on an instruction inside
|
||||
@@ -47,6 +53,8 @@ import {
|
||||
transcribeDocument,
|
||||
type DescribeInput,
|
||||
} from "@/lib/documents/describe";
|
||||
import { encryptDocumentSummary } from "@/lib/documents/store";
|
||||
import type { OutboundReason } from "@/lib/ai/safety/outbound-screen";
|
||||
import {
|
||||
resolveDocumentTextProvider,
|
||||
resolveDocumentVisionProvider,
|
||||
@@ -78,25 +86,115 @@ 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";
|
||||
}
|
||||
|
||||
/** Run the requested describe call (summary or raw text) over the input. */
|
||||
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.
|
||||
*
|
||||
* 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,
|
||||
): 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 });
|
||||
return { summary: blocked ? documentSummaryBlockedCopy(locale) : summary };
|
||||
const persistence = persist
|
||||
? await persistSummary(persist, summary, blocked)
|
||||
: undefined;
|
||||
return {
|
||||
summary: blocked ? documentSummaryBlockedCopy(locale) : summary,
|
||||
...(persistence ? { persistence } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a screened-clean summary, or record the refusal as a state.
|
||||
*
|
||||
* A blocked summary NEVER lands as text — the honest statement the caller shows
|
||||
* is generated copy for this response, not the model's prose, and persisting it
|
||||
* would put a refusal where a summary belongs. Only the WITHHELD state is kept,
|
||||
* and it is not terminal: the user can ask again.
|
||||
*
|
||||
* Storing is best-effort. The user is waiting on the summary they can already
|
||||
* read in the response; a failed write must not turn that into an error.
|
||||
*/
|
||||
async function persistSummary(
|
||||
target: SummaryPersistenceTarget,
|
||||
summary: string,
|
||||
blocked: OutboundReason | null,
|
||||
): Promise<"stored" | "withheld" | "failed"> {
|
||||
try {
|
||||
if (blocked) {
|
||||
await prisma.inboundDocument.updateMany({
|
||||
where: {
|
||||
id: target.documentId,
|
||||
userId: target.userId,
|
||||
deletedAt: null,
|
||||
summaryState: { not: "READY" },
|
||||
},
|
||||
data: { summaryState: "WITHHELD" },
|
||||
});
|
||||
return "withheld";
|
||||
}
|
||||
// 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,
|
||||
...(target.replaceExisting ? {} : { summaryEncrypted: null }),
|
||||
},
|
||||
data: {
|
||||
summaryEncrypted: encryptDocumentSummary(summary),
|
||||
summaryGeneratedAt: new Date(),
|
||||
summaryState: "READY",
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
/** The budget the requested mode charges. */
|
||||
@@ -122,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({
|
||||
@@ -130,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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -163,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 },
|
||||
@@ -237,6 +358,7 @@ async function handleTextSummary(
|
||||
ocrText: parsed.data.text,
|
||||
},
|
||||
locale,
|
||||
persist,
|
||||
);
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
@@ -258,6 +380,7 @@ async function handleVisionSummary(
|
||||
document: LoadedDocument,
|
||||
mode: DocumentSummaryMode,
|
||||
locale: Locale,
|
||||
persist: SummaryPersistenceTarget | null,
|
||||
): Promise<Response> {
|
||||
const { pick } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) {
|
||||
@@ -322,6 +445,7 @@ async function handleVisionSummary(
|
||||
documents: vision.documents,
|
||||
},
|
||||
locale,
|
||||
persist,
|
||||
);
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
* query NEVER selects the encrypted blob column.
|
||||
*/
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
process.env.ENCRYPTION_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const { txQueryRaw, txCreate, txCreateMany } = vi.hoisted(() => ({
|
||||
|
||||
@@ -421,7 +421,7 @@ describe("GET /api/export/full-backup", () => {
|
||||
expect(res.headers.get("content-disposition")).toMatch(/\.json"/);
|
||||
const json = await res.json();
|
||||
expect(json).toMatchObject({
|
||||
schemaVersion: "1",
|
||||
schemaVersion: "2",
|
||||
userId: "user-1",
|
||||
measurements: [],
|
||||
medications: [],
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -10,8 +10,14 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { create: vi.fn(), upsert: vi.fn() },
|
||||
moodEntry: { create: vi.fn(), upsert: vi.fn() },
|
||||
measurement: {
|
||||
create: vi.fn(),
|
||||
createManyAndReturn: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
moodEntry: { create: vi.fn(), findUnique: vi.fn(), upsert: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -27,6 +33,19 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
const { encryptNoteMock } = vi.hoisted(() => ({ encryptNoteMock: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/crypto/note-cipher", () => ({
|
||||
encryptNote: encryptNoteMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/arrivals/measurement-emit", () => ({
|
||||
emitInsertedMeasurementArrivals: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
|
||||
maybeEnqueueMorningRefresh: vi.fn(),
|
||||
}));
|
||||
// v1.4.39.1 — surface the rollup hook so the new regression test can
|
||||
// assert that the import path now folds the persistent rollup tier
|
||||
// for each touched (type, day). Mood rollup mock stays no-op because
|
||||
@@ -50,9 +69,20 @@ import { POST } from "../route";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
|
||||
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.measurement.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(emitInsertedMeasurementArrivals).mockResolvedValue(undefined);
|
||||
vi.mocked(maybeEnqueueMorningRefresh).mockResolvedValue(undefined);
|
||||
encryptNoteMock.mockImplementation((note: string | null) =>
|
||||
note === null ? null : `encrypted:${note}`,
|
||||
);
|
||||
});
|
||||
|
||||
interface ApiErrorEnvelope {
|
||||
@@ -60,6 +90,10 @@ interface ApiErrorEnvelope {
|
||||
error: string;
|
||||
}
|
||||
|
||||
function asDate(value: string | Date): Date {
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
// V3 audit: /api/import POST had no rate-limit. Bulk-injection vector
|
||||
// (max:10000 records per call). Now capped at 5/hour/user.
|
||||
describe("POST /api/import — rate-limit guard", () => {
|
||||
@@ -216,7 +250,10 @@ describe("POST /api/import — measurement rollup hook (v1.4.39.1)", () => {
|
||||
resetAt: new Date(Date.now() + 1000),
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.create).mockRejectedValue(
|
||||
new Error("P2002 duplicate"),
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await POST(
|
||||
@@ -242,6 +279,173 @@ describe("POST /api/import — measurement rollup hook (v1.4.39.1)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/import — arrival wiring", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: new Date(Date.now() + 1000),
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("emits and refreshes only a newly inserted sleep row", async () => {
|
||||
const measuredAt = new Date("2026-05-14T06:00:00.000Z");
|
||||
const created = {
|
||||
id: "sleep-new",
|
||||
type: "SLEEP_DURATION" as const,
|
||||
measuredAt,
|
||||
};
|
||||
vi.mocked(prisma.measurement.create).mockResolvedValue(created as never);
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest("http://localhost/api/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
measurements: [
|
||||
{
|
||||
type: "SLEEP_DURATION",
|
||||
value: 480,
|
||||
unit: "min",
|
||||
measuredAt: measuredAt.toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBeLessThan(400);
|
||||
expect(emitInsertedMeasurementArrivals).toHaveBeenCalledWith(
|
||||
"u-1",
|
||||
[created],
|
||||
"json_import",
|
||||
);
|
||||
expect(maybeEnqueueMorningRefresh).toHaveBeenCalledWith("u-1", [
|
||||
measuredAt,
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits and refreshes an external-id row only when INSERT returns it", async () => {
|
||||
const measuredAt = new Date("2026-05-14T06:00:00.000Z");
|
||||
const inserted = {
|
||||
id: "sleep-inserted",
|
||||
type: "SLEEP_DURATION" as const,
|
||||
measuredAt,
|
||||
};
|
||||
vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValue([
|
||||
inserted,
|
||||
] as never);
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest("http://localhost/api/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
measurements: [
|
||||
{
|
||||
type: "SLEEP_DURATION",
|
||||
value: 480,
|
||||
unit: "min",
|
||||
measuredAt: measuredAt.toISOString(),
|
||||
externalId: "sleep-new",
|
||||
},
|
||||
],
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBeLessThan(400);
|
||||
expect(emitInsertedMeasurementArrivals).toHaveBeenCalledWith(
|
||||
"u-1",
|
||||
[inserted],
|
||||
"json_import",
|
||||
);
|
||||
expect(maybeEnqueueMorningRefresh).toHaveBeenCalledWith("u-1", [
|
||||
measuredAt,
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not emit or refresh when INSERT loses a race and returns no row", async () => {
|
||||
vi.mocked(prisma.measurement.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.measurement.update).mockResolvedValue({
|
||||
id: "raced-winner",
|
||||
type: "SLEEP_DURATION",
|
||||
measuredAt: new Date("2026-05-14T06:00:00.000Z"),
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.upsert).mockResolvedValue({
|
||||
id: "raced-winner",
|
||||
type: "SLEEP_DURATION",
|
||||
measuredAt: new Date("2026-05-14T06:00:00.000Z"),
|
||||
} as never);
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest("http://localhost/api/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
measurements: [
|
||||
{
|
||||
type: "SLEEP_DURATION",
|
||||
value: 480,
|
||||
unit: "min",
|
||||
measuredAt: "2026-05-14T06:00:00.000Z",
|
||||
externalId: "sleep-raced",
|
||||
},
|
||||
],
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBeLessThan(400);
|
||||
expect(emitInsertedMeasurementArrivals).toHaveBeenCalledWith(
|
||||
"u-1",
|
||||
[],
|
||||
"json_import",
|
||||
);
|
||||
expect(maybeEnqueueMorningRefresh).toHaveBeenCalledWith("u-1", []);
|
||||
});
|
||||
|
||||
it("does not emit or refresh an existing external-id update", async () => {
|
||||
vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.measurement.update).mockResolvedValue({
|
||||
id: "existing",
|
||||
type: "SLEEP_DURATION",
|
||||
measuredAt: new Date("2026-05-14T06:00:00.000Z"),
|
||||
} as never);
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest("http://localhost/api/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
measurements: [
|
||||
{
|
||||
type: "SLEEP_DURATION",
|
||||
value: 480,
|
||||
unit: "min",
|
||||
measuredAt: "2026-05-14T06:00:00.000Z",
|
||||
externalId: "sleep-existing",
|
||||
},
|
||||
],
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBeLessThan(400);
|
||||
expect(emitInsertedMeasurementArrivals).toHaveBeenCalledWith(
|
||||
"u-1",
|
||||
[],
|
||||
"json_import",
|
||||
);
|
||||
expect(maybeEnqueueMorningRefresh).toHaveBeenCalledWith("u-1", []);
|
||||
});
|
||||
});
|
||||
|
||||
// v1.17.1 — two real bugs the data-portability audit found in the JSON
|
||||
// import. Regression guards so a future refactor can't silently re-open
|
||||
// them.
|
||||
@@ -323,8 +527,11 @@ describe("POST /api/import — measurement externalId dedup (v1.17.1)", () => {
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("upserts (not creates) when a measurement carries an externalId", async () => {
|
||||
vi.mocked(prisma.measurement.upsert).mockResolvedValue({} as never);
|
||||
it("updates in place when an external-id INSERT returns no row", async () => {
|
||||
vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.measurement.update).mockResolvedValue({} as never);
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest("http://localhost/api/import", {
|
||||
@@ -345,9 +552,11 @@ describe("POST /api/import — measurement externalId dedup (v1.17.1)", () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBeLessThan(400);
|
||||
expect(vi.mocked(prisma.measurement.upsert)).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
vi.mocked(prisma.measurement.createManyAndReturn),
|
||||
).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(prisma.measurement.create)).not.toHaveBeenCalled();
|
||||
const arg = vi.mocked(prisma.measurement.upsert).mock.calls[0][0];
|
||||
const arg = vi.mocked(prisma.measurement.update).mock.calls[0][0];
|
||||
expect(arg.where).toEqual({
|
||||
userId_type_source_externalId: {
|
||||
userId: "u-1",
|
||||
@@ -383,3 +592,574 @@ describe("POST /api/import — measurement externalId dedup (v1.17.1)", () => {
|
||||
expect(vi.mocked(prisma.measurement.upsert)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/import — write failure classification", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: new Date(Date.now() + 1000),
|
||||
} as never);
|
||||
});
|
||||
|
||||
function request(body: Record<string, unknown>): NextRequest {
|
||||
return new NextRequest("http://localhost/api/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function measurement(value = 80): Record<string, unknown> {
|
||||
return {
|
||||
type: "WEIGHT",
|
||||
value,
|
||||
unit: "kg",
|
||||
measuredAt: "2026-05-01T08:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function moodEntry(): Record<string, unknown> {
|
||||
return {
|
||||
date: "2026-05-01",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
loggedAt: "2026-05-01T12:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function expectRetryableFailure(
|
||||
body: unknown,
|
||||
stats: { measurements: number; moodEntries: number; skipped: number },
|
||||
): void {
|
||||
expect(body).toEqual({
|
||||
data: null,
|
||||
error: "Import write failed",
|
||||
meta: {
|
||||
errorCode: "import.write_failed",
|
||||
retryable: true,
|
||||
stats,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("counts an authentic P2002 measurement error as a duplicate", async () => {
|
||||
vi.mocked(prisma.measurement.create).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: {
|
||||
target: ["userId", "type", "measuredAt", "source", "sleepStage"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await POST(request({ measurements: [measurement()] }));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
data: { measurements: 0, moodEntries: 0, skipped: 1 },
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts an authentic P2002 mood error as a duplicate", async () => {
|
||||
vi.mocked(prisma.moodEntry.create).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: {
|
||||
target: ["userId", "date", "moodLoggedAt"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await POST(request({ moodEntries: [moodEntry()] }));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
data: { measurements: 0, moodEntries: 0, skipped: 1 },
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips an external-id insert race lost on timestamp", async () => {
|
||||
vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValueOnce(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.measurement.update).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientKnownRequestError("Record to update not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "test",
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await POST(
|
||||
request({
|
||||
measurements: [{ ...measurement(), externalId: "new-source-id" }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
data: { measurements: 0, moodEntries: 0, skipped: 1 },
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns retryable counts on database timeout", async () => {
|
||||
vi.mocked(prisma.measurement.create)
|
||||
.mockResolvedValueOnce({
|
||||
id: "measurement-1",
|
||||
type: "WEIGHT",
|
||||
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
} as never)
|
||||
.mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientInitializationError(
|
||||
"Operations timed out",
|
||||
"test",
|
||||
"P1008",
|
||||
),
|
||||
);
|
||||
|
||||
const response = await POST(
|
||||
request({ measurements: [measurement(80), measurement(81)] }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expectRetryableFailure(await response.json(), {
|
||||
measurements: 1,
|
||||
moodEntries: 0,
|
||||
skipped: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns retryable counts on connection loss", async () => {
|
||||
vi.mocked(prisma.measurement.create).mockResolvedValueOnce({
|
||||
id: "measurement-1",
|
||||
type: "WEIGHT",
|
||||
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
} as never);
|
||||
vi.mocked(prisma.moodEntry.create).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientInitializationError(
|
||||
"Can't reach database server",
|
||||
"test",
|
||||
"P1001",
|
||||
),
|
||||
);
|
||||
|
||||
const response = await POST(
|
||||
request({
|
||||
measurements: [measurement()],
|
||||
moodEntries: [moodEntry()],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expectRetryableFailure(await response.json(), {
|
||||
measurements: 1,
|
||||
moodEntries: 0,
|
||||
skipped: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a retryable failure when note encryption fails", async () => {
|
||||
encryptNoteMock.mockImplementationOnce(() => {
|
||||
throw new Error("Encryption key unavailable");
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
request({
|
||||
measurements: [{ ...measurement(), notes: "private note" }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expectRetryableFailure(await response.json(), {
|
||||
measurements: 0,
|
||||
moodEntries: 0,
|
||||
skipped: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes a partially committed legacy mood import without duplicates", async () => {
|
||||
type LegacyMoodWrite = { date: string; moodLoggedAt: Date };
|
||||
|
||||
const committedKeys = new Set<string>();
|
||||
let writeAttempt = 0;
|
||||
const writeMood = async (
|
||||
data: LegacyMoodWrite,
|
||||
target: Set<string>,
|
||||
): Promise<never> => {
|
||||
writeAttempt++;
|
||||
if (writeAttempt === 2) {
|
||||
throw new Error("transient write failure");
|
||||
}
|
||||
|
||||
const key = `${data.date}:${data.moodLoggedAt.toISOString()}`;
|
||||
if (target.has(key)) {
|
||||
throw new Prisma.PrismaClientKnownRequestError(
|
||||
"Unique constraint failed",
|
||||
{
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["userId", "date", "moodLoggedAt"] },
|
||||
},
|
||||
);
|
||||
}
|
||||
target.add(key);
|
||||
return {} as never;
|
||||
};
|
||||
|
||||
vi.mocked(prisma.moodEntry.create).mockImplementation(
|
||||
({ data }: Prisma.MoodEntryCreateArgs) =>
|
||||
writeMood(data as LegacyMoodWrite, committedKeys) as never,
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findUnique).mockImplementation(
|
||||
({ where }: Prisma.MoodEntryFindUniqueArgs) => {
|
||||
const composite = where.userId_date_moodLoggedAt!;
|
||||
const key = `${composite.date}:${asDate(composite.moodLoggedAt).toISOString()}`;
|
||||
if (!committedKeys.has(key)) return Promise.resolve(null) as never;
|
||||
const isFirstDay = composite.date === "2026-05-01";
|
||||
return Promise.resolve({
|
||||
source: "IMPORT",
|
||||
mood: isFirstDay ? "GUT" : "OKAY",
|
||||
score: isFirstDay ? 4 : 3,
|
||||
tags: null,
|
||||
externalId: null,
|
||||
} as never) as never;
|
||||
},
|
||||
);
|
||||
|
||||
const payload = {
|
||||
moodEntries: [
|
||||
{ date: "2026-05-01", mood: "GUT", score: 4 },
|
||||
{ date: "2026-05-02", mood: "OKAY", score: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
const failedResponse = await POST(request(payload));
|
||||
expect(failedResponse.status).toBe(503);
|
||||
expectRetryableFailure(await failedResponse.json(), {
|
||||
measurements: 0,
|
||||
moodEntries: 1,
|
||||
skipped: 0,
|
||||
});
|
||||
expect(committedKeys.size).toBe(1);
|
||||
|
||||
const retryResponse = await POST(request(payload));
|
||||
|
||||
expect(retryResponse.status).toBe(200);
|
||||
expect(await retryResponse.json()).toEqual({
|
||||
data: { measurements: 0, moodEntries: 1, skipped: 1 },
|
||||
error: null,
|
||||
});
|
||||
expect(committedKeys.size).toBe(2);
|
||||
});
|
||||
|
||||
it("does not alias unrelated date-only mood imports", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z"));
|
||||
const committedKeys = new Set<string>();
|
||||
vi.mocked(prisma.moodEntry.create).mockImplementation(
|
||||
({ data }: Prisma.MoodEntryCreateArgs) => {
|
||||
const key = `${data.date}:${asDate(data.moodLoggedAt).toISOString()}`;
|
||||
if (committedKeys.has(key)) {
|
||||
return Promise.reject(
|
||||
new Prisma.PrismaClientKnownRequestError(
|
||||
"Unique constraint failed",
|
||||
{
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["userId", "date", "moodLoggedAt"] },
|
||||
},
|
||||
),
|
||||
) as never;
|
||||
}
|
||||
committedKeys.add(key);
|
||||
return Promise.resolve({} as never) as never;
|
||||
},
|
||||
);
|
||||
|
||||
const firstResponse = await POST(
|
||||
request({
|
||||
moodEntries: [{ date: "2026-05-10", mood: "GUT", score: 4 }],
|
||||
}),
|
||||
);
|
||||
vi.advanceTimersByTime(1000);
|
||||
const secondResponse = await POST(
|
||||
request({
|
||||
moodEntries: [{ date: "2026-05-10", mood: "SCHLECHT", score: 2 }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(firstResponse.status).toBe(200);
|
||||
expect(secondResponse.status).toBe(200);
|
||||
expect(await secondResponse.json()).toEqual({
|
||||
data: { measurements: 0, moodEntries: 1, skipped: 0 },
|
||||
error: null,
|
||||
});
|
||||
expect(committedKeys.size).toBe(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("anchors a date-only mood row to its recorded day", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z"));
|
||||
vi.mocked(prisma.moodEntry.create).mockResolvedValue({} as never);
|
||||
|
||||
const response = await POST(
|
||||
request({
|
||||
moodEntries: [{ date: "2026-05-01", mood: "GUT", score: 4 }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const moodLoggedAt = asDate(
|
||||
vi.mocked(prisma.moodEntry.create).mock.calls[0][0].data.moodLoggedAt,
|
||||
);
|
||||
expect(moodLoggedAt.getTime()).toBeGreaterThanOrEqual(
|
||||
Date.parse("2026-04-30T22:00:00.000Z"),
|
||||
);
|
||||
expect(moodLoggedAt.getTime()).toBeLessThan(
|
||||
Date.parse("2026-04-30T22:05:00.000Z"),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts the current Berlin day shortly after local midnight", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-05-10T22:30:00.000Z"));
|
||||
vi.mocked(prisma.moodEntry.create).mockResolvedValue({} as never);
|
||||
|
||||
const response = await POST(
|
||||
request({
|
||||
moodEntries: [{ date: "2026-05-11", mood: "GUT", score: 4 }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const moodLoggedAt = vi.mocked(prisma.moodEntry.create).mock.calls[0][0]
|
||||
.data.moodLoggedAt;
|
||||
expect(new Date(moodLoggedAt).getTime()).toBeGreaterThanOrEqual(
|
||||
Date.parse("2026-05-10T22:00:00.000Z"),
|
||||
);
|
||||
expect(new Date(moodLoggedAt).getTime()).toBeLessThan(
|
||||
Date.parse("2026-05-10T22:05:00.000Z"),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves persisted and repeated-row fallback timestamp collisions", async () => {
|
||||
type StoredMood = {
|
||||
date: string;
|
||||
mood: string;
|
||||
score: number;
|
||||
tags: string | null;
|
||||
externalId?: string | null;
|
||||
moodLoggedAt: Date;
|
||||
};
|
||||
|
||||
const persisted = new Map<string, StoredMood>();
|
||||
vi.mocked(prisma.moodEntry.create).mockImplementation(
|
||||
({ data }: Prisma.MoodEntryCreateArgs) => {
|
||||
const moodLoggedAt = asDate(data.moodLoggedAt);
|
||||
const key = `${data.date}:${moodLoggedAt.toISOString()}`;
|
||||
if (persisted.has(key)) {
|
||||
return Promise.reject(
|
||||
new Prisma.PrismaClientKnownRequestError(
|
||||
"Unique constraint failed",
|
||||
{
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["userId", "date", "moodLoggedAt"] },
|
||||
},
|
||||
),
|
||||
) as never;
|
||||
}
|
||||
persisted.set(key, { ...data, moodLoggedAt } as StoredMood);
|
||||
return Promise.resolve({} as never) as never;
|
||||
},
|
||||
);
|
||||
vi.mocked(prisma.moodEntry.findUnique).mockImplementation(
|
||||
({ where }: Prisma.MoodEntryFindUniqueArgs) => {
|
||||
const composite = where.userId_date_moodLoggedAt!;
|
||||
const key = `${composite.date}:${asDate(composite.moodLoggedAt).toISOString()}`;
|
||||
return Promise.resolve((persisted.get(key) ?? null) as never) as never;
|
||||
},
|
||||
);
|
||||
|
||||
const firstPayload = {
|
||||
moodEntries: [
|
||||
{ date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-250" },
|
||||
],
|
||||
};
|
||||
const collidingPayload = {
|
||||
moodEntries: [
|
||||
{ date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-421" },
|
||||
],
|
||||
};
|
||||
|
||||
await POST(request(firstPayload));
|
||||
const collisionResponse = await POST(request(collidingPayload));
|
||||
const retryResponse = await POST(request(collidingPayload));
|
||||
|
||||
expect(await collisionResponse.json()).toEqual({
|
||||
data: { measurements: 0, moodEntries: 1, skipped: 0 },
|
||||
error: null,
|
||||
});
|
||||
expect(await retryResponse.json()).toEqual({
|
||||
data: { measurements: 0, moodEntries: 0, skipped: 1 },
|
||||
error: null,
|
||||
});
|
||||
expect(persisted.size).toBe(2);
|
||||
|
||||
const reverseOrderResponse = await POST(
|
||||
request({
|
||||
moodEntries: [
|
||||
{ date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-421" },
|
||||
{ date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-250" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(await reverseOrderResponse.json()).toEqual({
|
||||
data: { measurements: 0, moodEntries: 0, skipped: 2 },
|
||||
error: null,
|
||||
});
|
||||
expect(persisted.size).toBe(2);
|
||||
|
||||
const repeatedRowResponse = await POST(
|
||||
request({
|
||||
moodEntries: [
|
||||
{ date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-53200" },
|
||||
{ date: "2026-05-01", mood: "GUT", score: 4, tags: "tag-53200" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(await repeatedRowResponse.json()).toEqual({
|
||||
data: { measurements: 0, moodEntries: 2, skipped: 0 },
|
||||
error: null,
|
||||
});
|
||||
expect(persisted.size).toBe(4);
|
||||
});
|
||||
|
||||
it("keeps a generated timestamp stable when upserting an external ID", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z"));
|
||||
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({} as never);
|
||||
const payload = {
|
||||
moodEntries: [
|
||||
{
|
||||
date: "2026-05-01",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
externalId: "legacy-row-1",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await POST(request(payload));
|
||||
vi.advanceTimersByTime(1000);
|
||||
await POST(request(payload));
|
||||
|
||||
const first = vi.mocked(prisma.moodEntry.upsert).mock.calls[0][0];
|
||||
const second = vi.mocked(prisma.moodEntry.upsert).mock.calls[1][0];
|
||||
expect(first.create.moodLoggedAt).toEqual(second.create.moodLoggedAt);
|
||||
expect(first.update.moodLoggedAt).toEqual(second.update.moodLoggedAt);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["1899-12-31", "2026-05-11"])(
|
||||
"rejects an implausible date-only mood row dated %s",
|
||||
async (date) => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-05-10T08:00:00.000Z"));
|
||||
|
||||
const response = await POST(
|
||||
request({ moodEntries: [{ date, mood: "GUT", score: 4 }] }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(prisma.moodEntry.create).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("returns a retryable failure for a generic mood write error", async () => {
|
||||
vi.mocked(prisma.moodEntry.create).mockRejectedValueOnce(
|
||||
new Error("write failed"),
|
||||
);
|
||||
|
||||
const response = await POST(request({ moodEntries: [moodEntry()] }));
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expectRetryableFailure(await response.json(), {
|
||||
measurements: 0,
|
||||
moodEntries: 0,
|
||||
skipped: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("audits rows committed before a later write failure", async () => {
|
||||
vi.mocked(prisma.measurement.create)
|
||||
.mockResolvedValueOnce({
|
||||
id: "measurement-1",
|
||||
type: "WEIGHT",
|
||||
measuredAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
} as never)
|
||||
.mockRejectedValueOnce(new Error("write failed"));
|
||||
|
||||
const response = await POST(
|
||||
request({ measurements: [measurement(80), measurement(81)] }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(auditLog).toHaveBeenCalledWith("import.upload", {
|
||||
userId: "u-1",
|
||||
ipAddress: null,
|
||||
details: { measurements: 1, moodEntries: 0, skipped: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the classified failure when its audit also fails", async () => {
|
||||
vi.mocked(prisma.measurement.create).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientInitializationError(
|
||||
"Can't reach database server",
|
||||
"test",
|
||||
"P1001",
|
||||
),
|
||||
);
|
||||
vi.mocked(auditLog).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientInitializationError(
|
||||
"Can't reach database server",
|
||||
"test",
|
||||
"P1001",
|
||||
),
|
||||
);
|
||||
|
||||
const response = await POST(request({ measurements: [measurement()] }));
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expectRetryableFailure(await response.json(), {
|
||||
measurements: 0,
|
||||
moodEntries: 0,
|
||||
skipped: 0,
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -11,14 +11,14 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
vi.mock("@/lib/db", () => {
|
||||
const measurement = {
|
||||
findMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
createManyAndReturn: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
measurement,
|
||||
// Run the batched write callback against the same measurement mock so
|
||||
// createMany / updateMany calls are observable.
|
||||
// createManyAndReturn / updateMany calls are observable.
|
||||
$transaction: vi.fn(async (fn: (tx: unknown) => unknown) =>
|
||||
fn({ measurement }),
|
||||
),
|
||||
@@ -99,14 +99,15 @@ beforeEach(() => {
|
||||
// db mock factory (resetAllMocks clears its implementation), so restore it.
|
||||
const measurement = prisma.measurement as unknown as {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
createMany: ReturnType<typeof vi.fn>;
|
||||
createManyAndReturn: ReturnType<typeof vi.fn>;
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
measurement.findMany.mockResolvedValue([]);
|
||||
// Echo the attempted chunk size, matching Postgres semantics when no row
|
||||
// collides — the route now reconciles `inserted` against this count.
|
||||
measurement.createMany.mockImplementation(
|
||||
async (arg: { data: unknown[] }) => ({ count: arg.data.length }),
|
||||
// Echo the attempted rows, matching Postgres semantics when no row
|
||||
// collides — the route reconciles `inserted` against this result.
|
||||
measurement.createManyAndReturn.mockImplementation(
|
||||
async (arg: { data: Array<Record<string, unknown>> }) =>
|
||||
arg.data.map((row, index) => ({ ...row, id: `inserted-${index}` })),
|
||||
);
|
||||
measurement.updateMany.mockResolvedValue({ count: 0 });
|
||||
(
|
||||
@@ -139,7 +140,7 @@ describe("POST /api/import/csv — rate limit", () => {
|
||||
const mMeasurement = () =>
|
||||
prisma.measurement as unknown as {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
createMany: ReturnType<typeof vi.fn>;
|
||||
createManyAndReturn: ReturnType<typeof vi.fn>;
|
||||
updateMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
@@ -147,7 +148,7 @@ describe("POST /api/import/csv — fatal header error", () => {
|
||||
it("returns 422 when a required column is missing", async () => {
|
||||
const res = await POST(csvRequest("type,value,unit\nWEIGHT,80,kg"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(mMeasurement().createMany).not.toHaveBeenCalled();
|
||||
expect(mMeasurement().createManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,15 +174,15 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
|
||||
const reasons = body.data?.rows.map((r) => r.reason);
|
||||
expect(reasons).toContain("unknown_type");
|
||||
expect(reasons).toContain("missing_timezone_offset");
|
||||
// One bulk createMany carrying the single valid survivor.
|
||||
expect(mMeasurement().createMany).toHaveBeenCalledTimes(1);
|
||||
const arg = mMeasurement().createMany.mock.calls[0][0];
|
||||
// One bulk createManyAndReturn carrying the single valid survivor.
|
||||
expect(mMeasurement().createManyAndReturn).toHaveBeenCalledTimes(1);
|
||||
const arg = mMeasurement().createManyAndReturn.mock.calls[0][0];
|
||||
expect(arg.data).toHaveLength(1);
|
||||
// Rollup re-fold fired for the touched (type, day).
|
||||
expect(vi.mocked(recomputeBucketsForMeasurement)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("batches many rows into a single createMany (no per-row round-trip)", async () => {
|
||||
it("batches many rows into a single createManyAndReturn", async () => {
|
||||
const rows = [HEADER];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const mm = String(i).padStart(2, "0"); // unique minute → unique measuredAt
|
||||
@@ -191,9 +192,11 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as CsvEnvelope;
|
||||
expect(body.data?.inserted).toBe(50);
|
||||
// 50 rows, one createMany call — the whole point of the batch.
|
||||
expect(mMeasurement().createMany).toHaveBeenCalledTimes(1);
|
||||
expect(mMeasurement().createMany.mock.calls[0][0].data).toHaveLength(50);
|
||||
// 50 rows, one bulk call — the whole point of the batch.
|
||||
expect(mMeasurement().createManyAndReturn).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
mMeasurement().createManyAndReturn.mock.calls[0][0].data,
|
||||
).toHaveLength(50);
|
||||
});
|
||||
|
||||
it("dryRun previews without writing and reports projected inserts", async () => {
|
||||
@@ -208,7 +211,7 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
|
||||
expect(body.data?.dryRun).toBe(true);
|
||||
expect(body.data?.inserted).toBe(1);
|
||||
expect(body.data?.rows[0].status).toBe("inserted");
|
||||
expect(mMeasurement().createMany).not.toHaveBeenCalled();
|
||||
expect(mMeasurement().createManyAndReturn).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(recomputeBucketsForMeasurement)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -227,7 +230,7 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
|
||||
expect(body.data?.inserted).toBe(0);
|
||||
expect(body.data?.rows[0].status).toBe("updated");
|
||||
expect(mMeasurement().updateMany).toHaveBeenCalledTimes(1);
|
||||
expect(mMeasurement().createMany).not.toHaveBeenCalled();
|
||||
expect(mMeasurement().createManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resurrects a tombstoned externalId row on re-import (deletedAt: null in update)", async () => {
|
||||
@@ -264,7 +267,7 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
|
||||
expect(body.data?.inserted).toBe(1);
|
||||
expect(body.data?.updated).toBe(0);
|
||||
expect(body.data?.rows[0].status).toBe("inserted");
|
||||
expect(mMeasurement().createMany).toHaveBeenCalledTimes(1);
|
||||
expect(mMeasurement().createManyAndReturn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("counts a pre-existing natural-key row as skipped/duplicate", async () => {
|
||||
@@ -282,7 +285,7 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
|
||||
status: "skipped",
|
||||
reason: "duplicate",
|
||||
});
|
||||
expect(mMeasurement().createMany).not.toHaveBeenCalled();
|
||||
expect(mMeasurement().createManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("collapses an in-file duplicate (same type+measuredAt) to one insert + one duplicate", async () => {
|
||||
@@ -299,16 +302,21 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => {
|
||||
expect(body.data?.inserted).toBe(1);
|
||||
expect(body.data?.skipped).toBe(1);
|
||||
// Only the first survivor reaches the bulk insert.
|
||||
expect(mMeasurement().createMany.mock.calls[0][0].data).toHaveLength(1);
|
||||
expect(
|
||||
mMeasurement().createManyAndReturn.mock.calls[0][0].data,
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reconciles `inserted` against the createMany count when skipDuplicates absorbs a race", async () => {
|
||||
it("reconciles `inserted` against returned rows after a race", async () => {
|
||||
// Two fresh rows attempted, but a concurrent double-submit already
|
||||
// landed one of them — `skipDuplicates` absorbs the conflict and the
|
||||
// count comes back short. The envelope must sum to the DB truth: one
|
||||
// inserted, one downgraded to skipped/duplicate.
|
||||
mMeasurement().createMany.mockImplementation(
|
||||
async (arg: { data: unknown[] }) => ({ count: arg.data.length - 1 }),
|
||||
mMeasurement().createManyAndReturn.mockImplementation(
|
||||
async (arg: { data: Array<Record<string, unknown>> }) =>
|
||||
arg.data
|
||||
.slice(0, -1)
|
||||
.map((row, index) => ({ ...row, id: `inserted-${index}` })),
|
||||
);
|
||||
|
||||
const res = await POST(
|
||||
|
||||
Loaded 100 of 494 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user