mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e783d9a46a | ||
|
|
4c1c3f70e2 | ||
|
|
5aefcd59eb | ||
|
|
3077adfe06 | ||
|
|
458cb91b92 | ||
|
|
f846b0681d | ||
|
|
b7b160d4fa | ||
|
|
4111124f24 | ||
|
|
3ad1a8ab25 | ||
|
|
3ad0649ff6 | ||
|
|
60f587c5a7 | ||
|
|
083410473c | ||
|
|
0c4b6ab8ba | ||
|
|
2a180fec95 | ||
|
|
b4c6c2cc88 | ||
|
|
b59f8118a9 | ||
|
|
6a4d3fd553 | ||
|
|
c21338df45 | ||
|
|
25a7834dec | ||
|
|
bb59018ac9 | ||
|
|
f9876f9492 | ||
|
|
7fa10b43a0 | ||
|
|
7b0688b37e | ||
|
|
588a3a4a8d | ||
|
|
4c5dbf00ec | ||
|
|
9cf609bae2 | ||
|
|
6db4bd9793 | ||
|
|
ff92ba46c2 | ||
|
|
f4dec8c28f | ||
|
|
5ce3e81972 | ||
|
|
15c77597fc | ||
|
|
a1511b33b3 | ||
|
|
3189848ace | ||
|
|
4ced89553d | ||
|
|
6fef2081d0 | ||
|
|
3943dcfb72 | ||
|
|
550f314a54 | ||
|
|
97822e9e6d | ||
|
|
f3dda21917 | ||
|
|
1f45cf4da4 | ||
|
|
e122b50477 | ||
|
|
e9f776937d | ||
|
|
a86c59b77f | ||
|
|
c150983dc0 | ||
|
|
058d1d7a69 | ||
|
|
778c3a53a4 | ||
|
|
19a24a5160 | ||
|
|
45e391c60a | ||
|
|
57eafabb9d | ||
|
|
cac9a32756 | ||
|
|
159de9b78f | ||
|
|
954701b5f3 | ||
|
|
d1f6488b38 | ||
|
|
5859072561 | ||
|
|
4493eb2cac | ||
|
|
8b0fbfb522 | ||
|
|
853ff61520 | ||
|
|
9df6799a04 | ||
|
|
f4f3b75166 | ||
|
|
02642a964e | ||
|
|
5142823b7e | ||
|
|
07fe14c6ae | ||
|
|
9dc0ccd03a | ||
|
|
b765febc97 | ||
|
|
baec710138 | ||
|
|
8a5a26b49a | ||
|
|
7eeab2ae98 | ||
|
|
e21dfd040e | ||
|
|
d142a6ed31 | ||
|
|
7d3557a3fd | ||
|
|
dacdcccfd2 | ||
|
|
7e498760f5 | ||
|
|
12fce6fc14 | ||
|
|
b4d2d51d46 | ||
|
|
aaecd141ee | ||
|
|
ea29e9cf1b | ||
|
|
ad33fa46ed | ||
|
|
2718930e8d | ||
|
|
fd3226bc39 | ||
|
|
6bfba2f187 | ||
|
|
8fdf8597a2 | ||
|
|
f790e14edd | ||
|
|
95f21bfa4d | ||
|
|
a2a995e429 | ||
|
|
3d95ace218 | ||
|
|
657f2323a3 | ||
|
|
a664395021 | ||
|
|
242d2514ee | ||
|
|
b5e799c2d6 | ||
|
|
b346e78cc7 | ||
|
|
23f7c74c04 | ||
|
|
9fa4962314 | ||
|
|
11a061c4a1 | ||
|
|
03453aaed2 | ||
|
|
78fe82e348 | ||
|
|
ceeb818f41 | ||
|
|
01a100f50b | ||
|
|
390a362d7a | ||
|
|
6cca9b83aa | ||
|
|
40f7196c20 | ||
|
|
a4b3d2fcd0 | ||
|
|
44f344bf52 | ||
|
|
5639da4333 | ||
|
|
a62e00cfcd | ||
|
|
f13aa684f9 | ||
|
|
599b7e8c99 | ||
|
|
5778dd0be0 | ||
|
|
d56cf70d7c | ||
|
|
a8149e0bdf | ||
|
|
25f41f88fe | ||
|
|
3b480b9885 | ||
|
|
9445d89b76 | ||
|
|
7e7e17c68c | ||
|
|
ce3b4c70c8 | ||
|
|
410c62c292 | ||
|
|
35e8f076a2 | ||
|
|
fdf48e4890 | ||
|
|
963831d4ec | ||
|
|
bcbb05c770 | ||
|
|
e9084410e2 | ||
|
|
bd78cd7f7a | ||
|
|
5bfafa6f51 | ||
|
|
5e1035e47a | ||
|
|
8b745015db | ||
|
|
4ad17f9de3 | ||
|
|
6067d9176e | ||
|
|
024da9fe96 | ||
|
|
9f2c403f84 | ||
|
|
b55fab5e94 | ||
|
|
1e34671d92 | ||
|
|
258e2fd365 | ||
|
|
a2524b6d70 | ||
|
|
cc00d68d8c | ||
|
|
922fe88c11 | ||
|
|
5b7c554b1e | ||
|
|
a5650d0617 | ||
|
|
04765ea8ac | ||
|
|
4ca3de5ff3 | ||
|
|
24928dbb38 | ||
|
|
eb5a39a1c1 | ||
|
|
5b75184649 | ||
|
|
9fe5673251 | ||
|
|
51dd614199 | ||
|
|
b108ae7318 | ||
|
|
0cf9bc9348 | ||
|
|
d7bf0df10b | ||
|
|
aabc7915cb | ||
|
|
3cf48fa0f1 | ||
|
|
0a4bf34a2b | ||
|
|
fb6e494f64 | ||
|
|
f30cb49b05 | ||
|
|
6f45d72a84 | ||
|
|
298e4c34b4 | ||
|
|
41ba4c6c17 | ||
|
|
ba4421e464 | ||
|
|
65677c2112 | ||
|
|
225ae90a86 | ||
|
|
cef7b95a0b | ||
|
|
eb1bbef295 | ||
|
|
9e6d0d5acb | ||
|
|
09dbb1131d | ||
|
|
0761ce2346 | ||
|
|
76012bf8e2 | ||
|
|
b8c2b2e8c5 | ||
|
|
eb2c7ced73 | ||
|
|
a431b2346c | ||
|
|
050ddbecb2 | ||
|
|
dfb6a7f298 | ||
|
|
82d4081ac5 | ||
|
|
3ff7b48f95 | ||
|
|
e849b9cfb9 | ||
|
|
63baf47c96 | ||
|
|
2ee488d541 | ||
|
|
895c497f5d | ||
|
|
8201cd2d8c | ||
|
|
ac39b9ca34 | ||
|
|
6398cdece5 | ||
|
|
4bc7c6ae1f | ||
|
|
5509f8c15a | ||
|
|
580017f368 | ||
|
|
a58a73608c | ||
|
|
830c847d3b | ||
|
|
6f5f035b78 | ||
|
|
e523a3ae00 | ||
|
|
6526780f3b | ||
|
|
9bb68ade8a | ||
|
|
15f648588b | ||
|
|
0aa5cb5ea1 | ||
|
|
4cd49e9fa2 | ||
|
|
9532033277 | ||
|
|
c2453d6b8c | ||
|
|
7b7253b989 | ||
|
|
79e7c866e4 | ||
|
|
56bc46417e | ||
|
|
75bf9829a8 | ||
|
|
e5221b45e6 | ||
|
|
13d885b8b3 | ||
|
|
b2f75ec8bb | ||
|
|
8efdac7c5a | ||
|
|
42fcc22e39 | ||
|
|
bea6b9fed4 | ||
|
|
384ca762e8 | ||
|
|
4c88308fe2 | ||
|
|
bc2f510371 | ||
|
|
98cbb00c8f | ||
|
|
b3de2ed358 | ||
|
|
d51e569c59 | ||
|
|
e3bb33f4f0 | ||
|
|
1156168c21 | ||
|
|
1d4b470efa | ||
|
|
6164a0c5c2 | ||
|
|
e7e123be01 | ||
|
|
0f1b002108 | ||
|
|
7a8b6933f2 | ||
|
|
4895038bcb | ||
|
|
acd640479a | ||
|
|
4038a34ae6 | ||
|
|
f2bd051fa7 | ||
|
|
ca262d9839 | ||
|
|
0d69611bbf |
No files matched your search
+3
-2
@@ -78,8 +78,9 @@ CONTRIBUTING.md
|
||||
CONTRIBUTING-AI.md
|
||||
CODE_OF_CONDUCT.md
|
||||
SECURITY.md
|
||||
# LICENSE / LICENSE.md are deliberately NOT ignored: AGPL-3.0 conveys with
|
||||
# the software, so the licence text ships inside the image.
|
||||
# LICENSE / LICENSE.md are deliberately NOT ignored: the PolyForm
|
||||
# Noncommercial 1.0.0 notice terms require the licence text (or its URL)
|
||||
# to ship with every copy of the software, so it ships inside the image.
|
||||
AGENTS.md
|
||||
.planning
|
||||
docs
|
||||
|
||||
@@ -65,6 +65,14 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# Deploy webhook -- Coolify -> HealthLog auto-deploy feedback
|
||||
# -----------------------------------------------------------------------------
|
||||
# DEPLOY_WEBHOOK_SECRET=""
|
||||
# Optional: deployment-dashboard URL (e.g. the Coolify UI) linked in
|
||||
# deploy-failure notifications. When unset the notification omits the link.
|
||||
# DEPLOY_LOGS_URL=""
|
||||
# Optional replay hardening: require a fresh X-Deploy-Webhook-Timestamp
|
||||
# header (unix seconds / ms or ISO-8601, +/- 5 minutes) on every webhook
|
||||
# call. Leave unset for Coolify's stock sender (it attaches no
|
||||
# timestamp); see docs/ops/deploy.md.
|
||||
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="false"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -12,11 +12,11 @@ jobs:
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
@@ -27,7 +27,34 @@ jobs:
|
||||
|
||||
- run: pnpm test
|
||||
|
||||
- run: gh pr merge --auto --squash "$PR_URL"
|
||||
# Auto-merge is gated on the Dependabot-declared update type:
|
||||
# patch and minor bumps merge once required checks pass; major
|
||||
# bumps get a comment and wait for the maintainer. A major bump
|
||||
# routinely carries breaking changes the unit suite alone cannot
|
||||
# vouch for.
|
||||
- name: Fetch Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Enable auto-merge (patch / minor only)
|
||||
if: >-
|
||||
steps.metadata.outputs.update-type == 'version-update:semver-patch' ||
|
||||
steps.metadata.outputs.update-type == 'version-update:semver-minor'
|
||||
run: gh pr merge --auto --squash "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Flag major update for manual review
|
||||
if: steps.metadata.outputs.update-type == 'version-update:semver-major'
|
||||
run: >-
|
||||
gh pr comment "$PR_URL" --body
|
||||
"Major version bump (${DEP_NAMES}) — auto-merge is intentionally
|
||||
disabled for semver-major updates. Review the upstream changelog
|
||||
and merge manually."
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
DEP_NAMES: ${{ steps.metadata.outputs.dependency-names }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -20,6 +20,11 @@ on:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
# Default for any job that doesn't declare its own block; both jobs
|
||||
# below elevate to packages:write explicitly.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
@@ -72,10 +77,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
# GHCR rejects repository refs with uppercase letters. `IMAGE_NAME`
|
||||
# is derived from `github.repository`, which preserves the case of
|
||||
@@ -93,7 +98,7 @@ jobs:
|
||||
# Image is built but not pushed in that case.
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -101,13 +106,13 @@ jobs:
|
||||
|
||||
- name: Compute image labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ env.IMAGE_REF }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=HealthLog
|
||||
org.opencontainers.image.description=Self-hosted health-tracking PWA
|
||||
org.opencontainers.image.licenses=AGPL-3.0-only
|
||||
org.opencontainers.image.licenses=PolyForm-Noncommercial-1.0.0
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
|
||||
# v1.4.27 B3 — GeoLite2-City + GeoLite2-ASN databases are too large
|
||||
@@ -181,6 +186,19 @@ jobs:
|
||||
asn_size="$(stat -c%s assets/geolite2/GeoLite2-ASN.mmdb)"
|
||||
echo "::notice::GeoLite2 MMDBs verified — GeoLite2-City.mmdb ${city_size} bytes; GeoLite2-ASN.mmdb ${asn_size} bytes."
|
||||
|
||||
# Build provenance for /api/version: the short commit SHA and an
|
||||
# ISO-8601 build timestamp travel into the image as build-args
|
||||
# (NEXT_PUBLIC_APP_BUILD_SHA / NEXT_PUBLIC_APP_BUILT_AT) so the
|
||||
# deploy runbook can verify which commit a running container
|
||||
# actually carries — the `:latest` pull is the source of truth,
|
||||
# not the queued deploy status. 12 hex chars is plenty to be
|
||||
# unambiguous in this repository.
|
||||
- name: Resolve build provenance
|
||||
id: provenance
|
||||
run: |
|
||||
echo "build_sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
|
||||
echo "built_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Per-ref + per-platform gha cache scope. Tag builds warm-start
|
||||
# off main's last good layer set for the same platform; tag and
|
||||
# main builds at the same SHA do not contend on writes, and
|
||||
@@ -188,7 +206,7 @@ jobs:
|
||||
# arm64 layer never poisons an amd64 build).
|
||||
- name: Build & push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
@@ -203,6 +221,8 @@ jobs:
|
||||
# can never survive a cache hit into the new image.
|
||||
build-args: |
|
||||
NEXT_PUBLIC_APP_VERSION=${{ github.ref_name }}
|
||||
NEXT_PUBLIC_APP_BUILD_SHA=${{ steps.provenance.outputs.build_sha }}
|
||||
NEXT_PUBLIC_APP_BUILT_AT=${{ steps.provenance.outputs.built_at }}
|
||||
outputs: |
|
||||
type=image,name=${{ env.IMAGE_REF }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
|
||||
cache-from: |
|
||||
@@ -224,7 +244,7 @@ jobs:
|
||||
|
||||
- name: Upload digest artifact
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: digest-${{ matrix.cache_scope }}
|
||||
path: /tmp/digests/*
|
||||
@@ -248,17 +268,23 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# v1.16.4 — keyless image signing. The job requests an OIDC
|
||||
# token from GitHub's issuer; cosign exchanges it for a
|
||||
# short-lived Fulcio certificate bound to this workflow identity
|
||||
# and records the signature in the Rekor transparency log. No
|
||||
# long-lived signing key exists anywhere in this pipeline.
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Download digest artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
# GHCR requires the repository ref to be lowercase. See the same
|
||||
# step in the `build` job above for the longer rationale.
|
||||
@@ -267,7 +293,7 @@ jobs:
|
||||
echo "IMAGE_REF=${REGISTRY}/${IMAGE_NAME,,}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -275,7 +301,7 @@ jobs:
|
||||
|
||||
- name: Compute image tags & labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ env.IMAGE_REF }}
|
||||
tags: |
|
||||
@@ -299,7 +325,7 @@ jobs:
|
||||
labels: |
|
||||
org.opencontainers.image.title=HealthLog
|
||||
org.opencontainers.image.description=Self-hosted health-tracking PWA
|
||||
org.opencontainers.image.licenses=AGPL-3.0-only
|
||||
org.opencontainers.image.licenses=PolyForm-Noncommercial-1.0.0
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
|
||||
- name: Create multi-arch manifest
|
||||
@@ -313,15 +339,44 @@ jobs:
|
||||
run: |
|
||||
docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}'
|
||||
|
||||
# Phase C2 (v1.4.15): trigger Coolify deploy automatically once GHCR has
|
||||
# the new image. Removes the manual SSH + force-pull recipe documented
|
||||
# in `.planning/phase-8-report.md`.
|
||||
# v1.16.4 — sign the multi-arch manifest index (keyless, Sigstore).
|
||||
# Signing the INDEX digest covers both per-platform manifests in
|
||||
# one signature, and the digest is the only stable identity — every
|
||||
# tag (`:latest`, `:1.16.4`, …) is a mutable alias that a registry
|
||||
# compromise could repoint. Operators verify with:
|
||||
#
|
||||
# cosign verify \
|
||||
# --certificate-identity-regexp 'https://github.com/MBombeck/HealthLog/\.github/workflows/docker-publish\.yml@refs/tags/v.*' \
|
||||
# --certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
# ghcr.io/mbombeck/healthlog:<tag>
|
||||
#
|
||||
# See docs/ops/deploy.md ("Verify the image signature") for the
|
||||
# digest-pinned deploy recommendation that pairs with this.
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
|
||||
- name: Sign multi-arch manifest (keyless)
|
||||
env:
|
||||
IMAGE_VERSION: ${{ steps.meta.outputs.version }}
|
||||
run: |
|
||||
digest="$(docker buildx imagetools inspect "${IMAGE_REF}:${IMAGE_VERSION}" --format '{{json .Manifest.Digest}}' | tr -d '"')"
|
||||
if [ -z "$digest" ]; then
|
||||
echo "::error::Could not resolve the manifest digest for ${IMAGE_REF}:${IMAGE_VERSION} — signing aborted."
|
||||
exit 1
|
||||
fi
|
||||
echo "Signing ${IMAGE_REF}@${digest}"
|
||||
cosign sign --yes "${IMAGE_REF}@${digest}"
|
||||
echo "::notice::Image index signed (keyless): ${IMAGE_REF}@${digest}. Verify with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/${{ github.repository }}/' ${IMAGE_REF}@${digest}"
|
||||
|
||||
# v1.4.15: trigger the Coolify deploy automatically once GHCR has
|
||||
# the new image. Replaces the former manual SSH + force-pull recipe
|
||||
# on the deployment host.
|
||||
#
|
||||
# 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. Marc still has the
|
||||
# manual recipe as fallback.
|
||||
# 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
|
||||
@@ -377,8 +432,8 @@ jobs:
|
||||
# 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.
|
||||
# See `.planning/round-coolify-auto-deploy-fix-2026-05-16.md`
|
||||
# for the full root-cause + remediation note.
|
||||
# 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)…"
|
||||
sleep 90
|
||||
# v1.4.22 C3 — force=true tells Coolify to skip its image-cache
|
||||
@@ -405,8 +460,8 @@ jobs:
|
||||
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. Marc: deploy manually via SSH recipe in .planning/phase-8-report.md."
|
||||
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)."
|
||||
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, see .planning/coolify-auto-deploy-howto.md."
|
||||
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."
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Cancel in-progress runs when a new commit lands on the same PR.
|
||||
concurrency:
|
||||
group: e2e-${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -41,11 +44,11 @@ jobs:
|
||||
NODE_ENV: production
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
@@ -57,7 +60,7 @@ jobs:
|
||||
- run: pnpm db:migrate:deploy
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
@@ -74,7 +77,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright HTML report on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
|
||||
@@ -25,13 +25,13 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
@@ -6,16 +6,19 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
@@ -16,11 +16,11 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Scan src/ for TODO/FIXME/XXX/HACK markers
|
||||
run: |
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -15,11 +15,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
@@ -28,12 +28,10 @@ jobs:
|
||||
|
||||
- run: pnpm db:generate
|
||||
|
||||
# Blocking since v1.15.20 — the settings-split refactor that kept
|
||||
# this warn-only is long done and the tree lints clean.
|
||||
- name: ESLint
|
||||
run: pnpm lint
|
||||
# Non-blocking on the existing settings page monolith — its set-state-
|
||||
# in-effect violations need the 1.4.0 settings-split refactor to fix
|
||||
# cleanly. Will flip to blocking once settings/page.tsx is broken up.
|
||||
continue-on-error: true
|
||||
|
||||
- name: TypeScript strict check
|
||||
run: pnpm typecheck
|
||||
@@ -62,32 +60,37 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# Blocking since v1.15.20 — a HIGH/CRITICAL advisory against a
|
||||
# production dependency fails the build. If an advisory has no
|
||||
# upstream fix yet, pin an `auditConfig.ignoreCves` entry in
|
||||
# package.json (pnpm's documented suppression mechanism) with a
|
||||
# comment referencing the tracking issue, rather than flipping
|
||||
# this step back to warn-only.
|
||||
- name: Audit for known vulnerabilities
|
||||
run: pnpm audit --prod --audit-level=high
|
||||
continue-on-error: true
|
||||
|
||||
secret-scan:
|
||||
name: Secret Scanning
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Gitleaks secret scan
|
||||
uses: gitleaks/gitleaks-action@v3
|
||||
uses: gitleaks/gitleaks-action@e0c47f4f8be36e29cdc102c57e68cb5cbf0e8d1e # v3.0.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -97,7 +100,7 @@ jobs:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t healthlog:scan .
|
||||
@@ -109,6 +112,12 @@ jobs:
|
||||
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list
|
||||
sudo apt-get update && sudo apt-get install -y trivy
|
||||
|
||||
# Blocking since v1.15.20 — a HIGH/CRITICAL finding in the built
|
||||
# image fails the job. For a finding without an upstream fix
|
||||
# (e.g. a base-image CVE Alpine has not patched yet), add the CVE
|
||||
# id to a `.trivyignore` file at the repository root — Trivy reads
|
||||
# it automatically — one CVE per line, each with a comment naming
|
||||
# the tracking issue and the re-check condition. Never suppress a
|
||||
# finding that has a fixed version available; bump instead.
|
||||
- name: Trivy container scan
|
||||
run: trivy image --format table --severity CRITICAL,HIGH --exit-code 1 healthlog:scan
|
||||
continue-on-error: true
|
||||
+378
-51
@@ -2,6 +2,339 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.16.13] — 2026-06-14 — installable and offline, doses that find the right day, and headers that hold their line
|
||||
|
||||
### Added
|
||||
|
||||
- **The app installs and works offline.** The service worker now registers for every visitor, not only after notifications are switched on, so the app installs to the home screen, its shell and assets are cached, and a lost connection shows an offline page instead of a dead tab. Authenticated pages are never cached — their data always loads live — the share-by-link page is kept out of the cache entirely, and signing out clears the page cache. The running app still heals itself across deploys, and the cached-version marker can no longer drift stale between releases.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The default mood tags are curated.** The redundant `happy`, `excited`, water and music defaults step aside, `overtime` returns to the work picker, a new `praise` ("Lob") tag joins it, and the manual sleep-quality rating retires — a measured night already covers it. Tags already recorded against any of these keep resolving their labels; nothing on existing entries changes.
|
||||
- **Page headers hold one line on a phone.** The medications header no longer drops its actions to a second row, the insights "Ask the coach" action sits to the right where it belongs, and a long label in the more-menu stays inside its tile. The mood, measurements and dashboard headers get the same treatment so none of them can wrap either.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A historical dose edit credits the schedule that was live then.** Editing a past intake on a medication whose schedule has since changed now attributes it to the dosing era valid at the dose's own time rather than today's schedule — the same era the history and compliance already read it under.
|
||||
- **A CSV intake import moves the tracked stock.** It was the one path that recorded taken doses without decrementing the supply, so the runway read high; it now consumes exactly like every other path, once per dose, and a re-import never drains the shelf twice.
|
||||
- **A workout that reaches WHOOP late is no longer lost.** Workout ingest used a one-hour overlap window while recovery and sleep used a far wider one, so a phone that synced to the WHOOP cloud more than an hour after a session left the workout permanently outside the window. It now shares the wider window; the idempotent upsert keeps the widening from duplicating anything.
|
||||
- **Sleep night totals read as whole minutes** instead of a long fraction, matching the contract the clients decode.
|
||||
- **A tiles-only layout save no longer wipes section customization.** Reordering the navigation tiles without sending the section layout keeps the stored sections instead of resetting them to defaults, and the same holds the other way around.
|
||||
- **The web AI surfaces honour consent again.** The web client now records the consent receipt the server requires before any model call, so insights and the coach stop falling back to the empty no-provider state for people who have granted it, and the status reads "consent missing" honestly when it is genuinely absent.
|
||||
- **The coach button in the public demo no longer errors** — it steps aside there instead of offering a send the demo blocks.
|
||||
|
||||
## [1.16.12] — 2026-06-13 — split doses, steady cards, and a read that stays warm
|
||||
|
||||
### Added
|
||||
|
||||
- **A dose can consume a fraction of a unit.** Split-pill medications now declare ½, ⅓ or ¼ of a tablet per dose — the wizard offers a curated set of fractions alongside the whole numbers, the most error-resistant input. The supply tracks the fractional remainder (30 tablets dosed at a half each reads 29.5 after one dose), the days-left projection follows, and undoing a dose refunds exactly what it consumed. Existing whole-number doses are untouched; the inventory unit counts widen to decimals across the API contract. (#316)
|
||||
|
||||
### Changed
|
||||
|
||||
- **The medication cards hold one shape.** The low-supply notice moves onto the adherence row beside the streak — a slot every card already reserves — so a card that is low on stock no longer pushes its bars out of line with the card beside it. The gap between the drug class and the first line tightens at the same time.
|
||||
- **The dashboard and insights stay warm between visits.** The analytics and derived-insight reads keep a served-stale snapshot for an hour rather than ten minutes, so returning after a normal break paints instantly and refreshes in the background instead of paying the full cold rebuild — which a visit spaced more than ten minutes apart hit almost every time.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The medications list refreshes when you return to it.** Navigating back to the page — or reopening the supply tab — now refetches the list, compliance and stock instead of serving a client cache that could be minutes stale, so a dose logged on another device shows up without a manual reload.
|
||||
|
||||
## [1.16.11] — 2026-06-12 — the supply speaks up, tags find their groups, and nothing asks twice
|
||||
|
||||
### Added
|
||||
|
||||
- **The app says so before the pack runs out.** A daily pass projects every tracked supply against its schedule and notifies on the configured channels when the runway falls below a per-account threshold — once per crossing, re-armed by a refill, re-announced when the threshold changes. The threshold lives beside the reminder settings (1–60 days, default 7, or off), the push deep-links straight into the supply tab, the table's stock column carries the projection as "≈ X Tage", and the card states the remaining runway once it crosses the threshold — only then.
|
||||
- **Mood tags get groups, icons and a home of their own.** Custom groups with encrypted labels, a searchable 75-icon picker, and a manage page under settings; tags archive without touching the entries that carry them — editing an old entry can no longer drop its archived tags either — the picker offers inline creation right where a tag is missing, custom labels finally render in the picker AND across insights, and the arrangement persists per account. The whole surface ships in the public API contract.
|
||||
- **Every due dose in one confirmed action.** When two or more doses are due — including a dose escalated past its catch-up window — a button on the medications list collects them into one confirm dialog and records each through the same path as the card buttons: slot attribution, stock consumption and every dependent surface included; failures are counted, not hidden.
|
||||
- **A medication can declare itself as-needed.** No schedules, never due, never reminded, excluded from every compliance rate — but intakes log normally, consume inventory exactly like scheduled doses, and the history stays. The wizard offers it as a cadence choice; switching it onto a fixed schedule later never repaints the schedule-less stretch as missed doses, and the doctor report lists it without inventing a rate. (#316)
|
||||
- **Units-per-dose and the default pack size are editable from the supply tab** — a manufacturer switch with a different blister size no longer requires walking back through the wizard.
|
||||
|
||||
### Changed
|
||||
|
||||
- **One wrench marks every customize entry point.** Dashboard, insights and medications share the glyph, the table corners clip to their container again, and the card/table view choice moves into its own settings section.
|
||||
- **The coach opens in place.** The button brings the side drawer over the page you were reading instead of navigating away, and grew to a comfortable tap size.
|
||||
- **The card's dose state lives on the next-intake row.** Take-now, late and the overdue escalation speak from the line that names the dose — with their time beside them, on mobile too — instead of a separately reserved status line that left a hole on every settled card.
|
||||
- A source ladder saved before a newer integration existed now lists and ranks the later-added sources everywhere — settings, charts, rollups and the sleep picker — instead of silently pinning them below every ranked one.
|
||||
- WHOOP's self-reported profile weight is no longer ingested as a measurement; the stale entry stops resurfacing as the newest weight, and existing accounts heal on the next sync.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The hypnogram keeps its timeline when a summary writer joins the night.** A stage summary stamping all its rows on one instant no longer evicts a per-segment night; when only summaries exist, the card shows the stage breakdown instead of a fake skyline.
|
||||
- **A dose taken on the dashboard updates the dashboard.** The snapshot band refetches with every intake path instead of waiting out its polling interval.
|
||||
- The supply tab can delete a container, its controls carry the outline they always implied, each container states its condition inline next to its figures, and the table's stock warning follows the user's runway threshold instead of a leftover four-dose constant.
|
||||
- Every wide event carries the recent event-loop lag, and a stalled loop emits its own warning event — the instance-level signal per-request durations cannot carry.
|
||||
|
||||
## [1.16.10] — 2026-06-12 — the stock follows every dose, and the list takes your order
|
||||
|
||||
### Added
|
||||
|
||||
- **A medication can declare how many units one dose consumes.** Two 2 mg tablets for a 4 mg dose register as one dose taken and decrement two units, the supply dialog accepts quantities in doses or units with live conversion, containers carry a type (pen, ampoule, blister pack, inhaler, bottle) and hold up to 1000 units — a 200-dose inhaler canister finally fits. (#316)
|
||||
- **The medication list has a table view and an order of your choosing.** A toggle beside the add button switches between cards and a compact, per-column sortable table; both views share a manual order, and the whole arrangement lives in its own settings section behind the same gear icon the dashboard and insights pages use. The choice persists per account. (#316)
|
||||
|
||||
### Changed
|
||||
|
||||
- **The daily-overview band is opt-in now.** New accounts and accounts that never chose it start without the band and keep the plain greeting; it switches on under dashboard settings.
|
||||
- **Expired containers count as available nowhere.** Every surface — overview row, supply tab, list, table, GLP-1 card and the runway estimate — agrees on the same figure and shows expired stock separately instead of mixing it in.
|
||||
- The wellness rings sit exactly on the theme palette now — readiness green, recovery cyan, sleep purple, stress orange, strain pink — instead of neighbouring tones, and the light theme follows the same five hue families.
|
||||
- The inventory endpoints speak one wire dialect: request fields renamed to `unitsTotal` / `unitsRemaining` to match the responses they always returned.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Every taken dose now moves the tracked stock.** Only one of the six intake paths decremented the supply before — web, the status toggle, the phone sync, the external API and the Telegram button all consume now, the next container opens automatically, and undoing, skipping or deleting a dose refunds exactly what it consumed. Each dose carries its consumption stamp, so a double tap racing a sync replay decrements once, and a re-posted history never drains the shelf. Doses recorded before this release stay untouched — the count starts moving with the next dose, or correct a container once under the supply tab. (#316)
|
||||
- **A dashboard full of readings no longer claims "not enough data".** While the score warms up the band says it is being computed, and a first reading of a new data type — a fresh wearable metric, a first glucose value — can no longer knock the score out, because the gate checks only the types the score actually reads.
|
||||
- **The score stops flickering on busy accounts.** The pre-aggregation rebuild deleted its window before inserting the replacement without a transaction — a concurrent sync write could abort the insert and leave whole types empty until the next pass. Delete and insert run atomically now, and the boot-time backfill shares the fold horizon, so an account with years of history stops re-backfilling on every deploy.
|
||||
- **The filter pills on the mood and measurements lists open their menu on screen again.** The menu opened off-viewport below the page since v1.16.1 — selecting a mood, type or source read as a dead control.
|
||||
- **The coach button stops blinking out on chart hovers.** It no longer hides whenever a chart tooltip is open anywhere on the page, and it sits evenly in the corner.
|
||||
- The pre-item GLP-1 supply ledger reads again: accounts that tracked pens through the older ledger see their count on the card and in the coach until they register containers, which then take over.
|
||||
|
||||
## [1.16.9] — 2026-06-12 — the day opens with a verdict, and every dose path tells the truth
|
||||
|
||||
### Added
|
||||
|
||||
- **The dashboard opens with a daily band.** Greeting, the one item that matters right now — a fresh crisis-level blood-pressure reading, an overdue or upcoming dose, weight drifting from its range, a run of short nights, a quiet stretch, a score drop, or the briefing's key finding — with a single action, today's dose tally beside the next due time, and the health score as a ring. The verdict resolves from a fixed ladder with crisis floors that ignore personal target ranges, it never invites a dose the server has not called due, and the band can be turned off under dashboard settings, where the plain greeting then returns to the header.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **An intake recorded through the external API now credits the due slot.** The ingest endpoint was the one intake path that stored a bare row beside the pending slot — the card showed the intake as the last dose while still demanding it, and the ledger later counted a miss. It attributes to the dose band like every other path now, the Telegram confirm and skip buttons converge onto the reminder's own slot, and a nightly pass heals the pairs this has already produced — both days' history and compliance recompute, no manual repair needed.
|
||||
- **A cached page can no longer contradict an action you just took.** Invalidation now fences in-flight cache builds, so a read can neither join a build that started before your write nor have a background refresh re-store the pre-write state as fresh.
|
||||
- **Dose prompts err on the safe side everywhere.** A take shortly before the window credits the slot instead of floating loose, an off-schedule intake can no longer silently resolve a different slot and hide a genuinely due dose, a long-interval dose taken on an earlier day downgrades the slot-day prompt to "last dose n days ago" instead of inviting a double dose, and the overdue badge no longer disappears because the day's pending entries were counted as actions.
|
||||
- Intake times entered retroactively carry the same plausibility bounds as edits, moving a dose across a slot boundary keeps its recorded injection site and dose, both affected days recompute, and schedule corrections, data wipes and backup restores refresh the caches they feed.
|
||||
- The medication cards, the quick-entry preselection and the dose pills compute their clock in the profile timezone instead of a fixed one.
|
||||
- WHOOP credentials are trimmed on save — a stray space from the portal's copy button read as an unknown client — and an empty redirect override falls back to the derived callback URL.
|
||||
|
||||
## [1.16.8] — 2026-06-12 — texts that follow your data, charts that never strand
|
||||
|
||||
### Added
|
||||
|
||||
- **The coach is one tap away on every page.** A round button sits bottom right, stays put while you scroll, shows a quiet dot when a nudge is waiting, and steps aside for the selection bar and the welcome tour.
|
||||
- **Tell the coach to remember something.** A remember action under your own chat messages stores the statement in the matching self-context field — allergies land under allergies, diagnoses under conditions — visible and editable in the settings, and fed into every future conversation. Statements phrased casually are recognised again, and remembered facts no longer drop out of the prompt on data-rich accounts.
|
||||
- **Mood notes in full.** Notes expand to their complete text right in the list — the toggle only appears when something is actually clipped — and the CSV export carries a note column.
|
||||
- **The hypnogram names its source** and paints only the stages the night actually contains.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Insight texts regenerate when your data changes, not on a timer.** Every generated text stores a fingerprint of the data it describes; nightly runs, page visits and sync bursts skip the model call when nothing moved. A notable new reading is narrated the same morning, a manual regenerate refreshes the cards along with the briefing, and texts can no longer claim freshness over readings that crossed a staleness boundary. An active day drops from hundreds of model calls to a handful.
|
||||
- **Medication cards arrive in one piece.** All cards share a single compliance request instead of one each, the heavy date math runs about five times faster, and the status line, adherence bars, streak and cycle line keep reserved slots — nothing shifts when data lands. A dose logged from the phone app is reflected immediately.
|
||||
- **A cold start paints the page you'll actually get.** The chart row reserves its space before any data arrives, the tile silhouettes match the real layout, and the health-score card holds its column while the numbers compute. The insights overview runs its reads in parallel and serves repeat visits from cache — the targets and derived endpoints answered in well over a second before.
|
||||
- **Sleep nights pick the richest recorder.** Several apps writing through the same health platform no longer blend into one night — the writer with actual stage data wins, time in bed unions every writer's window, and nights that reach the sleep service late are fetched up to seven days back, so a wrong night heals itself on the next sync.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Charts no longer strand on a skeleton after a deploy: the update check re-arms per version, a failed chart chunk retries and then degrades to a single card with a reload button, stalled requests time out and retry, and a failed data read shows an error with a retry instead of "no data in this period".
|
||||
- Screen readers hear chart failures and coach nudges, the floating button leaves the tab order when it hides, focus survives the remember confirmation, and the icon cluster on insight pages no longer has overlapping touch areas.
|
||||
- The duplicated customize entry and the per-chart target link are gone, show-all-values became an icon beside the target control, pill borders stop clipping in the tab strip, and the mood tag bars adopt the muted palette of the charts above them.
|
||||
- Changing a target range, profile detail or source priority is reflected in the analytics on the next read, AI consent is honoured before any stored text is reused, and CSV exports neutralise spreadsheet formula prefixes in free-text cells.
|
||||
|
||||
## [1.16.7] — 2026-06-11 — insights answer instantly, every evening
|
||||
|
||||
### Changed
|
||||
|
||||
- **Insights always answer instantly.** A stale briefing is served immediately with a regeneration kicked off in the background — the response carries a revalidating flag and the open page polls briefly until the fresh content lands, instead of blocking the visit on up to ten seconds of inline generation.
|
||||
- **The nightly pre-warm no longer skips evening readers.** The staleness threshold drops from twenty hours to one — previously an evening visit stamped the cache fresh, the night run skipped it, and it expired exactly the next evening, so the people who read insights after work were the ones who always hit cold generation. The visit-triggered warm fills both locale families and collapses to one job per user.
|
||||
- **The session resolves faster.** The profile read inside `/api/auth/me` runs in parallel with the cookie refresh, the bug-report status answer comes from a ten-minute cache without a write per request, and the medications page is prefetched the moment its nav entry is touched.
|
||||
- **The insights route ships less JavaScript.** The schema validator loads lazily and the mood charts split out of the main chunk, trimming the cold download that dominated an uncached first visit.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A manually entered measurement is reflected in the analytics aggregates on the next read — interactive writes evict the cache instead of marking it stale, while background sync keeps the cheaper path.
|
||||
- The analytics script proxy degrades to a silent no-op instead of failing every page when the upstream is unreachable, and records the failure reason where the operator can find it.
|
||||
|
||||
## [1.16.6] — 2026-06-11 — corrected eras, an honest weekly status and a faster first paint
|
||||
|
||||
### Added
|
||||
|
||||
- **Schedule eras can be corrected.** A recorded era can be edited through a superseding revision that keeps the original as an audit trail; manual eras edit in place, all era writes serialise per medication, and duplicate dose times collapse in validation.
|
||||
- **The server-wide assistant key has an admin home** — status, provider, model and base URL with the BYOK-first and consent rules stated where the operator works.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The first paint got faster again.** The dashboard snapshot fires the moment login succeeds instead of waiting for the page chunk — its request starts about half a second earlier under throttled conditions — handed over so the server and client render never disagree.
|
||||
- **Guided coach follow-ups speak to your data.** Questions are personalised from your own snapshot when a provider is available, and an answer receives the coach's streamed reaction before the next question.
|
||||
- **Settings and admin finally share one face.** Twenty-seven hand-rolled card headers move onto the shared pattern, sixty containers normalise to one chrome, descriptions get a consistent one-sentence voice, and the one duplicated coach preference collapses onto a single owner.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A rolling medication whose next dose is tomorrow no longer reads as heavily overdue — the card status derives strictly from the served next-due.
|
||||
- The adherence bars keep a fixed geometry in every state instead of growing with their status text.
|
||||
- The remaining hydration warnings on the dashboard, insights and AI settings are gone.
|
||||
|
||||
## [1.16.5] — 2026-06-11 — a guided coach dialog and a schedule that shows its past
|
||||
|
||||
### Added
|
||||
|
||||
- **Open coach follow-ups run as a guided dialog.** A quiet card offers to start, the coach asks one question at a time with visible progress, answers can be adopted into the matching self-context field, skipping keeps a question pending, and a closing summary says what was taken over.
|
||||
- **The schedule shows its history.** Past eras appear as a collapsible timeline on the schedule tab, and an earlier era can be added manually for old history — validated against overlaps and bounded so it can never swallow tracked live history.
|
||||
- **Smarter proactive nudges.** Three new deterministic signals — weight drifting from its target band, a run of short nights, an active user falling silent — plus per-group toggles, a frequency preference and wording that addresses the focus you asked the coach to watch.
|
||||
- **Invites mint up to fifty uses** with a scrolling redemption list.
|
||||
|
||||
### Changed
|
||||
|
||||
- **A documented path away from the moving latest tag.** The compose image reference accepts a pinned digest via env var, and a promote script resolves the published digest, verifies its cosign signature and hands the operator the exact pinning call.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The self-context adopt runs in one transaction, the admin area renders nothing before the role is confirmed, and the json-body helper keeps caller headers intact.
|
||||
|
||||
## [1.16.4] — 2026-06-11 — half the bundle, a typed client, and stock you can record
|
||||
|
||||
### Added
|
||||
|
||||
- **The inventory tab records stock** — packs and pens with optional expiry, corrections and withdrawals. The detail page gains a dedicated API tab, an editor entry under lifecycle, and a danger zone without the red drama.
|
||||
- **Record the dose you actually took.** Intakes carry an optional actual dose, editable in the quick-add and log dialogs and shown as a quiet deviation in the history.
|
||||
- **Undo for deletes.** Removing a measurement or mood entry can be taken back from the toast.
|
||||
- **Pull-to-refresh** on the measurement and mood lists, and the customize entry on every insights sub-page.
|
||||
- **Answered coach follow-ups can flow into your self-context** with one tap.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The app got dramatically lighter and faster.** Only the active language ships to the browser — the dashboard route drops from 2.1 MB to under 1 MB parsed (288 KB gzip), with no language flash. The shell paints immediately while the session resolves, a redundant request stage is gone, and insight sub-pages prefetch on approach: a first tab switch lands in tens of milliseconds instead of nearly a second.
|
||||
- **Every client call goes through one typed API client** with the envelope, error and ok-handling in a single place, enforced by lint. The route registry, query keys, background worker and dashboard page split into feature-located modules behind thin indexes.
|
||||
- **Settings behave one way.** Provider and integration cards live in focused subcomponents, every setting persists server-side at exactly one home, save conventions are documented, and duplicated controls collapsed into single owners with links.
|
||||
- **Published images are signed**, dependabot merges only behind the required checks, a pre-commit secret scan guards every commit, the deploy webhook can demand a fresh timestamp, and a monthly drill proves the newest backup actually decrypts.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Server and first client render now agree everywhere query state used to leak into the chrome.
|
||||
- Delete confirmations admit the undo they offer, step counts drop their decimals, streaks know their singular, the medication header speaks your date format, and a failed coach turn keeps your message and says what went wrong.
|
||||
- A language switch updates pending status notes and only flips once the new language is actually loaded.
|
||||
|
||||
## [1.16.3] — 2026-06-10 — schedules remember their history
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Changing a schedule no longer rewrites the past.** Every material cadence change now archives the outgoing schedule as a dated revision, and history, compliance and write attribution evaluate each period against the schedule that was valid at the time — a dose era taken at its then-correct times stays on time forever. The repair script can infer historical eras from consistently dated old anchors behind an explicit flag.
|
||||
- **The welcome tour can no longer strand its buttons below the fold.** The target scrolls into view first, the popover flips above it when space runs out, and a centred sheet takes over on any viewport when neither fits.
|
||||
- The avatar menu drops its about entry; the section lives in settings.
|
||||
|
||||
## [1.16.1] — 2026-06-10 — a chat worth using, honest sleep stages, stock you can record
|
||||
|
||||
### Added
|
||||
|
||||
- **The inventory tab records stock.** Packs and pens with optional expiry, plus corrections and withdrawals — no API detour. The medication detail page also gains a dedicated API tab, an editor entry under lifecycle replacing the misleading header button, and a danger zone on the shared card surface with only its buttons in red.
|
||||
- **A shared filter bar.** Measurements and mood lists filter through one pill-rail language — active filters read as removable chips, date ranges live in a popover, every page feels the same.
|
||||
- **Six care achievements.** Miss-free dose streaks (7/30/90 days), a four-week measurement routine, a complete self-context and a week of sleep logging.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The coach chat behaves like one.** Single-line composer that grows with the text, a typing indicator, no inline disclaimer, a context rail that hides until asked, conversations in the full view, and coach preferences in Settings. Clearly stated allergies and conditions are remembered immediately.
|
||||
- **Settings and admin share one alignment language.** Icon column left, content flush on its own column, neutral header icons, no duplicated titles, and every select on the account-style idiom with a visible chevron.
|
||||
- **First paint shows silhouettes, not bare cards**, and charts reveal within 1.2 seconds even when one widget lags.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sleep stages show again next to coarse sources.** A night carrying granular watch stages alongside coarse phone samples picked the coarse source and showed only awake/asleep; the granular source now wins its session.
|
||||
- **The next-dose line tells the truth after a schedule change.** It reads from the dose calendar instead of a stale schedule window; the repair script reconciles drifted windows and clears pending rows on abandoned anchors.
|
||||
- **Nightly insight texts are actually there in the morning.** The warm pass no longer skips the status cards when the briefing step is budget-blocked or fails, and one hanging provider cannot stall the whole run.
|
||||
- The welcome tour stays inside the viewport — no more sideways scrolling to reach its buttons, with a bottom-sheet fallback on narrow screens.
|
||||
- The dashboard customize icon matches insights, the target-settings link leaves the home charts, and the mood heatmap adopts the damped fills of its sibling charts.
|
||||
|
||||
## [1.16.0] — 2026-06-10 — a coach that knows you, invites, and a faster, calmer app
|
||||
|
||||
### Added
|
||||
|
||||
- **Tell the assistant about yourself.** A new encrypted self-context in Settings → AI — free text plus structured fields for conditions, allergies and what the coach should watch — feeds the coach and the daily briefing as clearly fenced context. After saving, the coach may ask up to three targeted follow-up questions, surfaced as tappable chips above the chat composer.
|
||||
- **Proactive coach nudges.** A nightly pass watches four signals — low 7-day adherence, blood pressure above its target band, a falling recovery trend, and a stale self-context — and sends at most one gentle nudge per week through your configured notification channels, with its own opt-out. Off whenever the coach is disabled.
|
||||
- **Invite links with QR codes.** Operators on closed registrations can mint expiring invite links from a new admin section: status, uses, expiry and a redemption history of who joined when. Revoking keeps the history; the link and QR are shown exactly once at creation.
|
||||
- **Attribute an off-schedule dose to its slot.** History rows that landed off schedule now show when the dose had been due and offer to attribute the intake to that slot — visibly badged, always counted as taken late, reversible at any time.
|
||||
- **A time-format preference.** Automatic (follows the language), 24-hour, or 12-hour — set once in the profile, applied to every clock the app renders.
|
||||
- **Insight pills are manageable in one place.** The settings list that orders the navigation pills now also hides and shows them, with the same eye toggle the overview tiles use; the page-side manager folded into a link.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The medication detail page calms down.** Six tabs instead of seven (reminders live with the schedule they gate), every section on the card surface, an overview tab that actually summarises — next dose, reminder state, supply runway — and a history where settled rows carry one quiet menu instead of a button wall. Charts load on demand.
|
||||
- **The dashboard loads as one.** Structured skeletons replace bare tiles, and charts reveal together once their data is in instead of popping in one after another.
|
||||
- **The medication list stops hammering the server.** Compliance for the cards is computed in one calendar pass per request, cached for fifteen minutes and invalidated on every write; the dashboard drops two redundant queries; the BMI chart reuses the weight series; expensive analytics endpoints gain a shared per-user rate limit.
|
||||
- **Nightly assistant runs respect every gate.** Status generation skips users who disabled the coach and honours the operator kill switch, deleted measurements never feed a prompt, the mood card gets the nightly run the other cards always had, non-German locales consistently fall back to English, and worker failures finally reach the error tracker with real retries behind them.
|
||||
- **The README says what matters in one screen**, and the repository description and topics match it.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The wellness ring's glow is no longer clipped to the circle.
|
||||
- An overflowing insight-pill row is reachable by mouse — chevrons and wheel scrolling joined touch.
|
||||
- Safari favourites no longer render the icon on a white plate.
|
||||
- A rate-limited briefing regenerate stops claiming success, and a missing AI provider shows as exactly that instead of an eternal "preparing".
|
||||
- Editing schedule times migrates the open dose rows of the old anchors and reconciles a stale intake window instead of stranding both.
|
||||
- Auto-missed verdicts respect each schedule's real catch-up window, clear when the dose is taken after all, and skip deleted rows.
|
||||
- The intake repair script runs inside the production container.
|
||||
- Every workflow action is pinned, dependabot merges only patch and minor updates behind required checks, vulnerability gates block instead of warn, and the published image carries its build SHA so a deployment can prove what is running.
|
||||
|
||||
## [1.15.19] — 2026-06-10 — honest dose states, bounded bodies, new licence
|
||||
|
||||
### Changed
|
||||
|
||||
- **The project licence is now PolyForm Noncommercial 1.0.0.** HealthLog stays free to use, modify and self-host for noncommercial purposes; commercial use needs a separate arrangement. Releases up to and including v1.15.18 were published under AGPL-3.0 and remain available under that licence.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **An open dose no longer reads as missed the moment its time passes.** A pending dose now shows as upcoming until its intake window actually closes — hours for daily schedules, days for weekly ones — so the history tab and the compliance rate stop punishing doses that are still takeable. Phantom "ad-hoc" lines for tonight's not-yet-due doses are gone too.
|
||||
- **Logging the same dose from two places no longer doubles it.** A dose acknowledged via a reminder and also logged from another client produced two live rows on the same slot, inflating the day's schedule count (4 of 4 on a two-dose day). Intake writes now converge onto the existing slot row regardless of source, the compliance rollup counts slots instead of rows — which also corrects historical days on the next recompute — and the duplicate sweep runs nightly instead of only at worker start.
|
||||
- **A weekly dose taken a few days late counts as late, not missed.** The write path only searched ±1 day around the intake time for a matching slot, so a weekly injection taken inside its multi-day catch-up window was stored as off-schedule while the slot stayed missed. The search now covers the schedule's full window reach.
|
||||
- **An intake edit can no longer park a dose on an impossible date.** Editing a dose's time now rejects future timestamps and dates before the medication existed, the picker caps at now, the dialog shows the scheduled slot next to the field and warns when the new time sits more than 48 hours away from it — a day/month typo no longer corrupts the today view silently. The edit dialog's note field is removed; it was never saved.
|
||||
- **One poisoned day no longer halts the nightly consolidation for everyone.** The daily-mean pass could collide with a row parked exactly on its canonical timestamp and abort the whole run on the first conflict, leaving every later user and metric unconsolidated night after night. Collisions now resolve deterministically and a failing day is logged, counted and skipped.
|
||||
- **Every JSON request body is now size-bounded.** Raising the upload limit for large Apple Health archives had removed the implicit ceiling on all other JSON routes; each one now enforces an explicit cap sized to its payload and answers 413 beyond it.
|
||||
|
||||
### Added
|
||||
|
||||
- **An operator repair script for historical intake anomalies.** `scripts/repair-intake-anomalies.ts` lists duplicate rows sharing a dose slot and intakes whose taken time sits implausibly far from their slot; with `--fix` it merges the duplicates the same way the nightly sweep does and recomputes the affected compliance days. Runbook: `docs/ops/intake-repair.md`.
|
||||
|
||||
## [1.15.18] — 2026-06-08 — a traceable medication dose history
|
||||
|
||||
### Added
|
||||
|
||||
- **A traceable dose history for every medication.** The Verlauf tab now shows every expected dose slot with a clear status — taken on time, taken late, skipped, or missed — and tags any off-schedule intake as ad-hoc. The history reads from the same ledger the compliance percentage is built on, so the rate you see can no longer disagree with the timeline beneath it.
|
||||
- **A configurable intake window per dose.** Each dose time can carry either a point time or an explicit range (for example 07:00–09:00); the default stays ±1 hour. The window defines the on-time band, so a take that lands inside it counts as on time rather than late. The window is editable on the schedule and in the medication wizard.
|
||||
- **An Insights settings page.** A cog at the top right of the overview opens a dedicated Insights section in Settings, where you arrange the overview sections and sort the navigation pills. The old inline "Anpassen" toggle is gone — the cog is the single entry point.
|
||||
- **A dose-history endpoint.** A read endpoint returns the resolved per-slot status over a date range, so the history view and any client render the same picture.
|
||||
|
||||
### Changed
|
||||
|
||||
- **A redesigned medication detail page.** The detail page is now a tabbed layout — Übersicht, Zeitplan, Erinnerung, Bestand, Verlauf, Erweitert — and the old stacked advanced-settings modal is dissolved into tidy, grouped sections under Erweitert. Card actions open straight to the matching tab.
|
||||
- **The medication card keeps a constant surface.** The card no longer changes colour by dose status; the current status shows as a discreet label instead of tinting the whole card.
|
||||
- **Intake writes and edits attribute by the real intake time.** Marking a dose taken records it against the slot whose window it falls in; a take outside every window records as ad-hoc instead of snapping to a far-off slot. Editing a dose re-attributes it, a near-miss take offers "attribute to this dose anyway?", and marking taken or skipped recomputes the history and rate immediately.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The daily briefing's regenerate no longer times out before the text is ready.** A slow but successful regeneration used to be discarded by a short timeout, leaving the old text in place while the toast still claimed success. The regenerate path now waits for the warm text and the toast is honest — it reports a refresh only when one happened, and tells you to try again when generation is still running.
|
||||
- **The sleep-phase chart no longer double-counts time in bed.** Time-in-bed was being stacked as if it were a separate phase on top of the individual phases, roughly doubling the bar and the per-night total. It is no longer counted as a phase, so the bar and tooltip total read correctly.
|
||||
|
||||
## [1.15.17] — 2026-06-07 — large imports, demo chart toggles
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Large Apple Health exports import correctly.** A request-body size limit in the middleware layer truncated uploads over ~10 MB before they reached the importer, so a multi-megabyte `export.zip` lost its ZIP structure and failed with an "end-of-central-directory" error. The limit is raised so real exports arrive intact. (GitHub #281)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chart display toggles work in demo mode.** On a demo instance, the comparison-baseline selector and per-chart overlay toggles above charts are no longer blocked — these are harmless display preferences.
|
||||
|
||||
## [1.15.16] — 2026-06-07 — wellness ring glow no longer clipped
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The score ring's glow shows in full.** On a wellness score detail page the ring's soft glow was being clipped at the card edge. The card no longer clips its contents (its background gradient still follows the rounded corners), so the glow renders uncut on every side.
|
||||
|
||||
## [1.15.15] — 2026-06-07 — one warm, motivating assistant voice
|
||||
|
||||
### Changed
|
||||
|
||||
- **The whole assistant speaks in one voice.** The daily briefing, key findings, the insight write-ups, and the Coach now share the same warm, motivating, advisory tone the metric and wellness assessments adopted last release — encouraging and grounded in your data, honest about what needs attention, never generic. (Already-generated briefing text refreshes within about a day; the Coach speaks the new way immediately.)
|
||||
|
||||
## [1.15.14] — 2026-06-07 — manage every detail page, calmer mood charts
|
||||
|
||||
### Added
|
||||
|
||||
- **Manage every detail page from the overview.** "Anpassen" → "Detailseiten verwalten" now lists every insights sub-page, grouped by category, so you can reorder and hide any of them — not just the vitals tiles. The top navigation follows your choices: hide a page and its pill leaves the nav too.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Calmer, more compact mood charts.** The mood distribution and weekday-average charts on the mood page are smaller and use softer colour, so the page reads more easily. Their axis labels are now legible in the light theme too.
|
||||
|
||||
## [1.15.13] — 2026-06-07 — search, filter, and bulk delete
|
||||
|
||||
### Added
|
||||
|
||||
- **Filter your measurements and mood entries.** Both lists now filter by source (manual, Apple Health, Withings, …) and by a date range, alongside the existing type / mood filter and sorting.
|
||||
- **Select and delete in bulk.** Tick several entries on a page and remove them in one step, with a confirmation step. Deletions stay reversible on your devices the same way a single delete does.
|
||||
|
||||
## [1.15.12] — 2026-06-07 — a fairer health score and a richer overview
|
||||
|
||||
### Fixed
|
||||
@@ -943,14 +1276,14 @@ v1.5.5 gave every retired medication feature a home on the detail page, but the
|
||||
|
||||
- 5615 → 5635 unit; 1 skipped. Detail-page history surface, advanced-settings sheet, intake-edit seed, and legacy step consolidation are covered; the pre-tag reconcile sweep keeps the suite green.
|
||||
|
||||
## [1.5.5] — 2026-05-29 — Medication detail page, iOS coord wave, outbound-fetch hardening
|
||||
## [1.5.5] — 2026-05-29 — Medication detail page, iOS coordination, outbound-fetch hardening
|
||||
|
||||
The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 features down with the retired flat form — Einnahmen bearbeiten / löschen, Medikament pausieren / beenden / löschen, per-Med API tokens, Phasen-Konfiguration, CSV-Import — and the trends-row on Insights had a 34 px overflow that pushed annotation copy on top of the charts. This release lands the medication detail page that gives every retired feature a coherent home, polishes the wizard against the live walk-through feedback, closes the iOS audit follow-ups in one go, and hardens every outbound fetch the server makes.
|
||||
|
||||
### Added
|
||||
|
||||
- **`/medications/[id]/page.tsx`** — a single Server-Component detail page composed of eight sections (Header band → Today's-dose → Cadence summary → Phasen (GLP-1) → Intake history preview → Notifications → Settings → Verwaltung & Gefahrenzone). One-shot medications walk a five-section variant; paused medications keep the structure but the status pill flips to `Pausiert` and the dose-card surface mutes. Restores every one of the 16 features the v1.5.4 flat-form retirement displaced.
|
||||
- **Per-row + bulk intake history actions** — the preview now ships a per-row kebab (`Bearbeiten` / `Löschen`) and a multi-select toolbar that fires the new `POST /api/medications/{id}/intake/bulk-delete` endpoint. Marc's „Einnahmen löschen" + „Einnahmen bearbeiten" complaints from the v1.5.4 walk-through close cleanly.
|
||||
- **Per-row + bulk intake history actions** — the preview now ships a per-row kebab (`Bearbeiten` / `Löschen`) and a multi-select toolbar that fires the new `POST /api/medications/{id}/intake/bulk-delete` endpoint. The maintainer's „Einnahmen löschen" + „Einnahmen bearbeiten" complaints from the v1.5.4 walk-through close cleanly.
|
||||
- **`safeFetch` wrapper** at `src/lib/safe-fetch.ts` — every outbound fetch the server makes for user-supplied hosts now inherits `redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults. Closes #218.
|
||||
- **DNS-rebinding pinned `undici.Agent`** at `src/lib/safe-fetch-dispatcher.ts` — when `requirePublicHost: true`, the dispatcher resolves the hostname literally inside the connect hook, refuses any address `isPublicIp` would reject, and pins the connection to the first valid public address. The five user-host paths (MoodLog sync + push + test, local-AI client, ntfy) route through it. Closes #217.
|
||||
- **Self-hosted avatar storage** at `POST/GET/DELETE /api/user/avatar` — multipart-uploaded JPEG/PNG/WebP, 2 MiB / 2048×2048 max, hand-rolled magic-byte sniff + dimension probe so no native dependency lands. Stored on the User row as BYTEA; rides `pg_dump` alongside the rest of the row. The profile response carries an `avatarUrl` with a cache-busting timestamp. `src/lib/gravatar.ts` retires — Automattic no longer sees the email-hash on every authenticated page load.
|
||||
@@ -972,7 +1305,7 @@ The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 featu
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Trends-row text overlapping the charts** — Marc's specific Insights complaint. Chart slot was 140 px but the mini-card shell painted ~174 px; the 34 px overflow pushed annotation copy onto the chart envelope. The 180 px slot accommodates the full envelope.
|
||||
- **Trends-row text overlapping the charts** — a specific Insights complaint from a user report. Chart slot was 140 px but the mini-card shell painted ~174 px; the 34 px overflow pushed annotation copy onto the chart envelope. The 180 px slot accommodates the full envelope.
|
||||
- **Grace-row save dead on arrival** — the detail page Settings section PUTs `reminderGraceMinutes` at the top level; the route now normalises the value onto the primary schedule before the Prisma update. The schema declares the top-level field with a description noting the normalisation.
|
||||
- **Purge route did not invalidate server caches** — Tier-3a `Verlauf löschen` dropped the rollup rows but left the analytics + iOS today-tally caches with the pre-purge counts for up to their TTL. The success path now calls `invalidateUserMedications(user.id)` alongside the rollup delete.
|
||||
- **Bi-weekly worker still emitted every Wednesday** — the v1.5.3 cadence engine fix was correct, but the cadence chart + medication card on the dashboard still read the legacy `daysOfWeek` column. The v1.5.x window keeps this in place; the read-flip arrives in v1.5.6. Operators see correct reminder fan-out today; the dashboard chip cosmetic catch-up follows.
|
||||
@@ -989,11 +1322,11 @@ The v1.5.4 modal wizard landed the create + edit plan flow, but it took 16 featu
|
||||
- `safeFetch` + DNS-rebinding pinned dispatcher close issues #217 + #218 architecturally — the input-time `isPublicUrl` guard now pairs with a connect-time IP pin so DNS rebinding cannot flip the resolved host between accept and dispatch.
|
||||
- Avatar route enforces size before parse (Content-Length pre-flight + post-parse `file.size` check), magic-byte sniff over the declared content-type, dimension probe, owner-scoped on every method. No new XSS vector — the served content-type is whitelisted to the three image MIMEs.
|
||||
- `assertMedicationOwnership` sweep closes every detail-page route's ownership narrowing — the route layer is now the single ownership predicate across `src/app/api/medications/[id]/**`.
|
||||
- Pre-tag senior-dev + security architect audit produced two docs at `.planning/medication-detail-page-2026-05-28/F-{1,2}-*.md`. The four senior-dev Criticals + five Highs landed in code; F-1 H-5 (`safeFetch` migration to constant-host call sites like Withings + Codex + the GitHub bug-reporter) + F-2 M-1..M-3 (operator-host `requirePublicHost`, avatar chunked-body pre-flight, raw-fetch lint rule) deferred to v1.5.5.1.
|
||||
- Pre-tag senior-dev + security architect audit produced two docs (internal working notes). The four senior-dev Criticals + five Highs landed in code; F-1 H-5 (`safeFetch` migration to constant-host call sites like Withings + Codex + the GitHub bug-reporter) + F-2 M-1..M-3 (operator-host `requirePublicHost`, avatar chunked-body pre-flight, raw-fetch lint rule) deferred to v1.5.5.1.
|
||||
|
||||
### iOS coord
|
||||
|
||||
- The iOS team's v0.8.0 audit closed cleanly. Detailed acknowledgements at `.planning/ios-coord/v155-wire-six-deferred-identifiers.md` + `.planning/ios-coord/v155-step-length-speed-followup.md`.
|
||||
- The iOS team's v0.8.0 audit closed cleanly. Detailed acknowledgements live in the internal iOS coordination notes.
|
||||
- The iOS team flips `walkingAsymmetryPercentage` + `walkingDoubleSupportPercentage` from pre-multiplied to raw in their next release; until then the server fails-closed (`skipped:"value_out_of_range"`) on > 100% values, so no DB pollution.
|
||||
|
||||
### Notes
|
||||
@@ -1023,7 +1356,7 @@ The v1.5.3 creation wizard shipped as a single-page card with seven inline steps
|
||||
### Fixed
|
||||
|
||||
- **Multi-schedule data-loss risk** — the v1.5.3 wizard collapsed a multi-schedule medication to its first schedule on save and silently dropped the rest. Compose-mode is the proper fix; the encoder now emits every schedule, the hydrator reads every schedule, the per-schedule `id` round-trips so the PUT preserves identity.
|
||||
- **The edit form being wider than its container** — Marc's specific complaint from the v1.5.3 hand-walk. The container is now a constrained Dialog at `sm:max-w-md` on desktop and a sticky-footer Sheet at `max-h-[90dvh]` on mobile; the form-as-page surface is gone.
|
||||
- **The edit form being wider than its container** — a specific user complaint from the v1.5.3 hand-walk. The container is now a constrained Dialog at `sm:max-w-md` on desktop and a sticky-footer Sheet at `max-h-[90dvh]` on mobile; the form-as-page surface is gone.
|
||||
|
||||
### Tests
|
||||
|
||||
@@ -1033,7 +1366,7 @@ The v1.5.3 creation wizard shipped as a single-page card with seven inline steps
|
||||
|
||||
### Notes
|
||||
|
||||
- **No API or wire-format change.** The wizard writes the same `createMedicationSchema` payload the v1.5.3 wizard wrote; iOS clients reading and writing the existing schedule shape are unaffected. The native `treatmentClass` enum already shipped `GLP1`; the wizard's Step 2 maps the Marc-confirmed labels onto the existing enum + the two new `MedicationCategory` values.
|
||||
- **No API or wire-format change.** The wizard writes the same `createMedicationSchema` payload the v1.5.3 wizard wrote; iOS clients reading and writing the existing schedule shape are unaffected. The native `treatmentClass` enum already shipped `GLP1`; the wizard's Step 2 maps the maintainer-confirmed labels onto the existing enum + the two new `MedicationCategory` values.
|
||||
- **Cadence-engine read-flip is still v1.5.x deferred** — the today-projector, the cadence chart, and the medication card continue to read the legacy `daysOfWeek` / `intervalWeeks` columns. A wizard-minted rolling or one-shot medication will render a daily-looking next-due chip on the dashboard until the read-flip ships. The reminder worker and the engine work with the new fields correctly.
|
||||
- **Test totals.** 5500 unit + 1 skipped, 261 integration + 3 skipped, 14 Playwright wizard instances. Typecheck, lint, `openapi:check`, i18n call-site coverage, locale-integrity all green at HEAD.
|
||||
|
||||
@@ -1178,7 +1511,7 @@ Before this change, the iOS HealthKit observer would POST today's running step t
|
||||
|
||||
## [1.4.50] — 2026-05-24 — MoodLog reverse-sync (HealthLog → MoodLog push)
|
||||
|
||||
Marc reported that mood entries logged inside HealthLog — specifically via the iOS app — never reached MoodLog. The historic integration ran one-way: `syncMoodLogEntries` polled MoodLog every 15 minutes and pulled new rows, but nothing flowed the other direction. A user tracking mood in HealthLog ended up with one log per app and no overlap.
|
||||
A user reported that mood entries logged inside HealthLog — specifically via the iOS app — never reached MoodLog. The historic integration ran one-way: `syncMoodLogEntries` polled MoodLog every 15 minutes and pulled new rows, but nothing flowed the other direction. A user tracking mood in HealthLog ended up with one log per app and no overlap.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1200,7 +1533,7 @@ The matching MoodLog v0.2.0 release adds the `POST /api/integrations/health-log/
|
||||
|
||||
## [1.4.49.4] — 2026-05-24 — BD-tile number truncation + medication-compliance test tz drift
|
||||
|
||||
Marc reported the dashboard BD (Sys) tile rendering `"13… mmHg"` — the systolic / diastolic pair (`131/85`) was wider than the narrowest grid column and the value-row's `truncate` ellipsis chopped the most important part of the tile (the number itself).
|
||||
A user reported the dashboard BD (Sys) tile rendering `"13… mmHg"` — the systolic / diastolic pair (`131/85`) was wider than the narrowest grid column and the value-row's `truncate` ellipsis chopped the most important part of the tile (the number itself).
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1210,12 +1543,12 @@ Marc reported the dashboard BD (Sys) tile rendering `"13… mmHg"` — the systo
|
||||
|
||||
## [1.4.49.3] — 2026-05-23 — Full i18n call-site audit + 28 stale missing keys filled
|
||||
|
||||
Marc reported additional raw key strings (`notifications.eventMoodReminder`, `notifications.eventMoodReminderDesc`) reaching the UI after v1.4.49.2 had landed the relative-time fix. The narrow `{count}`-call audit that drove v1.4.49.2 had missed every dynamic key construction; the full exhaustive sweep below picked up 28 keys called from real code that had never existed in any locale bundle.
|
||||
A user reported additional raw key strings (`notifications.eventMoodReminder`, `notifications.eventMoodReminderDesc`) reaching the UI after v1.4.49.2 had landed the relative-time fix. The narrow `{count}`-call audit that drove v1.4.49.2 had missed every dynamic key construction; the full exhaustive sweep below picked up 28 keys called from real code that had never existed in any locale bundle.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Filled 28 missing translation keys across all 6 locales (de / en / es / fr / it / pl = 168 entries), grouped by feature surface that introduced them and never wired the strings:
|
||||
- **Notifications event matrix** (14 keys — added with v1.4.41 / MOOD_REMINDER surfaced the gap in v1.4.49): `notifications.event{MedicationReminder,MeasurementAnomaly,ComplianceLow,WithingsSyncFailed,SystemAlert,PersonalRecord,MoodReminder}` and the `Desc` variant of each. The `/notifications` settings page is the consumer; before this release the per-event rows on Marc's account rendered the bare keys instead of the localised name + description.
|
||||
- **Notifications event matrix** (14 keys — added with v1.4.41 / MOOD_REMINDER surfaced the gap in v1.4.49): `notifications.event{MedicationReminder,MeasurementAnomaly,ComplianceLow,WithingsSyncFailed,SystemAlert,PersonalRecord,MoodReminder}` and the `Desc` variant of each. The `/notifications` settings page is the consumer; before this release the per-event rows on an affected account rendered the bare keys instead of the localised name + description.
|
||||
- **Welcome carousel** (13 keys — added with the 3-slide intro in v1.4.45): `onboarding.welcome.{title,carouselLabel,slideOf,prevSlide,nextSlide,gotoSlide,cta}` for the chrome plus `slide{1,2,3}.title/body` for the content. The carousel mounted with raw keys for every screen-reader label, dot-pager aria-label, and slide body.
|
||||
- **Measurement list error state** (1 key — added with v1.4.44): `measurements.loadError`. The error branch in `measurement-list.tsx` rendered the raw key when a fetch failed.
|
||||
|
||||
@@ -1229,7 +1562,7 @@ The guard above did not exist before. v1.4.27 B6 introduced `i18n-drift-guard.te
|
||||
|
||||
## [1.4.49.2] — 2026-05-23 — Raw i18n key leak on relative-time helper
|
||||
|
||||
Marc reported the raw string `insights.relativeHoursAgo` rendering verbatim on medication cards, recent-achievements, admin sections, the iOS notification preview, and every other consumer of `formatDateOrRelative`. Audit across the full `t(…, { count })` surface (~20 call sites) confirmed exactly two leaks: `insights.relativeMinutesAgo` and `insights.relativeHoursAgo`. Both pointed to keys that have NEVER existed in the translation bundle — only the pluralised `*One` / `*Other` variants ship.
|
||||
A user reported the raw string `insights.relativeHoursAgo` rendering verbatim on medication cards, recent-achievements, admin sections, the iOS notification preview, and every other consumer of `formatDateOrRelative`. Audit across the full `t(…, { count })` surface (~20 call sites) confirmed exactly two leaks: `insights.relativeMinutesAgo` and `insights.relativeHoursAgo`. Both pointed to keys that have NEVER existed in the translation bundle — only the pluralised `*One` / `*Other` variants ship.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1249,7 +1582,7 @@ v1.4.49 shipped the slim-slice (`?slice=summaries`) cold-fallback fix, but the p
|
||||
|
||||
### Fixed
|
||||
|
||||
- `GET /api/analytics` (default slice) no longer fans out 15 per-type `findMany` reads against `measurements`. The route now delegates the per-type `summaries` work to `computeSummariesSlice`, which reads the same data from `measurement_rollups` DAY buckets + a 90-day narrow `$queryRaw` for the windowed avg / slope / r² columns. On Marc's production account this drops the cold critical path from ~8 s to sub-second; warm cache is unchanged.
|
||||
- `GET /api/analytics` (default slice) no longer fans out 15 per-type `findMany` reads against `measurements`. The route now delegates the per-type `summaries` work to `computeSummariesSlice`, which reads the same data from `measurement_rollups` DAY buckets + a 90-day narrow `$queryRaw` for the windowed avg / slope / r² columns. On a large production account this drops the cold critical path from ~8 s to sub-second; warm cache is unchanged.
|
||||
- Pool starvation that ALSO affected the supposedly-fast `?slice=summaries` request when fired concurrently — the slim slice now resolves in its own SQL budget regardless of whether the default-slice request is also in flight.
|
||||
|
||||
### Changed
|
||||
@@ -1341,7 +1674,7 @@ v1.4.47 closed the high-priority punch list. v1.4.49 bundles the remaining v1.4.
|
||||
|
||||
### Deferred
|
||||
|
||||
- iOS v0.6.0.8 ships the Settings-side notification-permission re-trigger (`healthlog-iOS#10`), the in-app APNs diagnostic surface, and the `CFBundleShortVersionString` + UA build-number fixes — these depend on Marc's Xcode build cycle, not the server. The server-side suppression flag added in this release is opt-in (`clientManaged: false` by default) so existing users on the still-buggy v0.6.0.7 client see no behaviour change until they upgrade to v0.6.0.8 and the iOS client explicitly opts in via `PATCH /api/auth/me/notification-prefs`.
|
||||
- iOS v0.6.0.8 ships the Settings-side notification-permission re-trigger (`healthlog-iOS#10`), the in-app APNs diagnostic surface, and the `CFBundleShortVersionString` + UA build-number fixes — these depend on the iOS client's build cycle, not the server. The server-side suppression flag added in this release is opt-in (`clientManaged: false` by default) so existing users on the still-buggy v0.6.0.7 client see no behaviour change until they upgrade to v0.6.0.8 and the iOS client explicitly opts in via `PATCH /api/auth/me/notification-prefs`.
|
||||
- Reactive `prefersReducedMotion()` hook — current helper at `src/lib/charts/reduced-motion.ts` is read-once at render; doesn't react to mid-session OS toggle. Twenty-one call sites would benefit from a `useSyncExternalStore`-backed hook. Scoped as a v1.4.50 single-purpose refactor.
|
||||
- `handleReminderCheck` extraction — the cron handler in `src/lib/jobs/reminder-worker.ts` is a 260-line non-exported blob. The v1.4.49 suppression skip-path is pinned via the helper `isMedicationReminderClientManaged` rather than a direct integration test of the handler. Scoped as v1.4.50 hygiene.
|
||||
|
||||
@@ -1678,7 +2011,7 @@ v1.4.41 closed the iOS BP/Weight 14 s perf paper-cut and the soft-delete reader-
|
||||
- **`offhost-backup.ts:219` DR-intent comment** — the nightly disaster-recovery S3 snapshot deliberately includes soft-deleted rows so a future "restore from yesterday" brings back a row the user undeleted on the source side. The inline comment now documents the asymmetry vs the user-facing `/api/export/full-backup` which correctly excludes them.
|
||||
- **`src/lib/doctor-report-data.ts` byte cleanliness** — the file was checked in as `Binary files differ` because the sanitiser regex held literal control bytes (NUL + US + DEL) in its character class. The regex now uses `[\x00-\x1F\x7F]` escape-sequence form so the file becomes UTF-8 clean and future code reviews can read the diffs. Same runtime behaviour; the existing 18-case test suite still passes.
|
||||
- **Two dead re-exports dropped**: `listSupportedTimezones` re-export in `src/lib/tz/resolver.ts` (callers import from `@/lib/tz/format` directly) and the `describeInjectionSite` re-export in `src/components/medications/glp1-medication-card.tsx` ("Re-export so the parent doesn't need to import it" — no caller ever did).
|
||||
- **`pnpm check-env` catches the v1.4.40 AP-2 silent-disable it was conceived to catch.** The APNs group in `scripts/env-manifest.json` shipped without `allOrNone: true`, so an operator setting three of four `APNS_*` vars and leaving `APNS_KEY` / `APNS_KEY_FILE` empty would still exit 0 — exactly the silent-disable shape the wave was meant to prevent. Adding the flag closes the gap; the regression test pins the AP-2 scenario directly.
|
||||
- **`pnpm check-env` catches the v1.4.40 AP-2 silent-disable it was conceived to catch.** The APNs group in `scripts/env-manifest.json` shipped without `allOrNone: true`, so an operator setting three of four `APNS_*` vars and leaving `APNS_KEY` / `APNS_KEY_FILE` empty would still exit 0 — exactly the silent-disable shape the gate was meant to prevent. Adding the flag closes the gap; the regression test pins the AP-2 scenario directly.
|
||||
|
||||
### Operator notes
|
||||
|
||||
@@ -1745,11 +2078,11 @@ v1.4.39.x stitched the rollup tier across mood, medication compliance, and cumul
|
||||
### Changed
|
||||
|
||||
- **Per-tile Suspense boundaries on the dashboard.** Each chart tile now mounts inside its own `<Suspense>` so a slow tile no longer blocks the others on first paint. The parent gate is the slim / thick analytics merge (already split per v1.4.39.2); the per-tile Suspense layer is the structural foundation the v1.4.41 React Server Components migration will graft onto. `mood-chart` queryKey dedup eliminates one round-trip on cold mount.
|
||||
- **queryKey factory enforcement** (`src/lib/__tests__/query-keys.test.ts`). A walker test fails CI if any guarded file declares a bare-literal `queryKey: [...]`. Guarded roots are `src/components/charts/`, `src/components/comparison/`, `src/app/page.tsx`, and `src/hooks/use-auth.ts`. Cheaper than a custom ESLint rule and points the failure message at the exact `file:line` a contributor needs to fix; opt-in expansion as future waves migrate the remaining sites away from bare literals.
|
||||
- **queryKey factory enforcement** (`src/lib/__tests__/query-keys.test.ts`). A walker test fails CI if any guarded file declares a bare-literal `queryKey: [...]`. Guarded roots are `src/components/charts/`, `src/components/comparison/`, `src/app/page.tsx`, and `src/hooks/use-auth.ts`. Cheaper than a custom ESLint rule and points the failure message at the exact `file:line` a contributor needs to fix; opt-in expansion as future releases migrate the remaining sites away from bare literals.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Prisma pool starvation root cause** (empirical-trace finding #1). The 15-way `fetchMeasurementSeriesChunked` fan-out in `/api/analytics` thick used to monopolise ≥ 8 of the default-10 `pg.Pool` connections during a power-user cold mount, blocking every Wave-B and Wave-C chart-tile fetch behind it. The fan-out is now wrapped in `p-limit(4)` so analytics holds at most 4 pool slots, and the `pg.Pool` `max` is raised from the library default 10 → 20 (overridable via `DATABASE_POOL_MAX`) so a second concurrent power-user retains ≥ 8 free slots after both branches hit their `p-limit(4)` cap. The cap is a per-request instance, not module-level, so a stale limit cannot leak in-flight state across HTTP boundaries.
|
||||
- **Prisma pool starvation root cause** (empirical-trace finding #1). The 15-way `fetchMeasurementSeriesChunked` fan-out in `/api/analytics` thick used to monopolise ≥ 8 of the default-10 `pg.Pool` connections during a power-user cold mount, blocking every other dashboard chart-tile fetch behind it. The fan-out is now wrapped in `p-limit(4)` so analytics holds at most 4 pool slots, and the `pg.Pool` `max` is raised from the library default 10 → 20 (overridable via `DATABASE_POOL_MAX`) so a second concurrent power-user retains ≥ 8 free slots after both branches hit their `p-limit(4)` cap. The cap is a per-request instance, not module-level, so a stale limit cannot leak in-flight state across HTTP boundaries.
|
||||
- **Soft-delete invisibility full-wire.** Eleven reader-tier helpers across `src/lib/measurements/rollups.ts`, `src/lib/measurements/rollup-coverage.ts`, `src/lib/analytics/{summaries-slice,correlations-fast-path,bp-in-target-fast-path,health-score-fast-path}.ts`, `src/lib/insights/comprehensive-aggregator.ts`, `src/app/api/dashboard/summary/route.ts`, `src/app/api/measurements/route.ts`, `src/app/api/measurements/series/route.ts`, and `src/lib/ai/coach/snapshot.ts` now filter `deletedAt: null` (or the SQL equivalent `m."deleted_at" IS NULL`) at every aggregate, every cursor walk, every `DISTINCT ON` latest probe, and every rollup-rebuild SQL. Three integration-test contracts in `tests/integration/measurement-soft-delete.test.ts` pin the invariant against a Postgres testcontainer.
|
||||
- **Six remaining insights `measurement.findMany` sites** that the earlier mood-rollup swap left unfiltered (`/api/insights/{targets,cards,generate}` plus `src/lib/insights/{features,glp1-plateau,pulse-status}.ts`). All six now filter `deletedAt: null` so the iOS-adapter card stream, the AI prompt feature aggregator, the GLP-1 plateau detector window, and the per-type tile-strip averages stop counting tombstoned readings once iOS sync starts emitting deletions.
|
||||
- **Compliance-rollup hook gap on bulk-projection paths.** Both `/api/medications/intake?scope=today` and `/api/dashboard/summary` mint fresh `(medicationId, scheduledFor)` rows in PENDING state when a daily schedule is projected on first read. Without a recompute hook the rollup for the affected `(user, medication, day)` tuples stayed at its previous (pre-projection) `scheduled` count, which inflated the apparent compliance % until the user logged against the new row. Both call sites now fire one recompute per distinct `(medicationId, dayKey)` tuple, deduplicated through a `Set` so the cost stays bounded. The recompute call is wrapped in `Promise.allSettled` so any future change that lets the helper throw still leaves the user request 200-OK.
|
||||
@@ -1760,9 +2093,9 @@ v1.4.39.x stitched the rollup tier across mood, medication compliance, and cumul
|
||||
|
||||
### Performance
|
||||
|
||||
Expected on Marc-sized accounts; numbers anchored on the v1.4.40 empirical trace. Live perf-verify rides the post-deploy window.
|
||||
Expected on large accounts; numbers anchored on the v1.4.40 empirical trace. Live perf-verify rides the post-deploy window.
|
||||
|
||||
- **Wave-C chart-tile first-paint: +7.3 s → +1.6 s.** Bounded analytics fan-out (p-limit 4 + pool max 20) lets the 6× `/api/measurements?source=rollup` Wave-C burst release incrementally as analytics rotates lanes, instead of gating the entire burst behind the thick analytics drain.
|
||||
- **Late-mounting chart-tile first-paint: +7.3 s → +1.6 s.** Bounded analytics fan-out (p-limit 4 + pool max 20) lets the 6× `/api/measurements?source=rollup` tile burst release incrementally as analytics rotates lanes, instead of gating the entire burst behind the thick analytics drain.
|
||||
- **6 insights routes cold-mount.** The mood-rollup swap on `/api/insights/{features,targets,cards}` moves mood aggregation off the live `MoodEntry.findMany` walk onto the v1.4.39 mood-rollup tier; the cold-mount budget for the affected routes drops onto the same flat-200 ms band the v1.4.39 `/api/mood/analytics` numbers land in.
|
||||
- **`avg30LastYear` now populated.** The 425-day `since` cap on the `/api/analytics` live-fallback per-type loop (v1.4.39) lets the year-ago baseline tile resolve from raw data without forcing a 347 k-row scan; the v1.4.40 pool cap keeps that fallback off the hot path.
|
||||
- **`slope90` via MONTH-tier reader.** `readBestGranularityRollups` auto-routes the 90-day slope window onto the MONTH bucket where coverage allows, eliminating a per-cold-mount aggregate on multi-year tenants.
|
||||
@@ -1864,7 +2197,7 @@ Withings sync, `/api/import`, and admin-restore paths. New writes from
|
||||
those surfaces now fold the persistent rollup tier on touch, and the
|
||||
boot-time backfill discovery surfaces every per-(user, type, day) gap
|
||||
so a worker restart converges any stranded accounts. v1.4.39.2 closes
|
||||
two follow-ups Marc surfaced from the post-deploy trace: the dashboard
|
||||
two follow-ups the maintainer surfaced from the post-deploy trace: the dashboard
|
||||
chart still painted its `< 3 daily points` empty-state on the 30-point
|
||||
range for accounts whose historic rollup partition had not yet been
|
||||
folded, and the cold-mount UX waterfall left every per-type tile
|
||||
@@ -2008,7 +2341,7 @@ self-heal via boot-time backfill on first reach.
|
||||
|
||||
### Performance
|
||||
|
||||
Expected on Marc-sized accounts; numbers anchored on the v1.4.38 perf
|
||||
Expected on large accounts; numbers anchored on the v1.4.38 perf
|
||||
analysis and confirmed by the unit-test fixture suite. Live perf-verify
|
||||
rides the post-deploy window.
|
||||
|
||||
@@ -2101,7 +2434,7 @@ longer poisons unrelated cards.
|
||||
### Operator notes
|
||||
|
||||
- No new migration. No env-var change.
|
||||
- Expected: cold-mount `/api/analytics` on Marc-sized accounts
|
||||
- Expected: cold-mount `/api/analytics` on large accounts
|
||||
drops from 30-75 s to ~3-5 s.
|
||||
|
||||
## [1.4.38.7] — 2026-05-18 — Rollup recompute observability + admin trigger
|
||||
@@ -2530,7 +2863,7 @@ dashboard SQL surfaced.
|
||||
load-shedding lands — no semantic change, just a hand-off note.
|
||||
- The private `dayKey` helper in `bp-in-target-fast-path` renames to
|
||||
`bucketDayKey` so the cross-file term lines up with the cross-tz
|
||||
guard wave naming.
|
||||
guard naming.
|
||||
- Health-score `bpInTargetPct` reuses the same prior-week query
|
||||
across windows instead of issuing one read per window.
|
||||
|
||||
@@ -2586,7 +2919,7 @@ dashboard SQL surfaced.
|
||||
`{day}` so a11y labels announce per-weekday).
|
||||
- `admin`, `settings`, and `achievements` namespaces remain
|
||||
intentional T3 — operator-facing surface that will land in a
|
||||
follow-up wave alongside the iOS launch.
|
||||
follow-up release alongside the iOS launch.
|
||||
|
||||
### Security
|
||||
|
||||
@@ -2655,7 +2988,7 @@ returns in a single round-trip.
|
||||
`count / min / max / mean` instead of ~306k bucket rows. The
|
||||
downstream JS aggregation is bypassed because the per-type
|
||||
aggregate is already shaped server-side. Cache-miss budget on
|
||||
Marc-sized accounts: ~3.1 s → < 100 ms expected.
|
||||
large accounts: ~3.1 s → < 100 ms expected.
|
||||
|
||||
### Operator notes
|
||||
|
||||
@@ -2905,7 +3238,7 @@ double-digit MB for power users. The Insights page itself stops
|
||||
blocking the entire shell on `/api/insights/comprehensive`, so each
|
||||
section renders its own skeleton and fills in independently.
|
||||
|
||||
On top of the perf wave: chart slot heights pinned to kill Recharts'
|
||||
On top of the performance work: chart slot heights pinned to kill Recharts'
|
||||
`width=-1 height=-1` warning and the matching CLS, a tri-state trend
|
||||
annotation contract that ends the cold-mount "more data needed" flash,
|
||||
medication intake history restored on the detail page, About card
|
||||
@@ -3648,8 +3981,7 @@ the iOS native client launches. Subsequent v1.4.x tags carry security
|
||||
fixes, dependency bumps, and hotfix-only corrections. New feature work
|
||||
is paused until the iOS app clears Apple review, at which point a
|
||||
v1.5.0 version-bump-only release tags the milestone. The Prisma
|
||||
schema head comment and `.planning/v15-strategic-plan.md` §5
|
||||
decision-log row pin the freeze trigger in-tree.
|
||||
schema head comment pins the freeze trigger in-tree.
|
||||
|
||||
## [1.4.33] — 2026-05-17 — Polish and reliability
|
||||
|
||||
@@ -3844,9 +4176,9 @@ the affected surfaces.
|
||||
the Insights mother page now accepts both eager and
|
||||
`next/dynamic` spellings.
|
||||
|
||||
## [1.4.32] — 2026-05-17 — HealthKit Tier 1 first wave
|
||||
## [1.4.32] — 2026-05-17 — HealthKit Tier 1 first surface
|
||||
|
||||
First public surface wave for the HealthKit Tier 1 metrics that
|
||||
First public surface for the HealthKit Tier 1 metrics that
|
||||
the iOS contributor brief locked in for v1.5. The headline item
|
||||
is a workouts flow that lands end-to-end on the web: a list page
|
||||
at `/insights/workouts`, a detail page with route preview and
|
||||
@@ -3968,8 +4300,7 @@ sleep before the trigger step lands inside the existing
|
||||
### Fixed
|
||||
|
||||
- **Insights tab-strip blocking on mobile.** Three orthogonal
|
||||
client-side fixes per
|
||||
`.planning/research/v15-insights-blocking-bug.md`:
|
||||
client-side fixes:
|
||||
- `fetchAdvisor` gets an 8 s `AbortController`; `AbortError`
|
||||
falls through to the existing graceful-null return path so the
|
||||
UI surfaces the regen CTA instead of pinning the query in
|
||||
@@ -4033,9 +4364,8 @@ Every change is additive on the wire.
|
||||
error and degrades gracefully through its existing 403
|
||||
handler. v1.4.31+ iOS reads the `errorCode` to render the
|
||||
surface-specific empty state.
|
||||
- The locked-contract entry at
|
||||
`.planning/v15-ios-handoff/08-locked-contracts.md` §14
|
||||
documents the rule that the flag matrix gates BOTH
|
||||
- The locked-contract coordination notes (§14)
|
||||
document the rule that the flag matrix gates BOTH
|
||||
server-routed AND on-device assistant surfaces. Apple
|
||||
Foundation Models on-device Coach + Briefing flows in the
|
||||
v0.6.0 iOS path honour the same flag matrix as the
|
||||
@@ -4061,12 +4391,12 @@ standalone-first track depends on lands as §13 of
|
||||
`MeasurementType`. `requireAuth()` gates the route — any logged-in
|
||||
user passes — and `Cache-Control: public, max-age=600` enables a
|
||||
10-minute edge cache. Locked per
|
||||
`.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R1.
|
||||
the iOS-team coordination response, §3 R1.
|
||||
|
||||
### Documented
|
||||
|
||||
- **SyncMode conflict-resolution policy.**
|
||||
`.planning/v15-ios-handoff/08-locked-contracts.md` gains §13 per
|
||||
The locked-contract coordination notes gain §13 per
|
||||
R9 of the iOS-team response doc. Hard-spec covers bulk-backfill,
|
||||
steady-state bidirectional sync via `(updatedAt, syncVersion)`
|
||||
optimistic concurrency, the 409 write-conflict + 410 Gone
|
||||
@@ -4110,8 +4440,7 @@ permission picker and the future Insights nav.
|
||||
- **Daily-stats `externalId` helper.** `dailyStatsExternalId(hkId,
|
||||
date)` mints `"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"`
|
||||
alongside the v1.4.29 `CUMULATIVE_HK_TYPES` set. The shape is
|
||||
locked in `.planning/v15-ios-handoff/08-locked-contracts.md` §12
|
||||
and `.planning/v15-ios-handoff/06-ios-responsibilities.md`. iOS
|
||||
locked in the iOS coordination notes (§12). iOS
|
||||
emits one row per day per cumulative type via
|
||||
`HKStatisticsCollectionQuery`; the unique index collapses
|
||||
re-syncs idempotently, and a future PATCH-on-divergence call
|
||||
@@ -4820,8 +5149,7 @@ is additive, `IF NOT EXISTS`-guarded, and forward-only.
|
||||
full filtered set, dedupes once, then slices, so `meta.total`
|
||||
reports the deduped count rather than the per-window
|
||||
`canonical.length`.
|
||||
- **iOS handoff addendum.** New
|
||||
`.planning/v15-ios-handoff/22-standalone-and-server-pairing.md`
|
||||
- **iOS handoff addendum.** A new coordination note
|
||||
documents the standalone-then-pair pattern for the iOS native
|
||||
client. Sibling of the existing `22-offline-first-architecture.md`;
|
||||
same research input, neutral framing per the v1.4.27 convention
|
||||
@@ -5278,7 +5606,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
per-locale bodies (no `REPLY LANGUAGE` footer indirection) with
|
||||
the full safety-contract matrix in YAML per locale + structural
|
||||
refusal-probe coverage in CI (see Tests). EN + DE stay
|
||||
Marc-maintained. Locale picker covers all six; signup
|
||||
maintainer-curated. Locale picker covers all six; signup
|
||||
browser-detect maps `fr|es|it|pl` to the corresponding bundle.
|
||||
- **Coach — native first-party system prompts across six locales.**
|
||||
Coach + insights system prompts move from EN body + `REPLY
|
||||
@@ -5872,8 +6200,8 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
|
||||
### Deferred to v1.4.26
|
||||
|
||||
See `.planning/v1426-backlog.md` for the full backlog with effort
|
||||
estimates and source citations. Headline items:
|
||||
The full backlog, with effort
|
||||
estimates and source citations, is tracked internally. Headline items:
|
||||
|
||||
- `User.onboardingGoals` column + server-side persistence for the
|
||||
goals chip-picker selection (today the picker holds the choice
|
||||
@@ -6432,7 +6760,7 @@ NOT NULL`) is the defence-in-depth backstop.
|
||||
|
||||
- **Coolify image-digest auto-deploy.** Instructions for the
|
||||
one-time UI-toggle ("Watch image registry for new digests")
|
||||
live at `.planning/coolify-auto-deploy-howto.md`. Future
|
||||
live in the internal ops notes. Future
|
||||
releases should drop the host-side retag fallback the moment
|
||||
the toggle is on.
|
||||
- **191 maintainer-name references in `src/` source comments
|
||||
@@ -6448,7 +6776,7 @@ NOT NULL`) is the defence-in-depth backstop.
|
||||
|
||||
### Deferred to v1.4.23
|
||||
|
||||
See `.planning/v1422-backlog.md` for the full carry-over list.
|
||||
The full carry-over list is tracked internally.
|
||||
Highlights: sentinel parser malformed-enum hardening (Sr-M5);
|
||||
analytics-route unbounded `findMany` paging; targets-route
|
||||
7-pass sparkline coalesce; `CoachDrawer key={prefill}`
|
||||
@@ -6750,8 +7078,7 @@ Coach extension for the new measurement types
|
||||
- 3 HIGH from QA — `/insights` `data?.` narrowing refactor,
|
||||
`/admin/api-tokens` touch-tooltip (needs Popover swap), and
|
||||
`/insights` hero density (folded into the v1.4.20 redesign).
|
||||
- 31 MED + 16 LOW from the quality-of-life audit. Short-list at
|
||||
`.planning/v1420-backlog.md`.
|
||||
- 31 MED + 16 LOW from the quality-of-life audit. Short-list tracked internally.
|
||||
- `/insights` redesign with AI Coach — separate roadmap, design
|
||||
handoff at `~/Downloads/design_handoff_insights_redesign`.
|
||||
|
||||
@@ -6823,9 +7150,9 @@ Coach extension for the new measurement types
|
||||
`messages/de.json` are still statically imported into the client
|
||||
bundle, so a determined user can `Cmd-F` for the hidden strings in
|
||||
`_next/static/chunks/*.js`. Needs a build-time strip or reversible
|
||||
obfuscation. Tracked in `.planning/v1419-backlog.md`.
|
||||
- See `.planning/v1419-backlog.md` for the short-list and
|
||||
`.planning/v15-backlog.md` for the strategic v1.5 items.
|
||||
obfuscation. Tracked internally.
|
||||
- The short-list and the strategic v1.5 items are tracked
|
||||
internally.
|
||||
|
||||
## [1.4.17] — 2026-05-10
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@ Working notes for anyone (human or otherwise) editing this repository. Skim befo
|
||||
|
||||
## What HealthLog is
|
||||
|
||||
Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. AGPL-3.0. Current line: v1.12.x.
|
||||
Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. PolyForm Noncommercial 1.0.0 (AGPL-3.0 through v1.15.18). Current line: v1.12.x.
|
||||
|
||||
## Voice and privacy
|
||||
|
||||
Hard rules for every committed artefact — commit messages, CHANGELOG entries, PR bodies, code comments, GitHub release notes, docs site copy. Treat these as the project's public face.
|
||||
|
||||
- **Voice.** English. Maintainer prose: present-tense, imperative for commits, factual for prose. No marketing tone, no apology tone, no "wow what a cool feature" framing. The voice is one human writing notes for the next.
|
||||
- **No assistant / tooling references.** Do not surface AI, agent, assistant, copilot, marathon, phase, wave, round, or session vocabulary into anything that lands on GitHub. The `Co-Authored-By` trailer on commits is fine — it is git metadata and the project accepts that convention; anywhere else, no.
|
||||
- **No assistant / tooling references.** Do not surface AI, agent, assistant, copilot, marathon, phase, wave, round, or session vocabulary into anything that lands on GitHub — including commit messages and trailers. Commits carry no `Co-Authored-By` tooling trailer; they are authored by the maintainer's git identity alone.
|
||||
- **No personal data.** No real names (the maintainer's, a reporter's, or anyone else's). No live health figures, no live tenant identifiers, no real-account anchors. Generalise to "a self-hoster", "a user", "an operator", "an authenticated session", etc. The internal `.planning/*` files are gitignored and exempt; everywhere else in the repo, no.
|
||||
|
||||
If a draft commit message or release note would violate one of the three, rewrite it before committing. The `git log` is more durable than today's task.
|
||||
|
||||
+1
-1
@@ -168,4 +168,4 @@ If you find a security vulnerability, **do not open a public issue**. See [SECUR
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [AGPL-3.0](LICENSE) license.
|
||||
The project is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE). By contributing, you agree that your contributions are submitted under the project license.
|
||||
+28
@@ -22,6 +22,18 @@ WORKDIR /app
|
||||
ARG NEXT_PUBLIC_APP_VERSION
|
||||
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
|
||||
|
||||
# Short Git SHA of the release commit, same workflow source as the
|
||||
# version arg above. Changes exactly when the source changes, so it
|
||||
# adds no cache churn beyond what the COPY below already causes.
|
||||
# /api/version surfaces it as `buildSha` for deploy verification
|
||||
# (docs/ops/deploy.md). The built-at timestamp is intentionally NOT
|
||||
# set in this stage: it differs on every run and would bust the
|
||||
# `pnpm build` layer cache even for content-identical rebuilds — the
|
||||
# runner stage below carries it instead (the route reads process.env
|
||||
# at request time, not at bundle time).
|
||||
ARG NEXT_PUBLIC_APP_BUILD_SHA
|
||||
ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
@@ -54,6 +66,13 @@ ENV TZ=Europe/Berlin
|
||||
ARG NEXT_PUBLIC_APP_VERSION
|
||||
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
|
||||
|
||||
# Short Git SHA the image was built from — /api/version returns it as
|
||||
# `buildSha` so an operator can verify which commit a running `:latest`
|
||||
# container actually carries (the deploy runbook checks it after every
|
||||
# deploy). Provided by docker-publish.yml alongside the version arg.
|
||||
ARG NEXT_PUBLIC_APP_BUILD_SHA
|
||||
ENV NEXT_PUBLIC_APP_BUILD_SHA=$NEXT_PUBLIC_APP_BUILD_SHA
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
@@ -97,6 +116,15 @@ RUN mkdir -p /opt/prisma-cli && \
|
||||
RUN mkdir -p /opt/geolite2
|
||||
COPY assets/geolite2/ /opt/geolite2/
|
||||
|
||||
# ISO-8601 build timestamp — /api/version returns it as `builtAt`.
|
||||
# Declared this late in the stage on purpose: the value differs on
|
||||
# every CI run, and every instruction after an ARG shares its cache
|
||||
# fate. Down here the only layers it invalidates are the cheap
|
||||
# entrypoint COPY/chmod below; the npm-install layers above stay
|
||||
# cache-warm across rebuilds of the same release.
|
||||
ARG NEXT_PUBLIC_APP_BUILT_AT
|
||||
ENV NEXT_PUBLIC_APP_BUILT_AT=$NEXT_PUBLIC_APP_BUILT_AT
|
||||
|
||||
# Entrypoint script (runs migrations, then starts app)
|
||||
COPY docker-entrypoint.sh ./
|
||||
RUN chmod +x docker-entrypoint.sh
|
||||
|
||||
@@ -1,661 +1,75 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
Required Notice: Copyright (c) 2026 the HealthLog maintainers
|
||||
|
||||
# PolyForm Noncommercial License 1.0.0
|
||||
|
||||
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
||||
|
||||
## Acceptance
|
||||
|
||||
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
||||
|
||||
## Copyright License
|
||||
|
||||
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
||||
|
||||
## Distribution License
|
||||
|
||||
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
||||
|
||||
## Notices
|
||||
|
||||
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
||||
|
||||
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
||||
|
||||
## Changes and New Works License
|
||||
|
||||
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
||||
|
||||
## Patent License
|
||||
|
||||
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
||||
|
||||
## Noncommercial Purposes
|
||||
|
||||
Any noncommercial purpose is a permitted purpose.
|
||||
|
||||
## Personal Uses
|
||||
|
||||
Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose.
|
||||
|
||||
## Noncommercial Organizations
|
||||
|
||||
Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding.
|
||||
|
||||
## Fair Use
|
||||
|
||||
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
||||
|
||||
## No Other Rights
|
||||
|
||||
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
||||
|
||||
## Patent Defense
|
||||
|
||||
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
||||
|
||||
## Violations
|
||||
|
||||
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
||||
|
||||
## No Liability
|
||||
|
||||
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
||||
|
||||
## Definitions
|
||||
|
||||
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
||||
|
||||
**You** refers to the individual or entity agreeing to these terms.
|
||||
|
||||
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
||||
|
||||
**Your licenses** are all the licenses granted to you for the software under these terms.
|
||||
|
||||
**Use** means anything you do with the software requiring one of your licenses.
|
||||
@@ -1,28 +1,18 @@
|
||||
<p align="center">
|
||||
<img src="public/logo-readme.svg" alt="HealthLog Logo" width="120" height="120" />
|
||||
<img src="public/logo-readme.svg" alt="HealthLog logo" width="120" height="120" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">HealthLog</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Your health data belongs to you. Track it on your own terms.</strong>
|
||||
<strong>Self-hosted, privacy-first health tracking. Your vitals, your devices, your server.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg" alt="License: AGPL-3.0" /></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-PolyForm--Noncommercial--1.0.0-blue.svg" alt="License: PolyForm Noncommercial 1.0.0" /></a>
|
||||
<a href="https://github.com/MBombeck/HealthLog/releases"><img src="https://img.shields.io/github/v/release/MBombeck/HealthLog?sort=semver&color=success&cacheSeconds=60" alt="Latest release" /></a>
|
||||
<a href="https://github.com/MBombeck/HealthLog/actions/workflows/integration.yml"><img src="https://img.shields.io/github/actions/workflow/status/MBombeck/HealthLog/integration.yml?branch=main&label=CI" alt="CI status" /></a>
|
||||
<a href="https://testflight.apple.com/join/bucuTBpa"><img src="https://img.shields.io/badge/iOS-TestFlight-007AFF?logo=apple&logoColor=white" alt="iOS app on TestFlight" /></a>
|
||||
<img src="https://img.shields.io/badge/Self--Hosted-yes-success" alt="Self-Hosted" />
|
||||
<img src="https://img.shields.io/badge/Next.js-16-black?logo=next.js" alt="Next.js 16" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
<a href="https://github.com/MBombeck/HealthLog/pkgs/container/healthlog"><img src="https://img.shields.io/badge/GHCR-multi--arch-2496ED?logo=docker&logoColor=white" alt="GHCR multi-arch image" /></a>
|
||||
<a href="https://buymeacoffee.com/mbombeck"><img src="https://img.shields.io/badge/Buy_me_a_coffee-FFDD00?logo=buy-me-a-coffee&logoColor=black" alt="Buy Me A Coffee" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Self-hosted health tracker. Weight, blood pressure, glucose, mood, medications.<br/>
|
||||
Withings, WHOOP, Google Health/Fitbit, and Apple Health sync, multi-provider AI insights you own, doctor-report PDF.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,652 +21,71 @@
|
||||
<a href="https://docs.healthlog.dev">Documentation</a> ·
|
||||
<a href="https://testflight.apple.com/join/bucuTBpa">iOS TestFlight</a>
|
||||
</p>
|
||||
<img width="2316" height="1207" alt="home" src="https://github.com/user-attachments/assets/aac0931d-5ea2-45b8-9a12-5f2f013d19e5" />
|
||||
|
||||
---
|
||||
<img width="2434" height="1247" alt="HealthLog dashboard: health metric tiles with 7-day trends, blood-pressure and weight charts, dark theme" src="docs/assets/dashboard-hero.jpeg" />
|
||||
|
||||
## Contents
|
||||
## Why HealthLog
|
||||
|
||||
- [What it is](#what-it-is)
|
||||
- [Why HealthLog?](#why-healthlog)
|
||||
- [How it compares](#how-it-compares)
|
||||
- [Key Features](#key-features)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Security and Privacy](#security-and-privacy)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Architecture](#architecture)
|
||||
- [API Reference](#api-reference)
|
||||
- [Integrations](#integrations)
|
||||
- [Local Development](#local-development)
|
||||
- [Deployment](#deployment)
|
||||
- [Documentation](#documentation)
|
||||
- [Roadmap](#roadmap)
|
||||
- [Native iOS client](#native-ios-client)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
HealthLog is a self-hosted health tracking app (PWA + native iOS client) that runs from a single `docker compose up` on a NAS, homelab, or small VPS. It brings weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications onto one timeline — synced from the devices you already own — and keeps everything encrypted at rest on hardware you control. No vendor cloud, no subscription, no telemetry.
|
||||
|
||||
---
|
||||
Try the [live demo](https://demo.healthlog.dev) to see a working install — sign in with `demo` / `demo123demo123`.
|
||||
|
||||
## What it is
|
||||
## Highlights
|
||||
|
||||
HealthLog is a self-hosted personal health tracker that runs from a single `docker compose up`. It covers the metrics most people actually log -- weight, blood pressure, pulse, body composition, blood glucose, sleep, mood, and medication compliance -- and brings them together in one dashboard with reference ranges from ESH 2023, ADA 2024, and NICE NG115. Withings, WHOOP, and Google Health/Fitbit devices sync automatically over OAuth2; an `export.zip` import folds your full Apple Health history into the same timeline; a native SwiftUI iOS client (public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa)) streams HealthKit live; multi-provider AI insights (BYOK or local) explain what the numbers mean; a doctor-report PDF generates client-side. EN/DE end-to-end. AGPL-3.0.
|
||||
- **Every vital on one timeline.** Weight, blood pressure, pulse, glucose, body composition, sleep, SpO₂, mood, and cycle tracking — charted with trends, moving averages, and clinical reference ranges (ESH 2023, ADA 2024), overridable with the targets your clinician set.
|
||||
- **Your devices, one server.** Withings and WHOOP sync over OAuth2, Google Health/Fitbit connects experimentally, an Apple Health `export.zip` folds your full history in, and the native iOS app streams HealthKit live. A per-metric source priority decides which reading is canonical when wearables overlap.
|
||||
- **Medication tracking that tells the truth.** Flexible schedules (weekly injections, weekday-only, intervals, PRN, cyclic), a configurable intake window per dose, and a traceable dose history where every slot reads taken, late, skipped, or missed — the same ledger the compliance rate is computed from, so the percentage can never disagree with the timeline.
|
||||
- **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements, with proactive check-ins. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so nothing leaves your network.
|
||||
- **Clinician-ready output.** A doctor-report PDF generated client-side, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit.
|
||||
- **Private by construction.** AES-256-GCM encryption at rest with zero-downtime key rotation, passkey login, server-side sessions, strict CSP — and no third-party tracking anywhere.
|
||||
- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history, English and German end to end.
|
||||
|
||||
> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current line: v1.12 — a Google Health/Fitbit connection for Fitbit and Pixel wearables (experimental, see [Integrations](#integrations)) and a rebuilt mood log (five-face capture, a structured tag catalog, and rated daily-life factors), on top of the v1.11 mood-relations and sleep-depth insights, a read-only FHIR R4 API with shareable clinician records, the v1.10 derived-metrics tier (fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, an early-strain flag), and the v1.7 health-record export (PDF + FHIR R4). The native iOS client is public-beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa).
|
||||
The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev).
|
||||
|
||||
> **Heavily developed.** HealthLog ships multiple releases per week. Behaviour, API shapes, and database schema can change between minor versions; migrations are forward-only and not all are rehearsed against every legacy fixture. If you self-host, pin a tag, take a backup before every upgrade, and read the [CHANGELOG](CHANGELOG.md) before pulling `latest`. Issue reports and PRs welcome — this is the rough edge where the project gets sharper.
|
||||
|
||||
Built for people who want their health data on their own server -- whether that's a NAS, a homelab, or a small VPS -- and who don't want to hand it to a US cloud to read a 7-day weight trend. **Try the [live demo](https://demo.healthlog.dev)** to see what a working install looks like, or skip to [Quick Start](#quick-start) below.
|
||||
|
||||
---
|
||||
|
||||
## Why HealthLog?
|
||||
|
||||
Most health apps lock your data behind proprietary clouds, push subscriptions, and sell your metrics to advertisers. HealthLog takes a different approach: your data stays on your server, encrypted at rest, accessible only to you.
|
||||
|
||||
---
|
||||
|
||||
## How it compares
|
||||
|
||||
| | HealthLog | Withings web | Apple Health | Oura web | WHOOP web | Fitbit web | Generic CSV |
|
||||
| -------------------------- | -------------------- | --------------- | ------------- | ----------- | ----------- | ------------ | ----------- |
|
||||
| Self-hosted | Yes | No | No | No | No | No | Yes |
|
||||
| Open source | AGPL-3.0 | No | No | No | No | No | n/a |
|
||||
| Withings device sync | Yes (OAuth2) | Yes (native) | Via shortcut | No | No | No | No |
|
||||
| WHOOP device sync | Yes (OAuth2, BYO-keys)| No | No | No | Native | No | No |
|
||||
| Google Health / Fitbit sync| Yes (experimental) | No | No | No | No | Native | No |
|
||||
| Apple Health import | Yes (`export.zip`) | No | Native | No | No | No | Manual |
|
||||
| Aggregates many providers | Yes | No | Partial | No | No | No | Manual |
|
||||
| Multi-source dedup + priority| Yes (per-metric) | n/a | Limited | n/a | n/a | n/a | No |
|
||||
| Blood pressure + glucose | First-class | BP only | Manual | No | No | Limited | Manual |
|
||||
| Medication compliance | Cadence-aware | No | No | No | No | No | Manual |
|
||||
| Derived wellness metrics | Yes (transparent) | Limited | Limited | Yes (closed)| Yes (closed)| Limited | No |
|
||||
| Custom clinician targets | Yes (audit-logged) | Limited | No | No | No | No | n/a |
|
||||
| Doctor-report PDF + FHIR | Yes (client-side) | No | No | No | No | No | n/a |
|
||||
| AI Insights | Multi-provider BYOK | No | Limited | Subscription| Subscription| Limited | n/a |
|
||||
| Encrypted at rest (AES-256-GCM)| Yes (your host) | Vendor cloud | Vendor cloud | Vendor cloud| Vendor cloud| Vendor cloud | Depends |
|
||||
| Subscription required | No | For some metrics| No | Yes | Yes | For some metrics| No |
|
||||
| Your data leaves device | Never | Withings cloud | Apple cloud | Oura cloud | WHOOP cloud | Fitbit cloud | Depends |
|
||||
|
||||
### Versus WHOOP / Oura / Fitbit ecosystems
|
||||
|
||||
The wrist-band ecosystems are good at what their hardware measures, and HealthLog does not try to be a ring or a strap. It does not ship a sensor, and it does not claim to match one. What it does is take the readings those devices already collect — plus the ones they don't hold, like cuff blood pressure, finger-stick or CGM glucose, and medication intake — and keep them on a server you run.
|
||||
|
||||
- **You own the data and the host.** No vendor account, no cloud round-trip, no subscription tier gating a metric you already recorded. AGPL-3.0, single `docker compose up`, encrypted at rest, on your NAS / homelab / VPS.
|
||||
- **It aggregates across providers instead of locking you to one.** Withings and WHOOP devices sync over OAuth2, a Google Health/Fitbit connection pulls Fitbit and Pixel data (experimental), an Apple Health `export.zip` folds your full history in, and the native iOS client streams HealthKit live — so a Withings scale, a WHOOP strap, an Apple Watch, and a glucometer land on one timeline instead of four apps. When two providers report the same metric, a per-metric source priority decides which one is canonical, so a dedicated scale outranks a wrist estimate and cumulative metrics like steps are never double-counted (see [Source priority](#source-priority)).
|
||||
- **It carries context the wrist bands don't.** Blood pressure, blood glucose, body composition, and cadence-aware medication compliance sit next to the wearable signals, so a trend reads against the whole picture rather than activity and sleep alone.
|
||||
- **The derived metrics are transparent, not a black box.** HealthLog computes a fitness-age and vascular-age delta, an HRV-balance and sleep-score band, a daily readiness and recovery score, and a coincident-deviation early-strain flag — each from your own measurements, each citing the inputs and the published method behind it, each degrading honestly to "not enough data" rather than fabricating a number. These are descriptive wellness framings, not clinical or training-grade assessments, and a score is only stored once its minimum inputs are present.
|
||||
|
||||
HealthLog is the right fit when you want your wearable and clinical numbers in one place that you control, with explainable analytics on top — not when you want a closed device that scores you and keeps the data.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, bone mass, and pulse oximetry (SpO₂) with interactive trend charts, moving averages, and traffic-light ranges based on ESH 2023, ADA 2024, and consensus pulse-oximeter guidance (NICE NG115). Body-composition + SpO₂ metrics sync automatically from Withings Body+ scales and ScanWatch devices. Every stored HealthKit metric now has a chart surface — flights climbed, audio exposure, walking speed/step-length/asymmetry/double-support, respiratory rate, the body-composition family, mobility, daylight, and more — and a metric/imperial display toggle renders walking speed in km/h while storage and exports stay canonical SI.
|
||||
|
||||
**Custom Thresholds** -- Override the computed default ranges per metric with the targets your clinician set. Audit-logged. Doctor Report PDF prints both your target and the standard reference.
|
||||
|
||||
**Customizable Dashboard** -- Show, hide, and drag-to-reorder every widget. Per-user layout with reset-to-defaults.
|
||||
|
||||
**Mood Logging** -- A five-face mood capture (a 1–5 scale) backed by a structured tag catalog and rated daily-life factors. Pick binary tags from categories covering feelings, sleep, health (including nutrition tags like ate-well, fast-food, no-sweets, big-meal), social, work, and hobbies; score rated factors — work, social, sleep quality, stress, and conflict — on their own scale (1–5, or yes/no for conflict), with stress and conflict flagged as inverse so a higher score reads as a worse day. Add a free-text note, then read it back through trend analytics and a mood-relations surface that associates your tags and factors with your other metrics. The standalone moodLog.app webhook is deprecated now that mood is tracked fully inside HealthLog; it stays functional for existing integrations.
|
||||
|
||||
**Medication Compliance** -- Flexible scheduling driven by a single recurrence engine: time windows, day-of-week recurrence, `intervalWeeks`, rolling intervals, RFC-5545 RRULE, one-time doses, as-needed (PRN), and cyclic on/off-week plans — so once-weekly GLP-1 injections, weekday-only doses, and non-daily plans score honestly against their real cadence rather than a daily denominator, everywhere from the dashboard tile to the detail page. Route of administration (oral / injection / other) carries the injection-site picker for any injection. Take / skip / snooze logging, compliance heatmaps, GLP-1 pen-inventory + injection-site rotation, structured side-effect tracking. External API for iOS Shortcuts integration.
|
||||
|
||||
**Withings Integration** -- OAuth2 device sync for scales, blood pressure monitors, and activity trackers with automatic deduplication.
|
||||
|
||||
**WHOOP Integration** -- Connect a WHOOP account with your own WHOOP developer OAuth client (client id/secret pasted into Settings, stored encrypted). Recovery, strain, sleep, body, and workout data sync server-side over OAuth2, with a connection test, manual sync (incremental or full history), and a resume control to recover from a revoked grant.
|
||||
|
||||
**Google Health / Fitbit Integration** _(experimental)_ -- Connect Fitbit and Pixel data through Google's Health API on your own Google Cloud OAuth client (client id/secret in Settings, encrypted at rest). Activity (steps, distance, active energy, floors, VO₂ max), health metrics (weight, body fat, SpO₂, resting heart rate, HRV, respiratory rate, wrist temperature, spot heart rate), per-stage sleep, and exercise workouts import server-side with the same connection-test / sync / resume controls as WHOOP. Because the Google Health API is young and several per-type value fields are not yet fully published, this connection is **experimental** — some data types may import nothing until the wire mapping is verified against a live account, and each operator's OAuth client needs Google brand verification plus an annual CASA assessment before it can leave Testing mode.
|
||||
|
||||
**Apple Health import** -- Drop your iOS `export.zip` on the import page. A streaming parser handles multi-gigabyte archives (Zip64), folds every `<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>` into the same timeline as your other metrics, and stays idempotent on re-upload. Per-type ingestion stats plus a live status endpoint so you can watch the progress on a long historical drain.
|
||||
|
||||
**AI Coach + Insights** -- Multi-provider, evidence-grounded health insights: per-metric assessments that read each vital against its reference range, correlations across your metrics, a daily briefing, a weekly report, a Health Score tile, and a conversational Coach grounded in your own data. Pick OpenAI, Anthropic Claude, ChatGPT via Codex device-OAuth (no API key needed), or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Feed the Coach a chosen set of data clusters (cardiovascular, body composition, activity, workouts, sleep, mood, glucose, medication, mobility, environment) with a soft budget cap that degrades the lowest-signal clusters first. Every claim links back to the measurements that produced it. The Coach now reads your history longitudinally — a narrative of how a metric has moved and a per-metric trajectory framing — so an answer reflects the direction of travel rather than only the latest reading. Local endpoints keep all data on your network.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/02-coach-pipeline.svg" alt="Coach pipeline: a user question through the snapshot builder and prompt assembler, into the provider chain (ChatGPT OAuth, OpenAI, Anthropic, local, admin-shared), through the response parser, out to a cited reply with mini-charts" width="900" />
|
||||
</p>
|
||||
|
||||
**Derived wellness metrics** -- A transparent metrics tier computed from your own measurements: a fitness-age band from VO₂max, a vascular-age delta, an HRV-balance band, a sleep score, a daily readiness and a stored recovery score, plus a coincident-deviation early-strain flag. Each one re-frames or blends signals you already recorded against age/sex norms or your personal baseline, cites the inputs and the published method behind it, and returns "not enough data" rather than a fabricated value when its minimum inputs are missing. The recovery score persists as a daily 0–100 series the dashboard, charts, and native client read without recomputing. These are descriptive wellness framings — not clinical or training-grade assessments.
|
||||
|
||||
**Doctor Report PDF Export** -- Generate professional medical reports client-side. Locale-aware (English/German), with vital sign summaries, BP/BMI/glucose classification, compliance rates, custom-threshold badges, and optional AI analysis.
|
||||
|
||||
**Health-record export (PDF + FHIR R4)** -- `POST /api/export/health-record` produces a selectable export: an enriched clinical PDF, a machine-readable HL7 FHIR R4 document bundle (LOINC-coded observations, a BP panel, medication statements, a diagnostic report), or both packaged as one zip. The selection chooses date range and per-domain sections; both formats read the same aggregator so they describe identical numbers, and the optional AI summary is an explicit opt-in section marked as not clinically validated. Optional patient identity (name, insurer, insurance number) on Account feeds the report cover and the FHIR `Patient`.
|
||||
|
||||
**Clinician-grade FHIR API + shareable record** -- A read-only HL7 FHIR R4 REST API (`GET /api/fhir/{metadata,Patient,Observation,MedicationStatement,MedicationAdministration,$everything}`) lets a clinician system pull your record in a standard shape, gated behind a dedicated `fhir:read` token scope. Alongside it, a shareable clinician record: create a scoped, time-limited share link, hand a clinician the read-only `/c/<token>` view, and revoke it when the visit is over. The two are not yet wired together — a share link does not expose the FHIR API (`capabilities.share.fhirApi=false`) — but each stands on its own.
|
||||
|
||||
**Built-in Feedback** -- Send bug reports and feature requests from inside the app. Stored in your HealthLog database — no GitHub config required. Optional GitHub escalation for admins.
|
||||
|
||||
**PWA with Offline Support** -- Installable on iOS and Android. Service worker with intelligent caching strategies for reliable offline access. A paginated, opaque-cursor sync delta feed (`GET /api/sync/changes`) with measurement tombstones lets native and offline clients reconcile against the server incrementally.
|
||||
|
||||
**Multi-Channel Notifications** -- Telegram (with inline action buttons), ntfy (self-hostable), Web Push (VAPID), and Apple Push (APNs) for the native iOS client. One dispatcher fans medication reminders out to whichever channels the user has enabled, with late/missed escalation.
|
||||
|
||||
**Sub-second dashboard** -- A persistent rollup tier pre-aggregates every measurement at DAY / WEEK / MONTH granularity. The dashboard and analytics paths read those buckets first and fall back to live SQL only when a tenant hasn't been backfilled yet, so paint time stays under 500 ms regardless of how many years of history you've imported. The above-the-fold tiles arrive together in a single first-paint snapshot, and a nightly job pre-generates the daily briefing so `/insights` is a cache read rather than a synchronous model call.
|
||||
|
||||
**Gamification** -- 59 persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
|
||||
|
||||
**Internationalization** -- English (default) and German UI with 2500+ translation keys each, guarded by a CI integrity test that fails the build on duplicate keys, drift between locales, or call-sites referencing keys that no locale ships. Numbers, dates, units, and AI prompts all locale-aware via `useFormatters()`. Browser-based detection with per-user override.
|
||||
|
||||
**Multi-tenant ready** _(v1.4)_ — Off-host AES-GCM-encrypted daily backups to any S3-compatible bucket (R2, B2, MinIO, AWS), encryption-key versioning + zero-downtime rotation CLI (`scripts/rotate-encryption-key.ts`), optional `HEALTHLOG_PROCESS_TYPE=web|worker|all` so HTTP and pg-boss can scale independently, and short-lived 24h access tokens with refresh-token rotation for native API clients. The browser cookie session is unchanged.
|
||||
|
||||
**Test connection buttons** _(v1.4)_ — One-click probes for Withings, moodLog.app, Web Push, Glitchtip, and Umami in addition to the existing AI / Telegram / ntfy tests. Each one rate-limited, sanitised against SSRF redirects, and surfaces a localisable `errorCode` so the UI can render the failure in the user's language.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
**3 minutes from `git clone` to a working install.** The bundled `docker-compose.yml` pulls a pre-built multi-arch image (`linux/amd64` + `linux/arm64`) from [GitHub Container Registry](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog); no build step required for self-hosters. Contributors who want to test local changes can `docker compose up --build`.
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MBombeck/HealthLog.git
|
||||
cd HealthLog
|
||||
git clone https://github.com/MBombeck/HealthLog.git && cd HealthLog
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Generate the three required secrets and paste them into `.env`:
|
||||
|
||||
```bash
|
||||
echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env
|
||||
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
Then bring the stack up:
|
||||
|
||||
```bash
|
||||
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Open **http://localhost:3000**. The first registered user becomes admin.
|
||||
Open **http://localhost:3000** — the first registered user becomes admin. The compose file pulls a pre-built multi-arch image (`amd64` + `arm64`) from [GHCR](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog); no build step required. Behind a reverse proxy, set `NEXT_PUBLIC_APP_URL` and `APP_URL` first — see the [self-hosting guide](https://docs.healthlog.dev/self-hosting/).
|
||||
|
||||
> Behind a reverse proxy (Caddy / Traefik / Nginx) for TLS, set `NEXT_PUBLIC_APP_URL` and `APP_URL` to your public URL in `.env` before starting. See [Self-Hosting → Reverse Proxy](https://docs.healthlog.dev/self-hosting/reverse-proxy/) for examples.
|
||||
## Self-hosting
|
||||
|
||||
---
|
||||
One container hosts both the web server and the job worker by default; split them via `HEALTHLOG_PROCESS_TYPE=web|worker` for horizontal scale. Encrypted daily backups to any S3-compatible bucket are opt-in via the admin panel, new users join by invite link or QR code, and the stack works out of the box behind Caddy, Traefik, Nginx, or [Coolify](https://coolify.io/).
|
||||
|
||||
## Tech Stack
|
||||
HealthLog ships releases roughly weekly. Pin a tag, back up before upgrades, and skim the [CHANGELOG](CHANGELOG.md) before pulling `latest`. The operator manual — reverse proxy, migrations, encryption-key rotation, backup and restore — lives at [docs.healthlog.dev](https://docs.healthlog.dev).
|
||||
|
||||
| Layer | Technology |
|
||||
| ------------- | ------------------------------------------------- |
|
||||
| Framework | Next.js 16 (App Router, React Server Components) |
|
||||
| Language | TypeScript (strict mode) |
|
||||
| Database | PostgreSQL 16 + Prisma 7 (61 models) |
|
||||
| Job Queue | pg-boss 12 (reminders, insights, backups) |
|
||||
| UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons |
|
||||
| Charts | Recharts 3 |
|
||||
| Data Fetching | TanStack Query 5 |
|
||||
| Forms | React Hook Form 7 + Zod 4 |
|
||||
| Auth | SimpleWebAuthn 13, Argon2id |
|
||||
| Notifications | Telegram Bot API, ntfy, Web Push (VAPID), APNs |
|
||||
| PDF | jsPDF (client-side generation) |
|
||||
| Testing | Vitest 4 |
|
||||
| Deployment | Docker (multi-stage Alpine) |
|
||||
| Native client | SwiftUI iOS app — public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa) |
|
||||
## Native iOS app
|
||||
|
||||
---
|
||||
A SwiftUI companion in public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa), built on the same API as the web client: live HealthKit two-way sync, medication reminders with action buttons that work without opening the app, and an on-device coach on Apple-Intelligence-capable iPhones. Code lives in [MBombeck/healthlog-iOS](https://github.com/MBombeck/healthlog-iOS).
|
||||
|
||||
## Security and Privacy
|
||||
## Tech stack
|
||||
|
||||
HealthLog is designed for people who take data ownership seriously.
|
||||
|
||||
- **Self-hosted** -- Your data never leaves your server. No telemetry, no third-party tracking.
|
||||
- **AES-256-GCM encryption** -- All stored secrets (OAuth tokens, API keys, VAPID keys, notification credentials, off-host backup payloads) are encrypted at rest.
|
||||
- **Your AI, your keys** -- AI insights run on a provider you choose: bring your own OpenAI / Anthropic key, sign in to ChatGPT via Codex device-OAuth, or point at a local OpenAI-compatible endpoint (Ollama, LM Studio, vLLM) so your health data never leaves your network. No model calls are made without a key you supply.
|
||||
- **Key versioning + zero-downtime rotation** -- Multiple encryption keys can coexist (`ENCRYPTION_KEYS` map + `ENCRYPTION_ACTIVE_KEY_ID`) and a CLI (`pnpm dlx tsx scripts/rotate-encryption-key.ts`) re-wraps every encrypted column from the old key to the new one without taking the app offline.
|
||||
- **Passkey authentication** -- WebAuthn as primary auth with password fallback (Argon2id + zxcvbn strength validation).
|
||||
- **Server-side sessions** -- PostgreSQL-backed with 30-day sliding expiry, HttpOnly/SameSite=Strict cookies.
|
||||
- **Security headers** -- CSP with nonces, HSTS, X-Frame-Options DENY, Permissions-Policy, Referrer-Policy.
|
||||
- **Rate limiting** -- Sliding window on auth and API endpoints.
|
||||
- **HMAC-SHA256 API tokens** -- Bearer tokens are hashed before storage.
|
||||
- **Offline IP geolocation** -- Bundled MaxMind GeoLite2 City + ASN databases resolve admin login-overview IPs without round-tripping to a third party. Public `ipwho.is` is only consulted when the local lookup misses.
|
||||
- **Wide-event structured logging** -- Every API route emits a single envelope with `action`, latency, request id, sampled payload (with secret redaction), and an optional Loki transport for self-hosted log aggregation.
|
||||
- **Audit logging** -- All sensitive operations tracked with IP addresses, dedup-windowed to keep the ledger compact under bursty writes.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/05-security-model.svg" alt="Security model: concentric layers — auth perimeter (passkey plus password fallback), session perimeter (HttpOnly cookie plus Postgres sessions), encrypted core (AES-256-GCM versioned keys), with side rails for the rate limiter, audit log, HMAC tokens, CSP/HSTS, and SSRF guard" width="900" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Required
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------- | ------------------------------------------------------------- |
|
||||
| `POSTGRES_PASSWORD` | Password for the bundled Postgres service (Docker Compose) |
|
||||
| `DATABASE_URL` | PostgreSQL connection string (uses `POSTGRES_PASSWORD` above) |
|
||||
| `ENCRYPTION_KEY` | 64-char hex string for AES-256-GCM |
|
||||
| `API_TOKEN_HMAC_KEY` | 64-char hex string for API token hashing |
|
||||
|
||||
### Optional
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------- | ---------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_APP_URL` | Public-facing URL (default: `http://localhost:3000`) |
|
||||
| `WITHINGS_CLIENT_ID` | Withings OAuth2 client ID |
|
||||
| `WITHINGS_CLIENT_SECRET` | Withings OAuth2 client secret |
|
||||
| `WITHINGS_REDIRECT_URI` | OAuth callback URL |
|
||||
| `WITHINGS_WEBHOOK_SECRET` | Webhook URL hardening secret |
|
||||
| `WHOOP_REDIRECT_URI` | WHOOP OAuth callback URL (client id/secret set in Settings) |
|
||||
| `WHOOP_WEBHOOK_SECRET` | HMAC secret for WHOOP webhook signature verification |
|
||||
| `FITBIT_REDIRECT_URI` | Google Health/Fitbit OAuth callback URL (defaults to `<NEXT_PUBLIC_APP_URL>/api/fitbit/callback`; client id/secret set in Settings) |
|
||||
| `TELEGRAM_WEBHOOK_SECRET` | Telegram bot webhook secret |
|
||||
|
||||
Telegram bot token, ntfy settings, Web Push VAPID keys, Umami, and GlitchTip URLs are configured in the **Admin Panel** and stored encrypted in the database.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
Data flows from every source — Withings, an Apple Health `export.zip`, the iPhone's live HealthKit, manual entry, moodLog — through the ingest endpoints into a single `Measurement` table and its pre-aggregated rollups, then back out to the dashboard, insights, Coach, and the doctor-report PDF.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/01-data-flow.svg" alt="Data flow: sources (Withings, Apple Health export, iPhone HealthKit, manual, moodLog) into ingest endpoints, into Measurement plus MeasurementRollup, out to dashboard, insights, Coach, and doctor PDF" width="900" />
|
||||
</p>
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router pages & API routes
|
||||
│ ├── api/ # REST API endpoints (180+ route files)
|
||||
│ ├── admin/ # Admin panel
|
||||
│ ├── auth/ # Login, register, passkey enrolment
|
||||
│ ├── medications/ # Medication management
|
||||
│ ├── measurements/ # Health metric entry
|
||||
│ ├── mood/ # Mood log
|
||||
│ ├── insights/ # AI-powered analytics
|
||||
│ ├── charts/ # Long-form charts
|
||||
│ ├── achievements/ # Gamification page
|
||||
│ ├── targets/ # Custom thresholds dashboard
|
||||
│ ├── notifications/ # Notification preferences matrix
|
||||
│ ├── bugreport/ # Built-in feedback / bug report
|
||||
│ ├── onboarding/ # 4-step guided setup
|
||||
│ └── settings/ # User preferences (8 top-level sections)
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn/ui primitives
|
||||
│ ├── layout/ # Shell (sidebar, topbar, bottom nav)
|
||||
│ ├── medications/ # Medication cards, forms, timeline
|
||||
│ ├── measurements/ # Measurement form, list
|
||||
│ ├── mood/ # Mood form, mood list
|
||||
│ ├── charts/ # Recharts wrappers
|
||||
│ ├── insights/ # AI insight status / advisor cards
|
||||
│ ├── gamification/ # Achievement cards, progress
|
||||
│ ├── monitoring/ # Umami / GlitchTip bootstrap
|
||||
│ └── settings/ # Settings-page section components
|
||||
├── lib/
|
||||
│ ├── auth/ # Session, audit, passkey logic
|
||||
│ ├── notifications/ # Dispatcher + channel senders
|
||||
│ ├── jobs/ # pg-boss worker (reminders, insights, backups)
|
||||
│ ├── analytics/ # Trend calculations, compliance, correlations
|
||||
│ ├── ai/ # Multi-provider client (OpenAI, Anthropic, Codex, local)
|
||||
│ ├── insights/ # Insight pipeline + medical prompts
|
||||
│ ├── gamification/ # Achievement definitions
|
||||
│ ├── feedback/ # Built-in feedback + GitHub escalation
|
||||
│ ├── moodlog/ # moodLog.app webhook + sync
|
||||
│ ├── monitoring/ # Umami / GlitchTip server-side hooks
|
||||
│ ├── withings/ # OAuth client, sync service
|
||||
│ ├── logging/ # Wide Events: builder, context, transports
|
||||
│ ├── i18n/ # Translations context & config
|
||||
│ ├── validations/ # Shared Zod schemas
|
||||
│ ├── api-handler.ts # apiHandler wrapper, requireAuth/requireAdmin
|
||||
│ ├── api-response.ts # { data, error } envelope helpers
|
||||
│ ├── crypto.ts # AES-256-GCM encrypt/decrypt
|
||||
│ ├── rate-limit.ts # Sliding-window rate limiter
|
||||
│ ├── db.ts # Prisma singleton
|
||||
│ └── doctor-report-pdf.ts # Client-side PDF generation
|
||||
├── hooks/ # React hooks
|
||||
└── generated/prisma/ # Generated Prisma client
|
||||
```
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- **RSC by default** -- `"use client"` only for interactive components
|
||||
- **API envelope** -- All responses follow `{ data, error }` shape via `apiSuccess()` / `apiError()` in `src/lib/api-response.ts`
|
||||
- **apiHandler wrapper** -- Every API route wraps its handler in `apiHandler()` (`src/lib/api-handler.ts`) for consistent error handling, Wide-Event structured logging, and `x-request-id` propagation
|
||||
- **Encrypted secrets** -- Withings tokens, API keys, VAPID keys, notification credentials
|
||||
- **Timezone-aware** -- `Europe/Berlin` for display, UTC in database
|
||||
- **Route protection** -- `proxy.ts` (Next.js 16's renamed middleware) checks session cookie, redirects unauthenticated requests
|
||||
- **Client-side PDF** -- Doctor reports generated in browser via jsPDF
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
All mutations require authentication via session cookie. External ingest uses Bearer tokens. A machine-readable OpenAPI 3.1 spec for the iOS-locked native subset lives at [`docs/api/openapi.yaml`](docs/api/openapi.yaml) — the source of truth for any client codegen (Swift / Kotlin / OpenAPI Generator).
|
||||
|
||||
<details>
|
||||
<summary><strong>Health Data</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ----------------------- | ----------------------------------------- |
|
||||
| `GET` | `/api/measurements` | List measurements (paginated, filterable) |
|
||||
| `POST` | `/api/measurements` | Create measurement |
|
||||
| `DELETE` | `/api/measurements/:id` | Delete measurement |
|
||||
| `GET` | `/api/analytics` | Trend summaries (7d/30d) |
|
||||
| `GET` | `/api/export` | Export as CSV or JSON |
|
||||
| `POST` | `/api/import` | Import from JSON |
|
||||
| `POST` | `/api/doctor-report` | Aggregated data for PDF |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Mood</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ----------------------------------- | -------------------- |
|
||||
| `GET` | `/api/mood-entries` | List mood entries |
|
||||
| `POST` | `/api/mood-entries` | Create mood entry |
|
||||
| `DELETE` | `/api/mood-entries/:id` | Delete mood entry |
|
||||
| `GET` | `/api/mood/analytics` | Mood trend analytics |
|
||||
| `GET` | `/api/mood/tags` | Structured mood-tag + rated-factor catalog |
|
||||
| `GET` | `/api/mood/insights` | Mood-relations associations |
|
||||
| `POST` | `/api/integrations/moodlog/webhook` | moodLog.app webhook (deprecated) |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Medications</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | --------------------------------- | ------------------------ |
|
||||
| `GET` | `/api/medications` | List all medications |
|
||||
| `POST` | `/api/medications` | Create medication |
|
||||
| `PUT` | `/api/medications/:id` | Update medication |
|
||||
| `DELETE` | `/api/medications/:id` | Delete medication |
|
||||
| `POST` | `/api/medications/:id/intake` | Log intake event |
|
||||
| `GET` | `/api/medications/:id/compliance` | Compliance stats |
|
||||
| `POST` | `/api/ingest/medication` | External intake (Bearer) |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Auth and Integrations</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------- | -------------------------------- | ----------------------------------- |
|
||||
| `POST` | `/api/auth/register` | Create account |
|
||||
| `POST` | `/api/auth/login` | Password login |
|
||||
| `POST` | `/api/auth/logout` | Destroy session |
|
||||
| `GET` | `/api/auth/me` | Current user profile + avatar URL |
|
||||
| `POST` | `/api/auth/password` | Change password |
|
||||
| `PATCH` | `/api/auth/profile` | Update profile fields |
|
||||
| `POST` | `/api/user/avatar` | Upload a profile photo (self-hosted)|
|
||||
| `DELETE`| `/api/user/avatar` | Remove the profile photo |
|
||||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows (4 sub-routes) |
|
||||
| `GET` | `/api/auth/passkeys` | List enrolled passkeys |
|
||||
| `POST` | `/api/auth/codex/device-start` | ChatGPT (Codex) device-OAuth start |
|
||||
| `POST` | `/api/auth/codex/device-poll` | Codex device-OAuth poll for token |
|
||||
| `POST` | `/api/auth/codex/disconnect` | Revoke the stored Codex session |
|
||||
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
|
||||
| `POST` | `/api/withings/sync` | Trigger manual Withings sync |
|
||||
| `POST` | `/api/withings/webhook` | Withings notification webhook |
|
||||
| `GET` | `/api/whoop/connect` | Initiate WHOOP OAuth |
|
||||
| `POST` | `/api/whoop/sync` | Manual WHOOP sync (`{ fullSync }` for full history) |
|
||||
| `POST` | `/api/integrations/whoop/test` | Probe a saved WHOOP connection |
|
||||
| `POST` | `/api/integrations/whoop/resume` | Resume a WHOOP connection after a revoked grant |
|
||||
| `GET` | `/api/fitbit/connect` | Initiate Google Health/Fitbit OAuth |
|
||||
| `POST` | `/api/fitbit/sync` | Manual Fitbit sync (`{ fullSync }` for full history) |
|
||||
| `POST` | `/api/integrations/fitbit/test` | Probe a saved Google Health/Fitbit connection |
|
||||
| `POST` | `/api/integrations/fitbit/resume`| Resume a Fitbit connection after a revoked grant |
|
||||
| `POST` | `/api/insights/generate` | Regenerate AI insights |
|
||||
| `GET` | `/api/insights/comprehensive` | Aggregated insight payload |
|
||||
| `GET` | `/api/gamification/achievements` | Achievement progress |
|
||||
| `GET` | `/api/health` | Docker health check |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Personalization (Thresholds + Dashboard)</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | -------------------------- | ---------------------------------------------- |
|
||||
| `GET` | `/api/user/thresholds` | Read per-user threshold overrides |
|
||||
| `PUT` | `/api/user/thresholds` | Upsert thresholds (rate-limited, audit-logged) |
|
||||
| `GET` | `/api/insights/targets` | Effective ranges (defaults + overrides merged) |
|
||||
| `GET` | `/api/dashboard/widgets` | Read dashboard layout |
|
||||
| `PUT` | `/api/dashboard/widgets` | Persist dashboard layout (show/hide/reorder) |
|
||||
| `POST` | `/api/onboarding/complete` | Mark onboarding finished |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Feedback + API Tokens</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | ----------------------- | ----------------------------------- |
|
||||
| `POST` | `/api/feedback` | Submit in-app feedback |
|
||||
| `GET` | `/api/bugreport/status` | Check published GitHub issue state |
|
||||
| `GET` | `/api/tokens` | List own API tokens |
|
||||
| `POST` | `/api/tokens` | Mint new API token (Bearer, hashed) |
|
||||
| `DELETE` | `/api/tokens/:id` | Revoke API token |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Notifications</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | -------------------------------- | ----------------------------------- |
|
||||
| `GET` | `/api/notifications/preferences` | Read per-channel × per-event matrix |
|
||||
| `PUT` | `/api/notifications/preferences` | Update preferences |
|
||||
| `GET` | `/api/notifications/vapid` | VAPID public key for Web Push |
|
||||
| `POST` | `/api/notifications/web-push` | Register Web Push subscription |
|
||||
| `POST` | `/api/telegram/webhook` | Telegram bot inline-button callback |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Admin (admin role required)</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ------------------------------------- | ------------------------------------- |
|
||||
| `GET` | `/api/admin/status` | System + integration status |
|
||||
| `GET` | `/api/admin/users` | List users |
|
||||
| `POST` | `/api/admin/users/:id/reset-password` | Force password reset |
|
||||
| `GET` | `/api/admin/feedback` | All feedback / bug reports |
|
||||
| `POST` | `/api/admin/feedback/:id/github` | Escalate feedback to GitHub issue |
|
||||
| `GET` | `/api/admin/audit-log` | Audit-log viewer |
|
||||
| `GET` | `/api/admin/ai-settings` | Read shared AI provider config |
|
||||
| `PUT` | `/api/admin/ai-settings` | Update shared AI provider config |
|
||||
| `GET` | `/api/admin/tokens` | All issued API tokens |
|
||||
| `POST` | `/api/admin/notifications/test` | Send test notification |
|
||||
| `GET` | `/api/admin/data` | Data backups + counts |
|
||||
| `GET` | `/api/admin/status-overview` | Aggregated status for the 6-card grid |
|
||||
| `POST` | `/api/admin/backup/test` | Probe S3-compatible backup target |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Public + v1.4 additions</strong></summary>
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ---------------------------------- | ----------------------------------------------- |
|
||||
| `GET` | `/api/version` | Public — version + build SHA + license, no auth |
|
||||
| `POST` | `/api/integrations/withings/test` | Probe a saved Withings connection |
|
||||
| `POST` | `/api/integrations/moodlog/test` | Probe moodLog.app webhook reachability |
|
||||
| `POST` | `/api/notifications/web-push/test` | Send a test Web Push to the current user |
|
||||
| `POST` | `/api/admin/monitoring/glitchtip-test` | Trigger a Glitchtip ingest probe (admin) |
|
||||
| `POST` | `/api/admin/monitoring/umami-test` | Verify Umami script + website ID resolve (admin) |
|
||||
| `POST` | `/api/auth/refresh` | Native client refresh-token rotation (POST body opts into revoke) |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>FHIR + clinician sharing (v1.11)</strong></summary>
|
||||
|
||||
Read-only HL7 FHIR R4 REST API, gated behind the `fhir:read` token scope:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | -------------------------------------- | ---------------------------------------------------- |
|
||||
| `GET` | `/api/fhir/metadata` | FHIR CapabilityStatement |
|
||||
| `GET` | `/api/fhir/Patient` | Patient resource for the token's user |
|
||||
| `GET` | `/api/fhir/Observation` | Vitals + labs as LOINC-coded observations |
|
||||
| `GET` | `/api/fhir/MedicationStatement` | Medication regimen |
|
||||
| `GET` | `/api/fhir/MedicationAdministration` | Logged intake events |
|
||||
| `GET` | `/api/fhir/$everything` | Bundle of every resource above |
|
||||
|
||||
Clinician share-link lifecycle — create a scoped, time-limited link, hand a clinician the read-only `/c/<token>` view, and revoke it after the visit. Share links do not expose the FHIR API (`capabilities.share.fhirApi=false`).
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Integrations
|
||||
|
||||
| Integration | Setup | Purpose |
|
||||
| ------------------------ | ------------- | ---------------------------------------- |
|
||||
| **Withings** | Env vars | Auto-sync weight, BP, and activity |
|
||||
| **WHOOP** | User Settings | OAuth2 sync of recovery, strain, sleep, body, and workouts (BYO-keys) |
|
||||
| **Google Health / Fitbit** | User Settings | OAuth2 sync of activity, health metrics, sleep, and workouts for Fitbit/Pixel (BYO-keys, **experimental**) |
|
||||
| **Telegram** | Admin Panel | Medication reminders with inline buttons |
|
||||
| **ntfy** | User Settings | Self-hosted push notifications |
|
||||
| **Web Push** | Admin Panel | Browser-native VAPID notifications |
|
||||
| **OpenAI / Anthropic / local** | User Settings | AI health insights (BYOK or local endpoint) |
|
||||
| **moodLog.app** | User Settings | Mood tracking sync (deprecated — mood is now tracked natively) |
|
||||
| **Umami** | Admin Panel | Privacy-friendly analytics |
|
||||
| **GlitchTip** | Admin Panel | Sentry-compatible error tracking |
|
||||
|
||||
### Source priority
|
||||
|
||||
When two providers report the same metric — a Withings scale and an Apple Watch both logging weight, or HealthKit and Fitbit both reporting steps — HealthLog keeps every row as an audit trail but applies a per-metric source priority to decide which reading is canonical. Cumulative metrics (steps, distance, active energy, floors) pick exactly one source per day so they are never double-counted; point measurements (weight, blood pressure, HRV) pick a display-preferred source. The default ladder reflects measurement reliability for each metric class: a dedicated scale outranks a wrist estimate for weight, a recovery strap leads the sleep and HRV ladders, and a cuff leads blood pressure. The ladder is per-user adjustable, and an optional device-type tie-break decides between two devices on the same source. The underlying idea is [data fusion](https://en.wikipedia.org/wiki/Data_fusion) — combining several imperfect sensors into a single, more reliable estimate rather than averaging them blindly.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/04-source-priority.svg" alt="Source priority: three sources for the same metric on the same day, the resolver picking one canonical row per cumulative-versus-point ladder while the losing rows stay in the audit trail" width="900" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
# Prerequisites: Node.js 22, pnpm, PostgreSQL
|
||||
|
||||
cp .env.example .env
|
||||
pnpm install
|
||||
pnpm db:generate
|
||||
pnpm db:migrate
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Scripts
|
||||
|
||||
```bash
|
||||
pnpm dev # Development server
|
||||
pnpm build # Production build
|
||||
pnpm lint # ESLint
|
||||
pnpm typecheck # TypeScript strict check
|
||||
pnpm test # Vitest
|
||||
pnpm format # Prettier
|
||||
|
||||
pnpm db:generate # Generate Prisma client
|
||||
pnpm db:migrate # Create & apply migration
|
||||
pnpm db:migrate:deploy # Apply migrations (production)
|
||||
pnpm db:studio # Prisma Studio GUI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
The included `docker-compose.yml` runs the app and PostgreSQL. The entrypoint automatically waits for the database, runs pending migrations, and starts the server.
|
||||
|
||||
The app listens on port **3000**. Place it behind Nginx, Caddy, or Traefik for TLS termination. Works out of the box with [Coolify](https://coolify.io/).
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/diagrams/03-self-hosting-topology.svg" alt="Self-hosting topology: internet → reverse proxy → Next.js app + pg-boss worker → PostgreSQL, with GHCR image pull and optional Coolify autodeploy and S3 backup" width="900" />
|
||||
</p>
|
||||
|
||||
For a single-process default the same container hosts both the web and worker (`HEALTHLOG_PROCESS_TYPE=all`, the default). Split them via `HEALTHLOG_PROCESS_TYPE=web` and `HEALTHLOG_PROCESS_TYPE=worker` for horizontal scale. Off-host AES-GCM daily backups to any S3-compatible bucket (R2, B2, MinIO, AWS) are opt-in via the admin panel. See [`docs/self-hosting/`](docs/self-hosting/) and [`docs/ops/`](docs/ops/) for the full operator manual.
|
||||
|
||||
---
|
||||
Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL 16 with Prisma, pg-boss for jobs, Tailwind CSS 4 + shadcn/ui, Recharts, WebAuthn passkeys, Vitest + Playwright, multi-stage Alpine Docker image. The OpenAPI 3.1 contract for native clients is generated from the Zod schemas at [`docs/api/openapi.yaml`](docs/api/openapi.yaml).
|
||||
|
||||
## Documentation
|
||||
|
||||
The full documentation set lives under [`docs/`](docs/) (also published at [docs.healthlog.dev](https://docs.healthlog.dev)). A technical visitor browsing the source will also find a navigational `README.md` in each key code directory (`src/`, `src/lib/`, `src/app/`, `src/components/`, `prisma/`, `scripts/`, `e2e/`).
|
||||
| | |
|
||||
| ---------------------- | ------------------------------------------------ |
|
||||
| User and operator docs | [docs.healthlog.dev](https://docs.healthlog.dev) |
|
||||
| Release history | [CHANGELOG.md](CHANGELOG.md) |
|
||||
| API contract | [`docs/api/openapi.yaml`](docs/api/openapi.yaml) |
|
||||
| Security policy | [SECURITY.md](SECURITY.md) |
|
||||
| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
|
||||
|
||||
| Area | Where | What it covers |
|
||||
| ---- | ----- | -------------- |
|
||||
| Self-hosting | [`docs/self-hosting/`](docs/self-hosting/) | Getting started, reverse-proxy setup, horizontal scaling, [reproductive-health data on your own server](docs/self-hosting/reproductive-health-data.md) |
|
||||
| Threat model | [`docs/security/cycle-data-threat-model.md`](docs/security/cycle-data-threat-model.md) | What self-hosting cycle data does and does not protect against |
|
||||
| Operator runbooks | [`docs/ops/`](docs/ops/) | Deploy, encryption-key rotation, backup/restore, env check, migrations, TLS pinning |
|
||||
| API contract | [`docs/api/`](docs/api/) | OpenAPI 3.1 spec (`openapi.yaml`) for the native client subset |
|
||||
| Integrations | [`docs/integrations/`](docs/integrations/) | Withings, WHOOP, Google Health/Fitbit, Apple Health, AI providers |
|
||||
| Architecture diagrams | [`docs/diagrams/`](docs/diagrams/) | The five SVG diagrams embedded above |
|
||||
| Migration notes | [`docs/migration/`](docs/migration/) | Cross-version upgrade notes |
|
||||
| Design records | [`docs/adr/`](docs/adr/), [`docs/ui-guidelines.md`](docs/ui-guidelines.md) | Architecture decision records + UI conventions |
|
||||
| Contributor manual | [`CLAUDE.md`](CLAUDE.md), [`CONTRIBUTING.md`](CONTRIBUTING.md) | Conventions, branch/release model, the critical-files map |
|
||||
## Status
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
| Release line | Focus |
|
||||
| ------------ | ----- |
|
||||
| **v1.4.x** | Web maturity — Apple Health import, AI Coach, persistent rollup tier, multi-provider AI, doctor PDF, encryption-key rotation, Coolify autodeploy. Roughly weekly cadence. |
|
||||
| **v1.5** | Native iOS client (SwiftUI) in public beta via [TestFlight](https://testflight.apple.com/join/bucuTBpa). Backend contract locked in [`docs/api/openapi.yaml`](docs/api/openapi.yaml); the iOS app lives in a separate repository and ingests via the same `/api/measurements/batch` and `/api/auth/refresh` surfaces the web uses. |
|
||||
| **v1.6 – v1.7** | Medication editor overhaul + route of administration (v1.6), then health-record export (PDF + HL7 FHIR R4), flexible schedules (RRULE / rolling / interval-weeks / one-time / PRN / cyclic) with cadence-canonical compliance, full HealthKit metric coverage, a metric/imperial display preference, a first-paint dashboard snapshot, and a sync delta feed for offline clients (v1.7). |
|
||||
| **v1.8 – v1.10** | Insights redesign with selectable time ranges (v1.8 – v1.9), then a derived-metrics tier (v1.10): fitness-age, vascular-age delta, HRV balance, sleep score, readiness and recovery scores, and a coincident-deviation early-strain flag — each computed from your own measurements, each citing its inputs and degrading to "not enough data" rather than fabricating a value. |
|
||||
| **v1.11 – v1.12** (current) | WHOOP device sync, a read-only HL7 FHIR R4 API with shareable clinician records, mood-relations and sleep-depth (hypnogram) insights (v1.11); then a Google Health/Fitbit connection for Fitbit and Pixel wearables (experimental) and a rebuilt mood log — five-face capture, a structured tag catalog, and rated daily-life factors (v1.12). |
|
||||
| **v2.x** (planned) | Multi-tenant hardening, expanded device passthrough (Garmin / Polar), opt-in cross-user aggregate research mode (off by default; never enabled without explicit consent). |
|
||||
|
||||
Two known limitations carry forward from v1.11: dedup across two sources of the same standard vital is deferred (both rows persist, source priority decides display), and the coach's durable conversation-summary / remembered-facts layer is deferred — each answer reasons from your data afresh rather than from a running memory.
|
||||
|
||||
The detailed changelog lives in [`CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
---
|
||||
|
||||
## Native iOS client
|
||||
|
||||
<p align="center">
|
||||
<a href="https://testflight.apple.com/join/bucuTBpa"><strong>Join the TestFlight public beta →</strong></a>
|
||||
</p>
|
||||
|
||||
A SwiftUI iOS companion that lives on the same `/api` surfaces as the web client — same server, same data, same source of truth. The phone is a view + a write-ahead log; your server is the database. Code lives in a separate repo: [github.com/MBombeck/healthlog-iOS](https://github.com/MBombeck/healthlog-iOS).
|
||||
|
||||
### What the phone unlocks
|
||||
|
||||
- **HealthKit two-way sync.** Today's step count, weight, blood pressure, HRV, sleep, glucose, etc. read live from Apple Health and round-trip to your server through `HKObserverQuery` + `HKAnchoredObjectQuery` + a `BGProcessingTask` for guaranteed delivery. `HKMetadataKeyExternalUUID` on every iOS-side write keeps the server and Apple Health from echoing each other.
|
||||
- **Medication reminders that actually fire.** Local notifications with action buttons (Taken / Snooze 15 min / Skipped) that hit the server's mark-intake endpoint directly — without opening the app.
|
||||
- **On-device AI Coach.** A conversational surface powered by Apple's Foundation Models framework on iOS 26+ Apple-Intelligence-eligible iPhones. The prompt and the response never leave the device — your numbers, your health questions, your phone.
|
||||
- **Passkey-first auth.** Sign in with Face ID or Touch ID via WebAuthn; email + magic-link is the fallback. SPKI public-key pinning on every authenticated request, Keychain with `afterFirstUnlockThisDeviceOnly`, and a log sanitizer that filters tokens and hostnames out of every line.
|
||||
- **Doctor-report export.** Generate a FHIR-flavoured PDF bundle from the iPhone based on the LOINC mappings reviewed on the server's clinician surface.
|
||||
|
||||
### Built on Stanford Spezi
|
||||
|
||||
The iOS client adopts the [Stanford Spezi](https://spezi.stanford.edu) digital-health framework where it earns its keep — battle-tested code maintained by Stanford's Biodesign Digital Health group rather than rolled by hand:
|
||||
|
||||
- **SpeziHealthKit** for the HealthKit-read pipeline (live and historical),
|
||||
- **SpeziChat** under the on-device AI Coach surface,
|
||||
- **SpeziFHIR** + **HealthKitOnFHIR** for the doctor-report export bundle,
|
||||
- **SpeziAccessGuard**, **SpeziScheduler**, **SpeziMedication** queued for the next release line.
|
||||
|
||||
Spezi exists because medical-device-grade software needs medical-device-grade primitives — the same modules ship in clinical-trial apps at Stanford Medicine. Adopting them gives the iOS client a quality + security floor a solo-maintainer code-base could not otherwise hit.
|
||||
|
||||
### Status
|
||||
|
||||
`v0.6.1.x` ships almost daily; the [iOS repo CHANGELOG](https://github.com/MBombeck/healthlog-iOS/blob/main/CHANGELOG.md) and tag list track what landed in each build. German-primary UI with English secondary. iOS 18+ minimum (iOS 26+ for the on-device Coach). AGPL-3.0, same as this repo.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://testflight.apple.com/join/bucuTBpa">TestFlight beta</a> ·
|
||||
<a href="https://github.com/MBombeck/healthlog-iOS">iOS repo</a> ·
|
||||
<a href="https://github.com/MBombeck/healthlog-iOS/issues">iOS issues</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
- Code style: `pnpm format && pnpm lint`
|
||||
- Type safety: `pnpm typecheck` must pass
|
||||
- Tests: `pnpm test`
|
||||
- UI language: English by default, German selectable per user. Code, comments, and commits: English.
|
||||
|
||||
---
|
||||
Actively developed — new releases roughly weekly, issue reports and PRs welcome. Behaviour and schema can change between versions; migrations are forward-only.
|
||||
|
||||
## License
|
||||
|
||||
HealthLog is licensed under the [GNU Affero General Public License v3.0](LICENSE).
|
||||
HealthLog is licensed under the [PolyForm Noncommercial License 1.0.0](LICENSE): free to use, self-host, and modify for noncommercial purposes. Commercial use requires a separate agreement — open an issue or reach out via [healthlog.dev](https://healthlog.dev). Releases up to and including v1.15.18 were published under AGPL-3.0 and remain available under that license.
|
||||
|
||||
---
|
||||
|
||||
@@ -685,5 +94,5 @@ HealthLog is licensed under the [GNU Affero General Public License v3.0](LICENSE
|
||||
<a href="https://demo.healthlog.dev">Live Demo</a> ·
|
||||
<a href="https://docs.healthlog.dev">Docs</a> ·
|
||||
<a href="https://testflight.apple.com/join/bucuTBpa">iOS TestFlight</a> ·
|
||||
<a href="https://buymeacoffee.com/mbombeck">Buy Me A Coffee</a>
|
||||
<a href="https://buymeacoffee.com/mbombeck">Buy Me a Coffee</a>
|
||||
</p>
|
||||
+42
-11
@@ -4,17 +4,29 @@ services:
|
||||
# For horizontal scaling switch to the web/worker split below
|
||||
# (HEALTHLOG_PROCESS_TYPE=web on this service + uncomment app-worker).
|
||||
app:
|
||||
# Tracks the GHCR `:latest` multi-arch image. `pull_policy: always`
|
||||
# is the load-bearing line — without it, Docker keeps a stale
|
||||
# `:latest` digest cached on the host and skips the registry round-
|
||||
# trip on `compose up`, which is what kept Coolify re-deploying the
|
||||
# previous image after every tag push. With pull_policy=always the
|
||||
# compose-up always re-checks the registry digest and pulls when
|
||||
# GHCR has a fresher manifest. The small bandwidth cost on every
|
||||
# `compose up` is the price of digest-correctness.
|
||||
image: ghcr.io/mbombeck/healthlog:latest
|
||||
# Tracks the GHCR `:latest` multi-arch image by default.
|
||||
# `pull_policy: always` is the load-bearing line — without it,
|
||||
# Docker keeps a stale `:latest` digest cached on the host and
|
||||
# skips the registry round-trip on `compose up`, which is what kept
|
||||
# Coolify re-deploying the previous image after every tag push.
|
||||
# With pull_policy=always the compose-up always re-checks the
|
||||
# registry digest and pulls when GHCR has a fresher manifest.
|
||||
#
|
||||
# HEALTHLOG_IMAGE_REF pins the image deterministically without
|
||||
# editing this file: set it in the compose `.env` (or the Coolify
|
||||
# env vars) to `:vX.Y.Z` for a tag or `@sha256:<digest>` for an
|
||||
# immutable digest reference (see docs/ops/deploy.md, "Digest
|
||||
# pinning"; scripts/promote-digest.ts resolves + verifies the
|
||||
# digest after a release). Unset, everything behaves exactly as
|
||||
# before — `:latest` plus pull_policy.
|
||||
image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
|
||||
pull_policy: always
|
||||
container_name: healthlog-app
|
||||
# The app runs as the unprivileged `nextjs` user and never needs a
|
||||
# setuid/setgid escalation; blocking privilege gain caps what a
|
||||
# compromised process inside the container can do.
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
@@ -65,6 +77,17 @@ services:
|
||||
# container (the `environment:` block is a whitelist).
|
||||
WHOOP_WEBHOOK_SECRET: "${WHOOP_WEBHOOK_SECRET:-}"
|
||||
WHOOP_REDIRECT_URI: "${WHOOP_REDIRECT_URI:-}"
|
||||
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
|
||||
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
|
||||
# deployment-dashboard URL (e.g. the Coolify UI) linked in
|
||||
# deploy-failure notifications. Listed here so operator values in
|
||||
# .env reach the container (the `environment:` block is a whitelist).
|
||||
DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
|
||||
DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
|
||||
# Optional replay hardening: when "true", the deploy webhook
|
||||
# requires a fresh X-Deploy-Webhook-Timestamp header (±5 min).
|
||||
# See docs/ops/deploy.md.
|
||||
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}"
|
||||
# TLS leaf SPKI-change alarm baseline (optional). The native client
|
||||
# pins the served TLS leaf; this is the known-good pin set the monitor
|
||||
# alarms against on a rotation. Comma-separated for the dual-pin
|
||||
@@ -92,7 +115,7 @@ services:
|
||||
# See docs/self-hosting/scaling.md for the full guide.
|
||||
#
|
||||
# app-worker:
|
||||
# image: ghcr.io/mbombeck/healthlog:latest
|
||||
# image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
@@ -118,8 +141,16 @@ services:
|
||||
# restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
# Pinned to the concrete minor instead of the floating `16-alpine`
|
||||
# alias so a database update is always a deliberate, reviewable
|
||||
# change (at pin time `16-alpine` resolved to this same digest, so
|
||||
# existing deployments see no image change). Minor releases share
|
||||
# the on-disk format — bumping this pin is safe without a dump /
|
||||
# restore; a major-version bump (17+) is not.
|
||||
image: postgres:16.14-alpine
|
||||
container_name: healthlog-db
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
environment:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||||
|
||||
+3290
-107
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
---
|
||||
file: docs/apple-store-connect-checklist.md
|
||||
purpose: Step-by-step ASC submission checklist for HealthLog iOS — covering medical-device declaration, privacy nutrition labels, and ASC metadata
|
||||
audience: Marc (the maintainer) before submitting to App Store
|
||||
audience: the maintainer, before submitting to App Store
|
||||
estimated_read_time: 15 min
|
||||
last_updated: 2026-05-15
|
||||
---
|
||||
@@ -10,7 +10,7 @@ last_updated: 2026-05-15
|
||||
|
||||
## TL;DR
|
||||
|
||||
Three submission gates that need Marc's hand at ASC:
|
||||
Three submission gates that need the maintainer's hand at ASC:
|
||||
1. Regulated-Medical-Device declaration (Guideline 1.4 + 5.1.1(ix))
|
||||
2. Privacy Nutrition Labels matching the iOS Privacy Manifest (Guideline 5.1.2)
|
||||
3. Standard metadata (name, description, screenshots, categories, age rating)
|
||||
@@ -29,7 +29,7 @@ Plus pre-submit final checks.
|
||||
**What to check**:
|
||||
- [ ] Check "No" on the medical-device toggle
|
||||
- [ ] Add the description above to the medical-device-justification field if Apple shows one
|
||||
- [ ] Cross-reference the in-app `/privacy` page (https://healthlog.bombeck.io/privacy) §7 MDR-boundary
|
||||
- [ ] Cross-reference the in-app `/privacy` page (https://<your-instance>/privacy) §7 MDR-boundary
|
||||
|
||||
**Why this matters**: Apple's first-submission rejection rate for health apps is ~40%. The most common reason is unclear medical-device classification. The above wording mirrors the in-app disclaimer + the per-feature gating; consistency between the form and the app is what Apple looks for.
|
||||
|
||||
@@ -37,7 +37,7 @@ Plus pre-submit final checks.
|
||||
|
||||
**Where**: App Store Connect → App Privacy → "Edit" the privacy choices.
|
||||
|
||||
**Cross-reference**: every data category here MUST match what the iOS Privacy Manifest (in the iOS repo) declares. iOS-Claude's v0.3.0 release extended the manifest to: Email + OtherUserContent + SensitiveInfo + DeviceID + UserID + AccountManagement-purpose.
|
||||
**Cross-reference**: every data category here MUST match what the iOS Privacy Manifest (in the iOS repo) declares. The iOS client's v0.3.0 release extended the manifest to: Email + OtherUserContent + SensitiveInfo + DeviceID + UserID + AccountManagement-purpose.
|
||||
|
||||
### Data categories to declare
|
||||
|
||||
@@ -86,7 +86,7 @@ For each category below: **Linked to user, used for App Functionality + Account
|
||||
- Location (Precise or Coarse) — workout GPS routes are opt-in per-workout; if the iOS app implements them, declare; otherwise omit
|
||||
- Purchases
|
||||
- Financial Info
|
||||
- Diagnostics, Crash Data, Performance Data, Other Diagnostic Data — if iOS uses any analytics SDK, declare; otherwise omit. Verify with iOS-Claude.
|
||||
- Diagnostics, Crash Data, Performance Data, Other Diagnostic Data — if iOS uses any analytics SDK, declare; otherwise omit. Verify against the iOS client.
|
||||
- Advertising Data
|
||||
- Other Usage Data
|
||||
|
||||
@@ -101,7 +101,7 @@ For each category below: **Linked to user, used for App Functionality + Account
|
||||
- [ ] Subtitle: 30-char tagline (suggestion: "Personal health-data log + Coach")
|
||||
- [ ] Primary category: Health & Fitness
|
||||
- [ ] Secondary category: Medical (if Apple accepts non-medical-device classification for it)
|
||||
- [ ] Content rights: confirm Marc owns the app rights
|
||||
- [ ] Content rights: confirm the maintainer owns the app rights
|
||||
- [ ] Age rating: 17+ (because Sensitive Health Info content)
|
||||
|
||||
### Description
|
||||
@@ -114,36 +114,36 @@ For each category below: **Linked to user, used for App Functionality + Account
|
||||
- [ ] iPad screenshots if supporting iPad.
|
||||
|
||||
### URLs
|
||||
- [ ] Marketing URL: https://healthlog.bombeck.io (or marketing site once landing repo's v1.4.25 deployed)
|
||||
- [ ] Privacy Policy URL: **https://healthlog.bombeck.io/privacy** (ships with v1.4.25.1 hotfix)
|
||||
- [ ] Marketing URL: https://<your-instance> (or the marketing site once the landing repo's v1.4.25 is deployed)
|
||||
- [ ] Privacy Policy URL: **https://<your-instance>/privacy** (ships with v1.4.25.1 hotfix)
|
||||
- [ ] Support URL: https://github.com/MBombeck/HealthLog/issues
|
||||
|
||||
### App Review Information
|
||||
- [ ] Demo account credentials: Marc creates a read-only demo account on `demo.healthlog.dev` and provides credentials in the demo account fields
|
||||
- [ ] Demo account credentials: the maintainer creates a read-only demo account on `demo.healthlog.dev` and provides credentials in the demo account fields
|
||||
- [ ] Review notes: explain MDR boundary + AI Coach safety contract + Research Mode gating; reference `/privacy` §7 MDR-boundary. Apple reviewers DO read this and DO check the in-app behavior matches the claim.
|
||||
- [ ] Sign-in required: yes (provide demo credentials)
|
||||
|
||||
### Pricing + Availability
|
||||
- [ ] Free
|
||||
- [ ] Availability: select countries (Marc-decision — recommend EU + US + Canada + Australia + Japan for first launch)
|
||||
- [ ] Availability: select countries (maintainer decision — recommend EU + US + Canada + Australia + Japan for first launch)
|
||||
|
||||
## Section 4 — Pre-Submit Final Check
|
||||
|
||||
Run through this list:
|
||||
|
||||
- [ ] Privacy Policy URL responds 200 (curl https://healthlog.bombeck.io/privacy)
|
||||
- [ ] Privacy Policy URL responds 200 (curl https://<your-instance>/privacy)
|
||||
- [ ] iOS Privacy Manifest categories match the App Privacy declarations in §2
|
||||
- [ ] Demo account works on the demo server
|
||||
- [ ] iOS app's TestFlight build is on the latest commit
|
||||
- [ ] Marc has the APNs .p8 backed up to 1Password or similar (see `.planning/v15-apns-key-handling.md`)
|
||||
- [ ] Marc's Apple Developer Program subscription is active
|
||||
- [ ] The maintainer has the APNs .p8 backed up to a password manager
|
||||
- [ ] The maintainer's Apple Developer Program subscription is active
|
||||
|
||||
## Section 5 — Common First-Submission Rejection Reasons (Apple Health-App-Specific)
|
||||
|
||||
- Guideline 1.4.1: "Medical claims" — wording in app or screenshots implies diagnosis/treatment → fix: tone down wording, lean on the MDR-boundary disclaimer
|
||||
- Guideline 5.1.1(v): "Account Deletion missing" — iOS-Claude added this in v0.3.0
|
||||
- Guideline 5.1.2(i): "Third-Party AI consent" — DEFERRED to v0.3.1 per iOS-Claude release notes; resubmission may flag
|
||||
- Guideline 5.1.2: "Privacy Manifest mismatch" — see §2; iOS-Claude needs to ensure the manifest matches
|
||||
- Guideline 5.1.1(v): "Account Deletion missing" — the iOS client added this in v0.3.0
|
||||
- Guideline 5.1.2(i): "Third-Party AI consent" — DEFERRED to v0.3.1 per the iOS release notes; resubmission may flag
|
||||
- Guideline 5.1.2: "Privacy Manifest mismatch" — see §2; the iOS release process needs to ensure the manifest matches
|
||||
|
||||
## Section 6 — Post-Submit / If Rejected
|
||||
|
||||
|
||||
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
@@ -85,3 +85,30 @@ pnpm tsx scripts/restore-backup.ts \
|
||||
The script downloads the object, decrypts it, and writes the JSON dump
|
||||
to disk. Importing the JSON back into a HealthLog instance is left to
|
||||
the operator (use `prisma db seed` or a custom script).
|
||||
|
||||
## Monthly restore drill (automatic)
|
||||
|
||||
Since v1.16.4 a pg-boss job (`data-restore-drill`, cron `11 4 1 * *` —
|
||||
04:11 on the 1st of each month) exercises the read path end-to-end:
|
||||
fetch the most recent backup object from the bucket, decrypt it under
|
||||
the current `BACKUP_ENCRYPTION_KEY`, JSON-parse it, and sanity-check
|
||||
the payload shape. It performs **no database restore** — it validates
|
||||
the artefact, not the import path.
|
||||
|
||||
Outcomes:
|
||||
|
||||
- **Success** — record counts, object age, and sizes land in the
|
||||
wide-event meta (`job.restore_drill`).
|
||||
- **Stale chain** — the newest object is older than 3 days: the nightly
|
||||
uploader has stalled (or the lifecycle rule is too aggressive). The
|
||||
drill pages via the worker error reporter (stderr + GlitchTip).
|
||||
- **Failure** — empty bucket, fetch error, decryption failure (wrong or
|
||||
rotated key), malformed JSON: pages the same way. A decryption
|
||||
failure right after a `BACKUP_ENCRYPTION_KEY` change means the new
|
||||
key cannot read the existing objects — re-encrypt or accept that
|
||||
pre-rotation backups are only readable with the retired key.
|
||||
- **Not configured** — deployments without the `BACKUP_S3_*` vars skip
|
||||
silently (wide-event warning only).
|
||||
|
||||
The drill needs no IAM grant beyond the uploader's existing
|
||||
`GetObject` + `ListBucket`.
|
||||
+127
-1
@@ -70,7 +70,7 @@ roll back:
|
||||
release pair shipped on the v1.4.x line.
|
||||
3. If a column-drop migration ships in the rollback target, hand-roll
|
||||
the column back via a fresh forward migration (`pnpm prisma migrate
|
||||
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
|
||||
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
|
||||
commit, and re-deploy. Never `migrate resolve --rolled-back` against
|
||||
production unless you've taken a fresh `pg_dump` first.
|
||||
|
||||
@@ -90,3 +90,129 @@ pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
|
||||
Pass `--only=prod` / `--only=demo` to check a single target, or
|
||||
`--attempts=N --interval=ms` to widen the retry window while a slow
|
||||
pull settles.
|
||||
|
||||
## Verify the image signature
|
||||
|
||||
Every release image (the multi-arch manifest **index**, covering both
|
||||
`amd64` and `arm64`) is signed keyless via Sigstore in the
|
||||
`docker-publish.yml` merge job: GitHub's OIDC token is exchanged for a
|
||||
short-lived Fulcio certificate bound to the workflow identity, and the
|
||||
signature lands in the Rekor transparency log. There is no long-lived
|
||||
signing key to leak or rotate.
|
||||
|
||||
Verify before (or after) a deploy with [cosign](https://docs.sigstore.dev/cosign/system_config/installation/):
|
||||
|
||||
```sh
|
||||
cosign verify \
|
||||
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
|
||||
--certificate-identity-regexp 'https://github.com/MBombeck/HealthLog/\.github/workflows/docker-publish\.yml@refs/tags/v.*' \
|
||||
ghcr.io/mbombeck/healthlog:1.16.4
|
||||
```
|
||||
|
||||
A passing verification proves the image was built by this repository's
|
||||
release workflow on a `v*` tag — not by anyone who merely obtained push
|
||||
access to the GHCR package.
|
||||
|
||||
### Prefer digest-pinned deploys
|
||||
|
||||
Tags (`:latest`, `:1.16.4`) are mutable aliases: a registry-side
|
||||
compromise or an accidental re-push can repoint them. The digest is the
|
||||
image's only immutable identity, and it is what the signature actually
|
||||
covers. For any deploy where supply-chain integrity matters more than
|
||||
convenience:
|
||||
|
||||
The compose file carries the hook for this: the `app` service image is
|
||||
`ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}`. Unset, the
|
||||
day-to-day `:latest` + `pull_policy: always` flow stays the documented
|
||||
default for self-hosters. Set `HEALTHLOG_IMAGE_REF` in the compose
|
||||
`.env` (or the deployment platform's env vars) to switch the reference
|
||||
without touching the file:
|
||||
|
||||
- `HEALTHLOG_IMAGE_REF=:v1.16.5` — pin to a release tag (still mutable).
|
||||
- `HEALTHLOG_IMAGE_REF=@sha256:<digest>` — pin to the immutable digest.
|
||||
|
||||
### Promote a release to a pinned digest
|
||||
|
||||
`scripts/promote-digest.ts` automates the whole promotion after
|
||||
`docker-publish.yml` has pushed a tag:
|
||||
|
||||
```sh
|
||||
pnpm dlx tsx scripts/promote-digest.ts v1.16.5
|
||||
```
|
||||
|
||||
It resolves the multi-arch index digest from the GHCR registry API
|
||||
(anonymous pull token, no docker daemon), verifies the keyless cosign
|
||||
signature against the workflow identity (fails closed — no cosign, no
|
||||
promotion), and prints the resulting `HEALTHLOG_IMAGE_REF=@sha256:…`
|
||||
value.
|
||||
|
||||
For a Coolify-managed deployment it can also apply the pin. Two facts
|
||||
about Coolify shape the mechanism (verified against the Coolify
|
||||
OpenAPI spec and a live instance):
|
||||
|
||||
- For a git-based "Docker Compose" application, Coolify re-reads the
|
||||
compose file from the repository checkout on every deploy.
|
||||
`docker_compose_raw` is its stored copy of that file, and the
|
||||
application-update API (`PATCH /api/v1/applications/{uuid}`) does not
|
||||
accept the field — editing the stored compose is neither possible
|
||||
via API nor durable.
|
||||
- Application **env vars** are durable, exposed via the documented
|
||||
`/api/v1/applications/{uuid}/envs` endpoints, and interpolated into
|
||||
the compose at deploy time.
|
||||
|
||||
So the pin travels as the `HEALTHLOG_IMAGE_REF` env var. With
|
||||
`COOLIFY_API_TOKEN`, `COOLIFY_API_URL` and `COOLIFY_APP_UUID` in the
|
||||
environment the script creates/updates the var itself (add
|
||||
`PROMOTE_DEPLOY=1` to also trigger the redeploy); without a token it
|
||||
prints the equivalent curl commands:
|
||||
|
||||
```sh
|
||||
curl -sS -X PATCH "https://<coolify-host>/api/v1/applications/<app-uuid>/envs" \
|
||||
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \
|
||||
-d '{"key":"HEALTHLOG_IMAGE_REF","value":"@sha256:<digest>"}'
|
||||
|
||||
curl -sS "https://<coolify-host>/api/v1/deploy?uuid=<app-uuid>" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
After the deploy settles, confirm `/api/version` reports the released
|
||||
`version` + `buildSha` on every target (see above).
|
||||
|
||||
**Rollback:** re-run the script with the previous tag (or PATCH the env
|
||||
var back to the previous digest / to empty for `:latest`) and redeploy.
|
||||
Digest pinning trades the automatic `:latest` pickup for determinism —
|
||||
a pinned instance only moves when the var moves, so the promotion step
|
||||
becomes part of the release routine, not an optional extra.
|
||||
|
||||
Digest pinning is the recommended hardening for operators with a
|
||||
threat model that includes the registry; `:latest` remains the
|
||||
low-friction default.
|
||||
|
||||
## Deploy-status webhook: replay protection
|
||||
|
||||
`POST /api/internal/deploy-webhook` authenticates via the static
|
||||
`X-Deploy-Webhook-Secret` header. Since v1.16.4 it additionally
|
||||
enforces a freshness window: a request carrying an
|
||||
`X-Deploy-Webhook-Timestamp` header (unix seconds, unix milliseconds,
|
||||
or ISO-8601) is rejected with `401 {"status":"stale"}` when the
|
||||
timestamp lies more than **5 minutes** from server time — so a captured
|
||||
request cannot be replayed later to re-page admins or forge deploy
|
||||
outcomes in the audit log.
|
||||
|
||||
Two modes:
|
||||
|
||||
- **Default (tolerant).** Requests _without_ the header pass — Coolify's
|
||||
stock notification sender does not attach one. A header that is
|
||||
present but stale or unparseable is always rejected.
|
||||
- **Strict.** Set `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` in the app
|
||||
environment to make the header mandatory, closing the replay window
|
||||
completely. Use this when the sender (or a reverse proxy in front of
|
||||
the app) can attach the timestamp, e.g.:
|
||||
|
||||
```sh
|
||||
curl -X POST https://<host>/api/internal/deploy-webhook \
|
||||
-H "X-Deploy-Webhook-Secret: $SECRET" \
|
||||
-H "X-Deploy-Webhook-Timestamp: $(date +%s)" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status":"success"}'
|
||||
```
|
||||
@@ -0,0 +1,146 @@
|
||||
# Medication intake repair
|
||||
|
||||
`scripts/repair-intake-anomalies.ts` repairs the known historic defects in
|
||||
the medication intake ledger (`medication_intake_events`) and its schedule
|
||||
metadata (`medication_schedules`):
|
||||
|
||||
1. **Duplicate dose-slot rows.** More than one live row on the same exact
|
||||
`(user, medication, scheduled_for)` tuple — cross-source duplicates the
|
||||
pre-v1.15.19 write path could mint (e.g. a pending REMINDER row plus a
|
||||
taken API row for the same slot). The duplicate inflates the per-day
|
||||
scheduled count and paints a phantom entry in the history view.
|
||||
2. **Implausible `taken_at`.** Live rows whose `taken_at` lands more than
|
||||
7 days before `scheduled_for` or more than 1 day in the future —
|
||||
typically the residue of a mis-attributed edit before the v1.15.19
|
||||
`taken_at` validation landed.
|
||||
3. **Window / times drift (v1.16.1).** Schedule rows whose legacy
|
||||
`window_start` / `window_end` no longer contains the canonical
|
||||
`times_of_day` — typically a degenerate `07:00 / 07:00` point window
|
||||
left behind by an old write path while the dose times moved on (e.g.
|
||||
to `09:00 / 21:00`). The dose-band model never reads the window once
|
||||
`times_of_day` exists, but the stale pair kept feeding every legacy
|
||||
read (the pre-v1.16.1 cards painted "next intake 07:00" from it).
|
||||
4. **Stale-anchor pending rows (v1.16.1).** Live pending rows
|
||||
(`taken_at IS NULL`, not skipped, not auto-missed) whose
|
||||
`scheduled_for` wall-clock HH:mm — evaluated in the user's timezone —
|
||||
matches no current dose anchor of the medication (any schedule's
|
||||
`times_of_day` entry; `window_start` for legacy rows without times).
|
||||
These are reminder-minted slots on retired schedule times that linger
|
||||
as phantom open doses in the ledger.
|
||||
|
||||
## When to use it
|
||||
|
||||
- After upgrading to v1.15.19+, when a user reports a compliance rate above
|
||||
100 %, duplicate entries in the medication history, or a dose shown as
|
||||
taken days away from its slot.
|
||||
- The compliance rollup counts DISTINCT slots since v1.15.19, so the
|
||||
percentages self-correct on recompute even without running this script.
|
||||
Running it additionally clears the phantom rows from the history view.
|
||||
- The `intake-slot-dedup` worker already collapses canonical-slot
|
||||
duplicates at boot and on a daily cron; this script is the manual,
|
||||
immediately-observable fallback and the only path that surfaces the
|
||||
implausible-`taken_at` rows.
|
||||
|
||||
## Run a dry-run first
|
||||
|
||||
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
|
||||
|
||||
# scoped to one account
|
||||
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user <userId>
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
The dry-run prints every duplicate group (which row would be kept, which
|
||||
would be tombstoned), a table of implausible rows (id, medication,
|
||||
`scheduledFor`, `takenAt`, source, timestamps), every schedule whose
|
||||
window has drifted off its `times_of_day` (with the reconciled bounds it
|
||||
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
|
||||
```
|
||||
|
||||
`--fix` does five things:
|
||||
|
||||
- **Duplicate groups** are collapsed to one winner per slot. Precedence:
|
||||
an actioned row (taken or skipped) beats a pending one; between two
|
||||
taken rows the earlier `taken_at` wins; ties break deterministically on
|
||||
`created_at`, then `id`. Losers are soft-deleted (`deleted_at` set,
|
||||
`sync_version` incremented) — the same tombstone shape the delete route
|
||||
writes, so connected clients drop the rows on their next delta-sync.
|
||||
Nothing is ever hard-deleted.
|
||||
- **Implausible `taken_at` rows are NOT touched.** The recorded intent is
|
||||
unknowable, so the operator (or the user) corrects or deletes them in
|
||||
the medication history tab. To soft-delete them wholesale instead, opt
|
||||
in explicitly:
|
||||
|
||||
```bash
|
||||
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
|
||||
```
|
||||
|
||||
`--tombstone-implausible` is refused without `--fix`.
|
||||
- **Drifted schedule windows are reconciled** to
|
||||
`min/max(times_of_day)` so the legacy pair stops contradicting the
|
||||
canonical dose times. Overnight windows (`window_end < window_start`)
|
||||
encode a deliberate wrap and are never touched.
|
||||
- **Stale-anchor pending rows are tombstoned** (same soft-delete shape
|
||||
as the duplicate losers). A medication without any derivable anchor
|
||||
(PRN-only / unscheduled) is skipped — there is nothing to compare
|
||||
against.
|
||||
- **Affected compliance rollups are recomputed** for every touched
|
||||
`(user, medication, day)`. The script executes the same DISTINCT-slot
|
||||
aggregation SQL the shared helper runs (a verbatim twin of
|
||||
`recomputeMedicationComplianceForDay` in
|
||||
`src/lib/rollups/medication-compliance-rollups.ts` — kept inline so
|
||||
the script needs no app imports), so the scheduled/taken counts and
|
||||
the rate self-correct immediately.
|
||||
|
||||
The script is idempotent: a second `--fix` run finds zero duplicate groups
|
||||
and changes nothing. Exit code is 0 on success (including a clean
|
||||
zero-findings run), non-zero on bad arguments or a fatal error.
|
||||
|
||||
## Era backfill (`--backfill-eras`, v1.16.3)
|
||||
|
||||
Schedule edits made before v1.16.3 replaced the schedule rows without
|
||||
archiving the old state, so history reads past days against the current
|
||||
times. Section 5 of the script infers the lost era from the recorded
|
||||
slot anchors:
|
||||
|
||||
- A medication qualifies when its anchor-shaped rows (user-tz `HH:mm` on
|
||||
a 5-minute grid) deviate from the **current** `times_of_day` for at
|
||||
least 7 consecutive recorded days before the current times first
|
||||
appear, and it has **no** existing `medication_schedule_revisions`
|
||||
row (re-runs are idempotent).
|
||||
- The proposal is one revision row: `valid_from` = the first deviating
|
||||
row's instant, `valid_until` = local midnight of the first day on the
|
||||
current times, payload = one `FREQ=DAILY` schedule carrying the
|
||||
observed old times.
|
||||
- A `starts_on` that postdates the first recorded row is flagged and
|
||||
pulled back to that row's instant.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Review the dry-run table first — the inference is a heuristic. A wrongly
|
||||
created revision can be deleted from `medication_schedule_revisions`
|
||||
without side effects (the read paths fall back to live-only minting).
|
||||
+96
-9
@@ -48,7 +48,7 @@ Tailwind's default 4-pixel scale. Reach for these stops first:
|
||||
| ------------------------------------ | ------------- | ------ |
|
||||
| Inline gap between icon and label | `gap-1` | 4 |
|
||||
| Tight stack inside a card | `gap-2` | 8 |
|
||||
| Default form-field stack | `space-y-3` | 12 |
|
||||
| Label-to-field stack (form fields) | `space-y-2` | 8 |
|
||||
| Section block (heading + body) | `space-y-4` | 16 |
|
||||
| Page section separator | `space-y-6` | 24 |
|
||||
| Dashboard tile-row gap, hero spacing | `gap-4` | 16 |
|
||||
@@ -65,13 +65,18 @@ these classes first:
|
||||
| Use | Class |
|
||||
| ---------------------------------- | --------------------------------------- |
|
||||
| Page title (h1) | `text-2xl font-semibold tracking-tight` |
|
||||
| Section heading (h2) | `text-lg font-semibold` |
|
||||
| Card title | `text-base font-medium` |
|
||||
| Section heading / section card | `text-lg font-semibold` |
|
||||
| Tile / inner card title | `text-base font-semibold` |
|
||||
| Body text | `text-sm` |
|
||||
| Muted helper / metadata | `text-sm text-muted-foreground` |
|
||||
| Microcopy / footer | `text-xs text-muted-foreground` |
|
||||
| Tabular data (latency, large nums) | `font-mono tabular-nums` |
|
||||
|
||||
Card titles come in exactly two sizes: `text-lg font-semibold` for a
|
||||
section-level card (one per page region) and `text-base font-semibold`
|
||||
for tiles and inner cards. Don't introduce a third level — a smaller
|
||||
heading is a sign the content wants a list row, not a card.
|
||||
|
||||
Never use `font-bold` for emphasis — bump to `font-semibold` instead and
|
||||
let the design system breathe.
|
||||
|
||||
@@ -144,9 +149,38 @@ Wrap inputs in `<label>` (visible) plus `aria-describedby` for helper text.
|
||||
_and_ the user routinely sweeps. For most thresholds and counts, an
|
||||
`<Input type="number">` plus stepper buttons is faster and less
|
||||
fiddly. See §6 (Slider audit) for the migration path.
|
||||
- **Boolean toggle**: `<Switch>` for "applies immediately" settings (e.g.
|
||||
enable Telegram). `<Checkbox>` for "applies on Save" lists (e.g. select
|
||||
measurements to export).
|
||||
- **Boolean toggle**: `<Switch>` for settings. Exactly two persistence
|
||||
patterns are legal (see §2.2.1); pick one per card, never mix.
|
||||
`<Checkbox>` stays reserved for "applies on Save" _lists_ (e.g. select
|
||||
measurements to export) and for action parameters that are not
|
||||
persisted at all (export options).
|
||||
|
||||
#### 2.2.1 Settings persistence patterns
|
||||
|
||||
Every control under `/settings` follows exactly one of two save
|
||||
patterns. The pattern is a property of the **card**, not of the
|
||||
individual control:
|
||||
|
||||
1. **Immediate persist** — a standalone boolean that is the card's only
|
||||
mutable state. The `<Switch>` fires the mutation in
|
||||
`onCheckedChange`, disables itself while pending, updates
|
||||
optimistically with rollback, and toasts on success/error. No Save
|
||||
button anywhere on the card. Examples: hide-Coach, ntfy enable,
|
||||
cycle tracking, research mode, mood reminder, Coach nudges.
|
||||
2. **Draft + Save** — the card edits a multi-field payload (or a list
|
||||
the server PUTs whole). All controls — including `<Switch>` rows —
|
||||
mutate a local draft only; a visible Save button flushes the draft
|
||||
in one request and the card surfaces its dirty state. Examples:
|
||||
Coach preferences, Telegram (enable + bot token), provider fallback
|
||||
chain, dashboard layout, threshold editor.
|
||||
|
||||
A `<Switch>` inside a draft+Save card is legal **only** when the card
|
||||
shows the Save button; a card with both an auto-persisting switch and
|
||||
a Save-gated field is a bug. Every setting presented under `/settings`
|
||||
persists server-side. `localStorage` is reserved for device-local UI
|
||||
state that is not a setting (sidebar collapse, last-update-check
|
||||
timestamp, tour progress) — label such keys with a comment at the
|
||||
declaration site and never render them with Save/Saved affordances.
|
||||
|
||||
**Validation.** Inline messages below the field. Field gets
|
||||
`aria-invalid="true"` and a destructive border. Form-level errors land
|
||||
@@ -193,7 +227,51 @@ admin views. Default to `<Card>` + list rows for everything else;
|
||||
tables on mobile collapse poorly. When you must use a table on mobile,
|
||||
swap to a vertical list at `sm:` breakpoint.
|
||||
|
||||
### 2.6 Status indicators
|
||||
### 2.6 Filter bars over data lists
|
||||
|
||||
Every list surface with filters (measurements, mood, future admin
|
||||
lists) uses the unified `<FilterBar>` rail from
|
||||
`src/components/ui/filter-bar.tsx` — never a per-page row of large
|
||||
labelled `<Select>`s and date inputs.
|
||||
|
||||
- **Grammar**: `[icon] [Zeitraum] [Typ] [Quelle] [extras…]
|
||||
[Zurücksetzen] … [count]`. Canonical dimension order is date range,
|
||||
type, source; surface-specific dimensions append after.
|
||||
- **Pill = trigger + chip**: inactive pills show only the dimension
|
||||
name; active pills show `Name: Wert`, pick up the primary accent and
|
||||
grow a removable ✕. No separate chip row.
|
||||
- **Reset** renders only while at least one dimension is active.
|
||||
- **Mobile** (< `sm`): the rail is a horizontal scroll row (hidden
|
||||
scrollbar, edge-bleed); the result count wraps to its own line.
|
||||
- The component is presentation-only — filter state, query keys and
|
||||
fetching stay in the owning page. Use `FilterBarSelect` for enum
|
||||
dimensions and `FilterBarDateRange` for the from/to pair.
|
||||
|
||||
### 2.7 Section-card headers — icon column + content column
|
||||
|
||||
Every Settings/Admin section card header follows one shape, implemented
|
||||
by `<SettingsCardHeader>` (`src/components/settings/_card-header.tsx`)
|
||||
or an equivalent inline structure:
|
||||
|
||||
- **Icon column** (left): one Lucide icon, `text-muted-foreground h-5 w-5`.
|
||||
Icons in card headers are always neutral — never `text-primary` or any
|
||||
`dracula-*` accent. Purple/primary is reserved for actions and
|
||||
highlight states (§2.8), not for iconography.
|
||||
- **Content column** (right of the icon, `gap-2` → 28 px offset): the
|
||||
title, its description, and **everything textual that follows in the
|
||||
card** — descriptions, follow-up headings after separators, form
|
||||
stacks, helper/feedback lines. Apply `pl-7` to body blocks so nothing
|
||||
textual ever starts further left than the title above it, and
|
||||
follow-up headings align with the title column, not the icon.
|
||||
- **Exception — full-bleed structures**: data tables, tile/card grids,
|
||||
charts, ladder lists, and `<hr>` separators may span the full card
|
||||
width. The rule governs text alignment, not structural surfaces.
|
||||
- The page-level section `<h1>` is `sr-only`: the shell header already
|
||||
carries "Einstellungen · <Sektion>", so a visible in-section repeat
|
||||
is a double title. Keep the `<h1>` in the DOM for the document
|
||||
outline and `aria-labelledby`.
|
||||
|
||||
### 2.8 Status indicators
|
||||
|
||||
Use the same color taxonomy across the app:
|
||||
|
||||
@@ -272,7 +350,7 @@ Same shape as Settings, with the right-hand sidebar listing:
|
||||
- Maintenance
|
||||
- Audit log
|
||||
|
||||
Status badges use the §2.6 color taxonomy. Each section opens to a card
|
||||
Status badges use the §2.8 color taxonomy. Each section opens to a card
|
||||
grid, never a long fluid list.
|
||||
|
||||
---
|
||||
@@ -381,7 +459,16 @@ We aim for **WCAG 2.1 AA**, with the following non-negotiables:
|
||||
- Color contrast ≥ 4.5:1 for normal text, 3:1 for large text and UI
|
||||
components. Verified by `@axe-core/playwright` in CI on every page.
|
||||
- Touch targets ≥ 44×44 CSS px on bottom-nav and primary action buttons
|
||||
(WCAG 2.5.5).
|
||||
(WCAG 2.5.5). Two idioms implement the floor, and they are deliberate:
|
||||
- **Form controls** (`Input`, `NativeSelect`, `SelectTrigger` default
|
||||
size): `h-11 sm:h-10` is baked into the primitives — 44 px on
|
||||
mobile, back to the 40 px desktop tier. Never re-add `min-h-11` on
|
||||
top of these; it forces 44 px on desktop and breaks the row rhythm.
|
||||
- **Buttons on dense touch surfaces** (medication detail, dashboard
|
||||
quick actions): `min-h-11 sm:min-h-9` — 44 px mobile floor,
|
||||
compact `sm` tier on desktop. Plain `min-h-11` without the `sm:`
|
||||
reset is legacy spelling; don't introduce new ones, and align a
|
||||
button to its direct neighbours when editing one.
|
||||
- Focus is always visible: shadcn's `focus-visible:ring` is preserved on
|
||||
every customized component.
|
||||
- Every interactive element has an accessible name. Icon-only buttons
|
||||
|
||||
+2
-2
@@ -116,7 +116,7 @@ test.describe("axe-core authenticated surfaces", () => {
|
||||
}
|
||||
|
||||
// The keyboard-only "skip to content" link is fixed at the top-left
|
||||
// corner of every authenticated page. Marc reported it intercepting
|
||||
// corner of every authenticated page. The maintainer reported it intercepting
|
||||
// logo clicks in v1.4.14 — the fix combines `pointer-events-none`
|
||||
// with `-translate-y-full` so the element sits offscreen until
|
||||
// focused. Regression guard: with no keyboard focus, the logo's own
|
||||
@@ -127,7 +127,7 @@ test.describe("axe-core authenticated surfaces", () => {
|
||||
}) => {
|
||||
// The desktop sidebar (and its logo) is `hidden md:flex` — the
|
||||
// mobile profile (Pixel 5) hides it by design. The skip-link
|
||||
// regression Marc originally reported in v1.4.14 was a desktop-
|
||||
// regression the maintainer originally reported in v1.4.14 was a desktop-
|
||||
// only artifact (mobile uses the topbar logo, which is a
|
||||
// different DOM node). Skip the mobile project so we don't
|
||||
// flake on a contract that doesn't apply there.
|
||||
|
||||
@@ -5,7 +5,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
/**
|
||||
* v1.4.16 A3 — `/admin/api-tokens` must not produce horizontal page
|
||||
* overflow on a Pixel-5 viewport (393 CSS px). v1.4.15 added column-
|
||||
* hide breakpoints + tighter card padding, but Marc still saw a
|
||||
* hide breakpoints + tighter card padding, but the maintainer still saw a
|
||||
* scrollbar on prod. The v1.4.16 fix swaps the desktop `<table>` for a
|
||||
* mobile card-list at <md, mirroring `<UserManagementSection>`.
|
||||
*
|
||||
@@ -23,7 +23,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
|
||||
}) => {
|
||||
// Mock the admin tokens endpoint with a moderately wide payload —
|
||||
// a token with multiple permissions, a long name, and timestamps.
|
||||
// This is the realistic worst case Marc hit on prod.
|
||||
// This is the realistic worst case the maintainer hit on prod.
|
||||
await page.route("**/api/admin/tokens", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
@@ -38,7 +38,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
|
||||
expiresAt: null,
|
||||
createdAt: "2026-05-01T08:00:00Z",
|
||||
revoked: false,
|
||||
user: { id: "u1", username: "marc-the-very-long-username" },
|
||||
user: { id: "u1", username: "user-the-very-long-username" },
|
||||
},
|
||||
{
|
||||
id: "tok2",
|
||||
@@ -48,7 +48,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
|
||||
expiresAt: "2026-08-01T00:00:00Z",
|
||||
createdAt: "2026-04-15T08:00:00Z",
|
||||
revoked: false,
|
||||
user: { id: "u1", username: "marc-the-very-long-username" },
|
||||
user: { id: "u1", username: "user-the-very-long-username" },
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
@@ -76,7 +76,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
|
||||
});
|
||||
|
||||
// v1.4.18 phase A2 — production probe at Pixel-5 viewport pinned the
|
||||
// visible "scrollbar" Marc reported for the third time as the
|
||||
// visible "scrollbar" the maintainer reported for the third time as the
|
||||
// `<AdminShell>` mobile section strip, NOT the api-tokens table. The
|
||||
// strip carries 13 admin sections so its scrollWidth (~1700 CSS px)
|
||||
// permanently exceeds clientWidth (~360 CSS px). Adding the
|
||||
@@ -120,7 +120,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
|
||||
});
|
||||
|
||||
// v1.4.19 phase A7 (4th attempt) — even with the mobile-strip
|
||||
// `no-scrollbar` fix from v1.4.18, Marc still reported a painted
|
||||
// `no-scrollbar` fix from v1.4.18, the maintainer still reported a painted
|
||||
// scrollbar. The card body itself must not contribute any
|
||||
// horizontal-overflow culprit: every element inside the api-tokens
|
||||
// section must satisfy `scrollWidth <= clientWidth + 1`. Pads with
|
||||
@@ -149,7 +149,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
|
||||
revoked: false,
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "marc-the-very-long-username-that-overflows",
|
||||
username: "user-the-very-long-username-that-overflows",
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -166,7 +166,7 @@ test.describe("/admin/api-tokens mobile no-overflow", () => {
|
||||
|
||||
// Walk every element inside the mobile card-list and assert no
|
||||
// child has a wider scrollWidth than its container. This is the
|
||||
// Pixel-5 worst-case Marc kept hitting.
|
||||
// Pixel-5 worst-case the maintainer kept hitting.
|
||||
//
|
||||
// Elements clamped via `truncate` (Tailwind's `overflow:hidden +
|
||||
// text-overflow:ellipsis + white-space:nowrap` shorthand) are by
|
||||
|
||||
@@ -80,7 +80,15 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// Hydration gate — the provider query is fired by the same client
|
||||
// boundary that renders the select, so its response proves React
|
||||
// has hydrated the card and attached the select's change handler.
|
||||
// `selectOption` on the SSR-painted element any earlier flips the
|
||||
// native value without React noticing, and the config form below
|
||||
// never switches (CI failure at slow hydration).
|
||||
const cardHydrated = page.waitForResponse("**/api/user/ai-provider");
|
||||
await page.goto("/settings/ai", { waitUntil: "domcontentloaded" });
|
||||
await cardHydrated;
|
||||
|
||||
// 1. Dropdown surfaces on the page.
|
||||
const select = page.getByTestId("ai-active-provider-select");
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
/**
|
||||
* v1.4.18 — per-chart overlay-controls popover.
|
||||
*
|
||||
* Marc reverted v1.4.16's always-on chart overlays (gradient fill,
|
||||
* The maintainer reverted v1.4.16's always-on chart overlays (gradient fill,
|
||||
* personal-baseline line, target-zone shading) and asked for per-chart
|
||||
* switches that the user can flip on or off. This spec exercises the
|
||||
* end-to-end behaviour against the seeded test user:
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* Guided clarifying-questions flow on the full-page Coach route
|
||||
* (v1.16.5 — V2 of the v1.16.0 composer chips).
|
||||
*
|
||||
* Contracts under test:
|
||||
*
|
||||
* 1. With pending self-context questions, the entry card
|
||||
* (`data-slot="coach-guided-offer"`) renders above the composer
|
||||
* and offers start / later / don't-ask-again.
|
||||
* 2. Starting the flow mounts the first deterministic question
|
||||
* bubble in the thread (`data-slot="coach-guided-question"`,
|
||||
* `data-state="current"`) with the progress line and the
|
||||
* skip / later / dismiss actions.
|
||||
* 3. Skip advances to the next question without any server write.
|
||||
* 4. "Later" mid-sequence ends the flow without a summary when
|
||||
* nothing was answered; the entry card stays hidden for the
|
||||
* session.
|
||||
* 5. The entry-card "later" hides the offer without touching the
|
||||
* thread.
|
||||
*
|
||||
* 6. v1.16.6 — answering a question streams a contextual Coach
|
||||
* reaction (mocked SSE) BEFORE the next question appears; the
|
||||
* sequence advances only once the adopt offer settles.
|
||||
*
|
||||
* The questions API is mocked; the answer test also mocks the chat SSE
|
||||
* POST (the reaction content itself is the model's business, only the
|
||||
* ordering contract is pinned here).
|
||||
*/
|
||||
test.describe("Coach guided clarifying questions", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
const QUESTIONS = [
|
||||
"Guided e2e question one?",
|
||||
"Guided e2e question two?",
|
||||
"Guided e2e question three?",
|
||||
];
|
||||
const REACTION = "Thanks for sharing — that helps me read your numbers.";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Empty conversation list so the page surface paints the empty
|
||||
// thread + history rail without hitting the database.
|
||||
// Wide pattern on purpose: the list GET may fire with or without a
|
||||
// cursor query. Neither the detail GET nor the SSE POST fires in
|
||||
// this spec (no conversation is opened, no message is sent).
|
||||
await page.route("**/api/insights/chat*", (route) => {
|
||||
// v1.16.6 — the answer test POSTs the guided answer through the
|
||||
// normal chat pipeline; serve a minimal SSE reaction. GETs keep
|
||||
// the empty conversation list.
|
||||
if (route.request().method() === "POST") {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body:
|
||||
`data: ${JSON.stringify({ type: "token", token: REACTION })}\n\n` +
|
||||
`data: ${JSON.stringify({
|
||||
type: "done",
|
||||
conversationId: "conv-guided-e2e",
|
||||
messageId: "msg-guided-e2e",
|
||||
})}\n\n`,
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { conversations: [], nextCursor: null },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
// Detail refetch after the SSE `done` frame.
|
||||
await page.route("**/api/insights/chat/conv-guided-e2e", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
id: "conv-guided-e2e",
|
||||
title: "Guided e2e",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
messageCount: 2,
|
||||
summary: null,
|
||||
messages: [
|
||||
{
|
||||
id: "msg-user-e2e",
|
||||
role: "user",
|
||||
content: "Yes, hypertension since 2019.",
|
||||
createdAt: new Date().toISOString(),
|
||||
metricSource: null,
|
||||
providerType: null,
|
||||
promptVersion: null,
|
||||
},
|
||||
{
|
||||
id: "msg-guided-e2e",
|
||||
role: "assistant",
|
||||
content: REACTION,
|
||||
createdAt: new Date().toISOString(),
|
||||
metricSource: null,
|
||||
providerType: "mock",
|
||||
promptVersion: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/insights/coach/nudge-status*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { nudgedAt: null, unread: false },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// Pending questions: GET serves the snapshot, DELETE (dismiss one
|
||||
// or all) empties it.
|
||||
await page.route("**/api/coach/about-me/questions*", (route) => {
|
||||
const isDelete = route.request().method() === "DELETE";
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { questions: isDelete ? [] : QUESTIONS },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("offer card starts the guided sequence; skip and later walk it", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"flow logic is viewport-independent; desktop run suffices",
|
||||
);
|
||||
|
||||
await page.goto("/insights/coach", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// 1. Entry card with the three choices.
|
||||
const offer = page.locator('[data-slot="coach-guided-offer"]');
|
||||
await expect(offer).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
offer.locator('[data-slot="coach-guided-offer-start"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
offer.locator('[data-slot="coach-guided-offer-later"]'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
offer.locator('[data-slot="coach-guided-offer-dismiss"]'),
|
||||
).toBeVisible();
|
||||
|
||||
// 2. Start → first question bubble (typing reveal lasts <1s).
|
||||
await offer.locator('[data-slot="coach-guided-offer-start"]').click();
|
||||
await expect(offer).toHaveCount(0);
|
||||
const question = page.locator(
|
||||
'[data-slot="coach-guided-question"][data-state="current"]',
|
||||
);
|
||||
await expect(question).toBeVisible({ timeout: 10_000 });
|
||||
await expect(question).toContainText(QUESTIONS[0], { timeout: 10_000 });
|
||||
await expect(
|
||||
question.locator('[data-slot="coach-guided-progress"]'),
|
||||
).toContainText("1");
|
||||
|
||||
// 3. Skip → second question, original numbering.
|
||||
await question.locator('[data-slot="coach-guided-skip"]').click();
|
||||
await expect(question).toContainText(QUESTIONS[1], { timeout: 10_000 });
|
||||
await expect(
|
||||
question.locator('[data-slot="coach-guided-progress"]'),
|
||||
).toContainText("2");
|
||||
|
||||
// 4. Later mid-sequence → flow ends, nothing answered → no summary,
|
||||
// no question bubble, and the offer stays hidden for the session.
|
||||
await question.locator('[data-slot="coach-guided-later"]').click();
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-guided-question"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-guided-summary"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(offer).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("entry-card later hides the offer without starting the flow", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"flow logic is viewport-independent; desktop run suffices",
|
||||
);
|
||||
|
||||
await page.goto("/insights/coach", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const offer = page.locator('[data-slot="coach-guided-offer"]');
|
||||
await expect(offer).toBeVisible({ timeout: 10_000 });
|
||||
await offer.locator('[data-slot="coach-guided-offer-later"]').click();
|
||||
await expect(offer).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-guided-question"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("an answer streams a Coach reaction before the next question", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"flow logic is viewport-independent; desktop run suffices",
|
||||
);
|
||||
|
||||
await page.goto("/insights/coach", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const offer = page.locator('[data-slot="coach-guided-offer"]');
|
||||
await expect(offer).toBeVisible({ timeout: 10_000 });
|
||||
await offer.locator('[data-slot="coach-guided-offer-start"]').click();
|
||||
|
||||
const current = page.locator(
|
||||
'[data-slot="coach-guided-question"][data-state="current"]',
|
||||
);
|
||||
await expect(current).toContainText(QUESTIONS[0], { timeout: 10_000 });
|
||||
|
||||
// Answer through the composer — the message rides the normal chat
|
||||
// pipeline (mocked SSE above).
|
||||
const textarea = page.locator('[data-slot="coach-input-textarea"]');
|
||||
await textarea.fill("Yes, hypertension since 2019.");
|
||||
await page.locator('[data-slot="coach-input-send"]').click();
|
||||
|
||||
// Order contract: the streamed Coach reaction appears, the adopt
|
||||
// offer follows, and NO next question is current yet.
|
||||
await expect(page.getByText(REACTION).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const adopt = page.locator('[data-slot="coach-self-context-adopt"]');
|
||||
await expect(adopt).toBeVisible({ timeout: 10_000 });
|
||||
await expect(current).toHaveCount(0);
|
||||
|
||||
// Settling the offer (decline via ✕) releases the next question.
|
||||
await adopt.locator('[data-slot="coach-self-context-adopt-dismiss"]').click();
|
||||
await expect(current).toContainText(QUESTIONS[1], { timeout: 10_000 });
|
||||
await expect(
|
||||
current.locator('[data-slot="coach-guided-progress"]'),
|
||||
).toContainText("2");
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
*
|
||||
* Walks the 7-step daily path through the modal dialog. The dialog
|
||||
* mounts when `/medications/new` redirects to `/medications?new=1`.
|
||||
* The DE label surface (Marc's primary locale) is the assertion
|
||||
* The DE label surface (the maintainer's primary locale) is the assertion
|
||||
* surface — "Schritt N von 7" tracks the visible counter.
|
||||
*/
|
||||
test.describe("medication wizard — daily", () => {
|
||||
|
||||
@@ -78,7 +78,9 @@ test.describe("medication wizard — one-shot", () => {
|
||||
await expectStep(page, 5, 5);
|
||||
const summary = page.locator('[data-slot="wizard-summary"]');
|
||||
await expect(summary).toContainText(/Einmaldosis/i);
|
||||
await expect(summary).toContainText(/2026-10-15/);
|
||||
// The summary renders the date through the locale-aware formatter
|
||||
// (DE UI → "15.10.2026"), not the ISO value the input carries.
|
||||
await expect(summary).toContainText(/15\.10\.2026/);
|
||||
|
||||
await clickSave(page);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
* Walks the 8-step recurring path with "Flexibel ab letzter
|
||||
* Einnahme" picked on Step 5 and rollingDays = 7 set on Step 6.
|
||||
* Emits `rollingIntervalDays: 7` (no rrule). The cadence row also
|
||||
* surfaces Marc's literal "Counter startet neu" copy directly below
|
||||
* surfaces the maintainer's literal "Counter startet neu" copy directly below
|
||||
* the row label — the assertion below pins the phrase so a future
|
||||
* copy edit can't silently drop the rolling explainer.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* #316 follow-up — does the medications list refetch when you navigate
|
||||
* back to the page?
|
||||
*
|
||||
* Reporter: "TanStack Query is not fetching data on the medications page
|
||||
* or other areas when the data is stale, even when I navigate between
|
||||
* pages." In Next.js App Router a route-level page UNMOUNTS on navigation
|
||||
* away and remounts on return, so the query observer is recreated — but
|
||||
* the global `staleTime: 5min` makes `refetchOnMount: true` skip the
|
||||
* fetch while the cached data is still "fresh", so the page shows a
|
||||
* potentially stale list with no network round-trip.
|
||||
*
|
||||
* This spec counts `/api/medications` requests across a client-side
|
||||
* round-trip (medications → dashboard → medications). The DESIRED
|
||||
* (post-fix) behaviour is a fresh fetch on return; pre-fix the count
|
||||
* stays at 1, which is the reproduction.
|
||||
*
|
||||
* Desktop-only: the assertion is about query behaviour, and the sidebar
|
||||
* nav links it clicks live behind a drawer on the mobile project.
|
||||
*/
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("medications list refetches on client-side navigation back to the page", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name.includes("mobile"),
|
||||
"sidebar nav links are drawer-gated on mobile; query behaviour is project-independent",
|
||||
);
|
||||
let medsRequests = 0;
|
||||
page.on("request", (req) => {
|
||||
try {
|
||||
const u = new URL(req.url());
|
||||
if (u.pathname === "/api/medications") medsRequests += 1;
|
||||
} catch {
|
||||
/* non-URL request — ignore */
|
||||
}
|
||||
});
|
||||
|
||||
// Initial load — the list query mounts and fetches once.
|
||||
await page.goto("/medications");
|
||||
await page.waitForResponse(
|
||||
(r) => new URL(r.url()).pathname === "/api/medications",
|
||||
);
|
||||
expect(medsRequests).toBe(1);
|
||||
|
||||
// Client-side nav AWAY (dashboard) then BACK — App Router unmounts and
|
||||
// remounts the medications page. A full page.goto() would reset the
|
||||
// client cache and always refetch, so we click the sidebar links.
|
||||
await page.locator('a[href="/"]').first().click();
|
||||
await page.waitForURL("**/");
|
||||
await page.locator('a[href="/medications"]').first().click();
|
||||
await page.waitForURL("**/medications");
|
||||
|
||||
// Give a returning refetch time to fire.
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Desired: the return mounts a fresh observer that refetches. Pre-fix
|
||||
// the 5-min staleTime masks it and this stays at 1 (the bug).
|
||||
expect(medsRequests).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
/**
|
||||
* v1.4.16 A1 — Sidebar Admin entry must mirror the Settings entry
|
||||
* exactly: a single link with no sub-item expansion in the global
|
||||
* sidebar. Marc reported in v1.4.15 that the sidebar was auto-expanding
|
||||
* sidebar. The maintainer reported in v1.4.15 that the sidebar was auto-expanding
|
||||
* admin sub-items on `/admin/*` and the gravatar dropdown felt linked to
|
||||
* the sidebar — both unwanted UX. The in-shell `<AdminShell>` already
|
||||
* renders its own per-section nav inside the page itself.
|
||||
|
||||
@@ -144,9 +144,20 @@ export function buildMockSnapshot(
|
||||
mood: { summary: null, entries: [] },
|
||||
},
|
||||
extras: null,
|
||||
medsToday: {
|
||||
activeCount: 0,
|
||||
scheduledToday: 0,
|
||||
takenToday: 0,
|
||||
skippedToday: 0,
|
||||
nextDueAt: null,
|
||||
nextDueOverdue: false,
|
||||
nextDueMedicationName: null,
|
||||
},
|
||||
healthScore: null,
|
||||
briefing: null,
|
||||
briefingState: "preparing",
|
||||
briefingUpdatedAt: null,
|
||||
briefingStale: false,
|
||||
generatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
+151
-49
@@ -3,36 +3,42 @@ import { expect, test } from "@playwright/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.4.27 R3d MB4 — Coach reachability on `/insights/{slug}` sub-pages
|
||||
* plus the Coach drawer bottom-sheet branch on `<sm`.
|
||||
* Coach launch surfaces on `/insights/{slug}` sub-pages.
|
||||
*
|
||||
* Two contracts under test:
|
||||
* Contracts under test (v1.16.8):
|
||||
*
|
||||
* 1. Every routed Insights sub-page mounts a Coach launch surface.
|
||||
* The layout renders a sticky-bottom FAB
|
||||
* (`data-slot="coach-launch-fab"`, visible on `<lg`); v1.8.6 moved
|
||||
* the per-page action into the sub-page header as an icon-only
|
||||
* button (`data-slot="coach-launch-icon"`, mounted by the shell at
|
||||
* heading height across breakpoints). The spec asserts the FAB
|
||||
* renders on Pixel-5 and the header icon renders on Desktop Chrome.
|
||||
* 1. The floating Coach FAB (`data-slot="coach-fab"`) is a permanent
|
||||
* launcher on every authenticated page. An unread Coach-initiated
|
||||
* nudge (`GET /api/insights/coach/nudge-status`) paints an unread
|
||||
* dot (`data-slot="coach-fab-unread"`) on its corner; with nothing
|
||||
* unread the FAB renders without the dot. The inline launch the
|
||||
* sub-page shell mounts in the header
|
||||
* (`data-slot="coach-launch-icon"`) stays the contextual entry.
|
||||
*
|
||||
* 2. Opening the Coach drawer on `<sm` mounts it as a bottom-sheet
|
||||
* (`data-variant="bottom-sheet"`). On `>=sm` it mounts as a
|
||||
* right-side sheet (`data-variant="side-sheet"`).
|
||||
* 2. Clicking the inline launch opens the Coach drawer — as a
|
||||
* bottom-sheet (`data-variant="bottom-sheet"`) on `<sm`, as a
|
||||
* right-side sheet (`data-variant="side-sheet"`) on `>=sm`.
|
||||
*
|
||||
* 3. With an unread nudge the FAB carries the dot; clicking the FAB
|
||||
* opens the Coach drawer in place (v1.16.11 — no navigation, the
|
||||
* page underneath stays) and the dot clears.
|
||||
*
|
||||
* 4. The drawer's "Conversations" button no longer opens an in-panel
|
||||
* tray; it hands off to the full-page route `/insights/coach`.
|
||||
*
|
||||
* The three sub-pages covered (blutdruck, gewicht, schlaf) span the
|
||||
* empty-state branch (no data → CTA + Coach launch) and the populated
|
||||
* branch (with data → full chart + Coach launch beneath).
|
||||
* branch (with data → full chart + Coach launch in the header).
|
||||
*/
|
||||
test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
|
||||
test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Populated analytics so each sub-page paints the data branch
|
||||
// (`hasMetricData` true) — the Coach button is mounted at the
|
||||
// bottom of the data tree.
|
||||
// v1.4.39.3 — regex form matches the slim slice the v1.4.39.2
|
||||
// dashboard split fires alongside the thick request.
|
||||
// (`hasMetricData` true) — the inline Coach launch is mounted in
|
||||
// the sub-page header by the shell.
|
||||
// Regex form matches the slim slice the dashboard split fires
|
||||
// alongside the thick request.
|
||||
await page.route(/\/api\/analytics(\?|$)/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
@@ -103,35 +109,52 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Default nudge state: nothing unread. Registered AFTER the
|
||||
// generic `**-status*` mock so it wins for the nudge endpoint
|
||||
// (Playwright matches routes in reverse registration order).
|
||||
await page.route("**/api/insights/coach/nudge-status*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { nudgedAt: null, unread: false },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
for (const slug of ["blutdruck", "gewicht", "schlaf"] as const) {
|
||||
test(`/insights/${slug} mounts the Coach launch button on Pixel 5 (FAB branch)`, async ({
|
||||
test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Pixel 5`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-mobile",
|
||||
"Pixel 5 FAB branch",
|
||||
"mobile inline-launch branch",
|
||||
);
|
||||
|
||||
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
|
||||
|
||||
// The FAB slot must be present and CSS-visible at Pixel 5.
|
||||
const fab = page
|
||||
.locator('[data-slot="coach-launch-fab"]')
|
||||
.first();
|
||||
// The header launch icon stays the contextual entry point.
|
||||
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
|
||||
await expect(icon).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The FAB is a permanent launcher; with no unread nudge it
|
||||
// renders without the unread dot.
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-fab-unread"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test(`/insights/${slug} mounts the header Coach icon on Desktop Chrome`, async ({
|
||||
test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Desktop Chrome`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
// Desktop Chrome viewport is 1280 — `lg` (1024) is matched so the
|
||||
// layout FAB is hidden by `lg:hidden`; the header icon the shell
|
||||
// mounts is the Coach entry on the page itself.
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"desktop header-icon branch",
|
||||
"desktop inline-launch branch",
|
||||
);
|
||||
|
||||
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
|
||||
@@ -139,12 +162,15 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
|
||||
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
|
||||
await expect(icon).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const fab = page.locator('[data-slot="coach-launch-fab"]').first();
|
||||
await expect(fab).toBeHidden();
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-fab-unread"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test("clicking the FAB opens the Coach drawer as a bottom-sheet on Pixel 5", async ({
|
||||
test("clicking the inline Coach launch opens the drawer as a bottom-sheet on Pixel 5", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
@@ -152,18 +178,26 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
|
||||
"drawer bottom-sheet branch is mobile-only",
|
||||
);
|
||||
|
||||
// Hydration gate — the status query is fired by the sub-page
|
||||
// boundary itself, so its response proves React has hydrated the
|
||||
// page and attached the launch icon's click handler. Clicking the
|
||||
// SSR-painted icon any earlier is silently lost (CI flake).
|
||||
const pageHydrated = page.waitForResponse(
|
||||
/\/api\/insights\/blood-pressure-status/,
|
||||
);
|
||||
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
|
||||
await pageHydrated;
|
||||
|
||||
const fab = page.locator('[data-slot="coach-launch-fab"]').first();
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
await fab.click();
|
||||
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
|
||||
await expect(icon).toBeVisible({ timeout: 10_000 });
|
||||
await icon.click();
|
||||
|
||||
const drawer = page.locator('[data-slot="coach-drawer"]');
|
||||
await expect(drawer).toBeVisible({ timeout: 10_000 });
|
||||
await expect(drawer).toHaveAttribute("data-variant", "bottom-sheet");
|
||||
});
|
||||
|
||||
test("clicking the header Coach icon opens the Coach drawer as a side-sheet on Desktop Chrome", async ({
|
||||
test("clicking the inline Coach launch opens the drawer as a side-sheet on Desktop Chrome", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
@@ -171,7 +205,12 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
|
||||
"drawer side-sheet branch is desktop-only",
|
||||
);
|
||||
|
||||
// Hydration gate — see the bottom-sheet branch above.
|
||||
const pageHydrated = page.waitForResponse(
|
||||
/\/api\/insights\/blood-pressure-status/,
|
||||
);
|
||||
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
|
||||
await pageHydrated;
|
||||
|
||||
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
|
||||
await expect(icon).toBeVisible({ timeout: 10_000 });
|
||||
@@ -182,18 +221,79 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
|
||||
await expect(drawer).toHaveAttribute("data-variant", "side-sheet");
|
||||
});
|
||||
|
||||
test("conversation history is collapsed behind a toggle that opens the tray on Desktop Chrome", async ({
|
||||
test("an unread Coach nudge paints the FAB dot and clicking the FAB opens the Coach drawer in place", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
// v1.12.0 — the history rail is no longer an always-on left column;
|
||||
// it lives behind the "Conversations" toggle on every viewport and
|
||||
// opens as a tray on demand so the thread keeps the full width.
|
||||
// The FAB contract is viewport-independent — the test runs on
|
||||
// both projects. Override the default nudge mock with an unread
|
||||
// one (later registration wins).
|
||||
await page.route("**/api/insights/coach/nudge-status*", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { nudgedAt: "2026-06-09T06:00:00.000Z", unread: true },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Hydration gate — the nudge-status query is fired by the FAB
|
||||
// component itself, so its response proves the FAB's click handler
|
||||
// is attached (the button paints from SSR HTML long before that).
|
||||
const fabHydrated = page.waitForResponse(
|
||||
/\/api\/insights\/coach\/nudge-status/,
|
||||
);
|
||||
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
|
||||
await fabHydrated;
|
||||
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
await expect(fab).toHaveAttribute("data-unread", "true", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-fab-unread"]'),
|
||||
).toBeVisible();
|
||||
await fab.click();
|
||||
|
||||
// v1.16.11 — the FAB opens the side drawer in place instead of
|
||||
// navigating to `/insights/coach`; the page underneath stays.
|
||||
const drawer = page.locator('[data-slot="coach-drawer"]');
|
||||
await expect(drawer).toBeVisible({ timeout: 10_000 });
|
||||
await expect(drawer).toHaveAttribute(
|
||||
"data-variant",
|
||||
testInfo.project.name === "chromium-mobile"
|
||||
? "bottom-sheet"
|
||||
: "side-sheet",
|
||||
);
|
||||
expect(new URL(page.url()).pathname).not.toContain("/insights/coach");
|
||||
|
||||
// Opening the Coach counts as reading the nudge on this device —
|
||||
// the dot clears and the unread flag drops off the FAB.
|
||||
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(fab).not.toHaveAttribute("data-unread", "true");
|
||||
});
|
||||
|
||||
test("the drawer's Conversations button hands off to the full-page chat on Desktop Chrome", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
// The in-panel history tray is gone from the drawer; the button
|
||||
// navigates to `/insights/coach` where the conversation list
|
||||
// renders inline on lg+.
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"exercises the desktop drawer layout",
|
||||
);
|
||||
|
||||
// Hydration gate — see the bottom-sheet test above.
|
||||
const pageHydrated = page.waitForResponse(
|
||||
/\/api\/insights\/blood-pressure-status/,
|
||||
);
|
||||
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
|
||||
await pageHydrated;
|
||||
|
||||
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
|
||||
await expect(icon).toBeVisible({ timeout: 10_000 });
|
||||
@@ -202,22 +302,24 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
|
||||
const drawer = page.locator('[data-slot="coach-drawer"]');
|
||||
await expect(drawer).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// No inline history column — only the toggle.
|
||||
// The drawer renders no inline history column.
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-drawer-history"]'),
|
||||
).toHaveCount(0);
|
||||
const historyToggle = page.locator(
|
||||
|
||||
const historyButton = page.locator(
|
||||
'[data-slot="coach-drawer-history-tray-trigger"]',
|
||||
);
|
||||
await expect(historyToggle).toBeVisible({ timeout: 10_000 });
|
||||
await expect(historyButton).toBeVisible({ timeout: 10_000 });
|
||||
await historyButton.click();
|
||||
|
||||
// The history rail surfaces only after the toggle is pressed.
|
||||
// No in-panel tray opens — the click navigates to the full view.
|
||||
await page.waitForURL("**/insights/coach", { timeout: 10_000 });
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-page"]'),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-drawer-history-tray"]'),
|
||||
).toHaveCount(0);
|
||||
await historyToggle.click();
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-drawer-history-tray"]'),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @fileoverview ESLint rule — client-side `/api/...` calls must route
|
||||
* through the typed `apiFetch` wrapper.
|
||||
*
|
||||
* `src/lib/api/api-fetch.ts` owns the `.ok` check, the
|
||||
* `{ data, error, meta? }` envelope unwrap, and the `ApiError` shape
|
||||
* (message via `readError`, `status`, error-side `meta`). A raw
|
||||
* `fetch("/api/…")` in a component re-implements that contract by hand
|
||||
* — the exact drift this wrapper retired — so this rule flags it at
|
||||
* authoring time and in CI.
|
||||
*
|
||||
* Complements `healthlog/safe-fetch-required`: that rule covers
|
||||
* OUTBOUND egress (absolute / variable-sourced URLs) and exempts
|
||||
* same-origin relative paths; this rule covers exactly those same-origin
|
||||
* `/api/...` paths inside the client surface.
|
||||
*
|
||||
* Allowed:
|
||||
* - `apiFetch` / verb helpers / `apiFetchEnvelope` / `apiFetchRaw` —
|
||||
* distinct identifiers, never match.
|
||||
* - calls inside `src/lib/api/api-fetch.ts` — the wrapper's own `fetch`.
|
||||
* - test files (`*.test.ts(x)`, `__tests__/`, `__mocks__/`) — they
|
||||
* mock or assert against `fetch` directly.
|
||||
*
|
||||
* The check is a syntactic `CallExpression` match against a bare
|
||||
* `fetch(` callee (Identifier `fetch`) and `globalThis.fetch(` /
|
||||
* `window.fetch(` / `self.fetch(` member forms, where the first
|
||||
* argument is a string literal or template literal whose head starts
|
||||
* with `/api/`. Variable-sourced or absolute URLs are the
|
||||
* safe-fetch-required rule's territory and stay out of scope here.
|
||||
*
|
||||
* @see src/lib/api/api-fetch.ts
|
||||
* @see eslint-plugins/healthlog/safe-fetch-required.js
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
// Files exempt from the rule — the wrapper internals and the test
|
||||
// surface. Posix-style suffix matches against the absolute filename via
|
||||
// `String#includes`, mirroring the safe-fetch-required convention.
|
||||
const EXEMPT_FILES = ["src/lib/api/api-fetch.ts"];
|
||||
|
||||
// Enforce across the client-facing source roots. `src/lib/` stays out:
|
||||
// server-side lib code never self-calls `/api/` routes, and the
|
||||
// safe-fetch rule already covers its outbound calls. `src/hooks/` is
|
||||
// client-only by construction (v1.16.4) — its `/api/` reads route
|
||||
// through the wrapper like every component.
|
||||
const ENFORCED_ROOTS = ["src/components/", "src/app/", "src/hooks/"];
|
||||
|
||||
function toPosix(filename) {
|
||||
return filename.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function isTestFile(posix) {
|
||||
return (
|
||||
/\.test\.[cm]?[jt]sx?$/.test(posix) ||
|
||||
/\.spec\.[cm]?[jt]sx?$/.test(posix) ||
|
||||
posix.includes("/__tests__/") ||
|
||||
posix.includes("/__mocks__/")
|
||||
);
|
||||
}
|
||||
|
||||
function isEnforced(filename) {
|
||||
const posix = toPosix(filename);
|
||||
if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false;
|
||||
if (EXEMPT_FILES.some((f) => posix.includes(f))) return false;
|
||||
if (isTestFile(posix)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @type {import("eslint").Rule.RuleModule} */
|
||||
const apiFetchRequiredRule = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Same-origin /api/ calls must route through the apiFetch wrapper (src/lib/api/api-fetch.ts).",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
rawApiFetch:
|
||||
'Raw fetch("/api/…") re-implements the envelope unwrap + error contract by hand. Import { apiFetch } (or a verb helper / apiFetchRaw) from "@/lib/api/api-fetch" and call it instead.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const filename = context.filename ?? context.getFilename?.();
|
||||
if (!filename || !isEnforced(filename)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
function isFetchCallee(callee) {
|
||||
// Bare `fetch(...)`.
|
||||
if (callee.type === "Identifier" && callee.name === "fetch") return true;
|
||||
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`.
|
||||
if (
|
||||
callee.type === "MemberExpression" &&
|
||||
!callee.computed &&
|
||||
callee.property.type === "Identifier" &&
|
||||
callee.property.name === "fetch" &&
|
||||
callee.object.type === "Identifier" &&
|
||||
(callee.object.name === "globalThis" ||
|
||||
callee.object.name === "window" ||
|
||||
callee.object.name === "self")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isApiPath(arg) {
|
||||
if (!arg) return false;
|
||||
const isApiHead = (head) => head.startsWith("/api/");
|
||||
// `fetch("/api/…")`
|
||||
if (arg.type === "Literal" && typeof arg.value === "string") {
|
||||
return isApiHead(arg.value);
|
||||
}
|
||||
// `fetch(`/api/${id}`)` — inspect the template's first cooked chunk.
|
||||
if (arg.type === "TemplateLiteral" && arg.quasis.length > 0) {
|
||||
return isApiHead(arg.quasis[0].value.cooked ?? "");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (!isFetchCallee(node.callee)) return;
|
||||
if (!isApiPath(node.arguments[0])) return;
|
||||
context.report({ node, messageId: "rawApiFetch" });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = apiFetchRequiredRule;
|
||||
@@ -4,16 +4,19 @@
|
||||
*
|
||||
* - `queryKey-factory` — bare-array queryKey / mutationKey bypass guard.
|
||||
* - `safe-fetch-required` — outbound fetch must route through safeFetch.
|
||||
* - `api-fetch-required` — client /api/ calls must route through apiFetch.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const queryKeyFactory = require("./queryKey-factory.js");
|
||||
const safeFetchRequired = require("./safe-fetch-required.js");
|
||||
const apiFetchRequired = require("./api-fetch-required.js");
|
||||
|
||||
module.exports = {
|
||||
rules: {
|
||||
"queryKey-factory": queryKeyFactory.rules["queryKey-factory"],
|
||||
"safe-fetch-required": safeFetchRequired,
|
||||
"api-fetch-required": apiFetchRequired,
|
||||
},
|
||||
};
|
||||
@@ -22,7 +22,7 @@
|
||||
* test-guard substitute holds the "guarded surface" line until a
|
||||
* future wave extends both the guard and this whitelist together.
|
||||
*
|
||||
* @see src/lib/query-keys.ts — factory home (do not lint as offender)
|
||||
* @see src/lib/query-keys/ — factory home (do not lint as offender)
|
||||
* @see src/lib/__tests__/query-keys.test.ts — test-guard substitute
|
||||
* @see .planning/phase-W-PROCESS-DOCS-v1441-report.md
|
||||
*/
|
||||
@@ -35,14 +35,16 @@
|
||||
// exempt for now. Path matches use `String#includes` semantics against
|
||||
// the absolute filename — keep entries as posix-style suffix paths.
|
||||
const FACTORY_HOME_FILES = [
|
||||
// The factory definition file is allowed to declare literal keys —
|
||||
// that's the whole point of the factory.
|
||||
"src/lib/query-keys.ts",
|
||||
// The factory's own unit-test file constructs literal keys to assert
|
||||
// the factory output shape.
|
||||
"src/lib/__tests__/query-keys.test.ts",
|
||||
];
|
||||
|
||||
// The factory definition files are allowed to declare literal keys —
|
||||
// that's the whole point of the factory. The factory is split into
|
||||
// per-feature files under this directory (barrel at index.ts).
|
||||
const FACTORY_HOME_DIRECTORY = "src/lib/query-keys";
|
||||
|
||||
// Directories under which any `queryKey: [ … ]` literal is an error.
|
||||
// Extend in lockstep with the test-guard substitute's `guardedRoots`.
|
||||
const GUARDED_DIRECTORIES = [
|
||||
@@ -81,6 +83,7 @@ function isGuarded(filename) {
|
||||
for (const home of FACTORY_HOME_FILES) {
|
||||
if (filename.endsWith(home)) return false;
|
||||
}
|
||||
if (filename.includes(`/${FACTORY_HOME_DIRECTORY}/`)) return false;
|
||||
for (const dir of GUARDED_DIRECTORIES) {
|
||||
if (filename.includes(`/${dir}/`)) return true;
|
||||
}
|
||||
@@ -96,7 +99,7 @@ const queryKeyFactoryRule = {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow literal-array `queryKey` / `mutationKey` declarations in guarded files. Use `queryKeys.<entry>()` from `src/lib/query-keys.ts` instead.",
|
||||
"Disallow literal-array `queryKey` / `mutationKey` declarations in guarded files. Use `queryKeys.<entry>()` from `src/lib/query-keys/` instead.",
|
||||
recommended: false,
|
||||
},
|
||||
messages: {
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
const EXEMPT_FILES = [
|
||||
"src/lib/safe-fetch.ts",
|
||||
"src/lib/safe-fetch-dispatcher.ts",
|
||||
// The same-origin `/api/...` envelope wrapper: its `fetch` calls are
|
||||
// relative-path by contract (the api-fetch-required rule pins every
|
||||
// client call site onto it), so the egress defences don't apply.
|
||||
"src/lib/api/api-fetch.ts",
|
||||
];
|
||||
|
||||
// Only enforce inside the application source roots. Scripts, config, and
|
||||
|
||||
+12
-6
@@ -13,8 +13,8 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// v1.4.41 — ignore Claude Code session worktrees so a mid-marathon
|
||||
// worktree on an older commit can't poison `pnpm lint` locally.
|
||||
// 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.
|
||||
".claude/**",
|
||||
// The project-local ESLint rule plugin is CommonJS (require/module
|
||||
@@ -22,11 +22,11 @@ const eslintConfig = defineConfig([
|
||||
// is not linted by the app's TypeScript ruleset.
|
||||
"eslint-plugins/**",
|
||||
]),
|
||||
// v1.4.41 W-PROCESS-DOCS — custom rule that flags any bare-array
|
||||
// v1.4.41 — custom rule that flags any bare-array
|
||||
// `queryKey: [ … ]` / `mutationKey: [ … ]` declaration inside the
|
||||
// files the queryKeys factory currently guards. Promotes the
|
||||
// test-guard substitute from v1.4.40 W-RSC to a real ESLint rule
|
||||
// so IDE + CI both fail fast on a factory bypass. See
|
||||
// files the queryKeys factory currently guards. Promotes the earlier
|
||||
// v1.4.40 test-guard substitute to a real ESLint rule so IDE + CI
|
||||
// both fail fast on a factory bypass. See
|
||||
// `eslint-plugins/healthlog/queryKey-factory.js` for the
|
||||
// whitelist + rationale.
|
||||
{
|
||||
@@ -38,6 +38,12 @@ const eslintConfig = defineConfig([
|
||||
// requirePublicHost connect-time pin). The wrapper internals and
|
||||
// test files are exempt; see the rule file for the allowlist.
|
||||
"healthlog/safe-fetch-required": "error",
|
||||
// v1.16.4 — same-origin `/api/...` calls in the client surface
|
||||
// (src/components + src/app) must route through the typed apiFetch
|
||||
// wrapper (`src/lib/api/api-fetch.ts`): one `.ok` check, one
|
||||
// envelope unwrap, one ApiError shape. Wrapper internals and test
|
||||
// files are exempt; see the rule file for the allowlist.
|
||||
"healthlog/api-fetch-required": "error",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -68,11 +68,17 @@
|
||||
"docker",
|
||||
"docker-compose",
|
||||
"pg_isready",
|
||||
"tsx"
|
||||
"tsx",
|
||||
"cosign"
|
||||
],
|
||||
"ignoreExportsUsedInFile": true,
|
||||
"ignoreIssues": {
|
||||
"src/components/ui/**": ["exports", "types"],
|
||||
"src/lib/validations/**": ["types"]
|
||||
"src/components/ui/**": [
|
||||
"exports",
|
||||
"types"
|
||||
],
|
||||
"src/lib/validations/**": [
|
||||
"types"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Git-hook manager (v1.16.4). `pnpm install` wires the hooks via the
|
||||
# `prepare` script in package.json; no manual step needed.
|
||||
#
|
||||
# Pre-commit secret scan: `gitleaks protect --staged` scans exactly the
|
||||
# staged hunks against the repo's `.gitleaks.toml` before every commit,
|
||||
# so a secret-shaped string never reaches the object store (where only
|
||||
# a history rewrite can remove it again).
|
||||
#
|
||||
# Requirements: the `gitleaks` binary must be on PATH — the command
|
||||
# below fails with install pointers when it is missing rather than
|
||||
# silently skipping the scan.
|
||||
#
|
||||
# Opt-out (one commit): LEFTHOOK=0 git commit ...
|
||||
# Opt-out (skip one job): LEFTHOOK_EXCLUDE=gitleaks git commit ...
|
||||
# Opt-out (permanent): pnpm lefthook uninstall
|
||||
# Per project policy (`--no-verify` is banned), prefer fixing the
|
||||
# finding over bypassing the hook.
|
||||
|
||||
pre-commit:
|
||||
commands:
|
||||
gitleaks:
|
||||
run: |
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
echo "ERROR: gitleaks binary not found on PATH — the staged-secret scan cannot run." >&2
|
||||
echo "Install it first, e.g.:" >&2
|
||||
echo " brew install gitleaks # macOS" >&2
|
||||
echo " apt/apk/pacman install gitleaks # Linux distros" >&2
|
||||
echo " https://github.com/gitleaks/gitleaks#installing" >&2
|
||||
echo "One-off bypass (use sparingly): LEFTHOOK=0 git commit ..." >&2
|
||||
exit 1
|
||||
fi
|
||||
gitleaks protect --staged --redact --no-banner
|
||||
+776
-86
File diff suppressed because it is too large.
Load diff
+759
-69
File diff suppressed because it is too large.
Load diff
+733
-43
File diff suppressed because it is too large.
Load diff
+733
-43
File diff suppressed because it is too large.
Load diff
+733
-43
File diff suppressed because it is too large.
Load diff
+734
-44
File diff suppressed because it is too large.
Load diff
@@ -157,8 +157,18 @@ const nextConfig: NextConfig = {
|
||||
outputFileTracingExcludes: {
|
||||
"*": ["./next.config.ts"],
|
||||
},
|
||||
// Next 16 caps the request body that passes THROUGH middleware (our
|
||||
// `src/proxy.ts`) at ~10MB by default, silently truncating larger bodies
|
||||
// before the route handler sees them. The Apple Health `export.zip` importer
|
||||
// (`/api/import/apple-health-export`) streams multi-MB archives to disk; the
|
||||
// 10MB cap truncated them so the ZIP end-of-central-directory was lost and
|
||||
// the parser failed (GitHub #281). Raise the ceiling so real exports pass
|
||||
// intact. The value is a ceiling, not a buffer — only the actual upload size
|
||||
// is held — so this is safe; very large exports also want a matching
|
||||
// reverse-proxy body limit (see docs/self-hosting/reverse-proxy.md).
|
||||
experimental: {
|
||||
optimizePackageImports: ["recharts", "lucide-react"],
|
||||
middlewareClientMaxBodySize: "512mb",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+11
-7
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.15.12",
|
||||
"version": "1.16.13",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -24,6 +24,7 @@
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
"prepare": "lefthook install || echo 'lefthook install skipped (no git repo or binary unavailable)'",
|
||||
"dev": "next dev",
|
||||
"prebuild": "node scripts/generate-sw-version.mjs",
|
||||
"build": "next build",
|
||||
@@ -59,8 +60,8 @@
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@simplewebauthn/server": "^13.3.1",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@types/sax": "^1.2.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -75,10 +76,11 @@
|
||||
"p-limit": "^7.3.0",
|
||||
"pg-boss": "^12.18.2",
|
||||
"prisma": "^7.8.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-hook-form": "^7.78.0",
|
||||
"recharts": "^3.8.1",
|
||||
"rrule": "^2.8.1",
|
||||
"sax": "^1.6.0",
|
||||
@@ -97,8 +99,9 @@
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testcontainers/postgresql": "^12.0.1",
|
||||
"@types/node": "^25",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/web-push": "^3.6.4",
|
||||
@@ -106,7 +109,8 @@
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"knip": "^6.14.2",
|
||||
"knip": "^6.16.1",
|
||||
"lefthook": "^2.1.9",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.21.0",
|
||||
"prettier": "^3.8.3",
|
||||
|
||||
Generated
+602
-362
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,15 @@
|
||||
-- v1.15.13 — source-filter support indexes for the measurements + mood
|
||||
-- management lists. The new list surfaces add a source filter (and the
|
||||
-- existing source/value sort). No index leads with `(user_id, source)`
|
||||
-- today, so a source filter forces a `user_id` scan + in-memory sort —
|
||||
-- the landmine at 100–200k rows per user. These two additive indexes turn
|
||||
-- a source filter + recency sort into an index range scan.
|
||||
--
|
||||
-- Pure additive `CREATE INDEX IF NOT EXISTS`, no backfill — same low-risk
|
||||
-- shape as migration 0123.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "measurements_user_id_source_measured_at_idx"
|
||||
ON "measurements" ("user_id", "source", "measured_at" DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "mood_entries_user_id_source_mood_logged_at_idx"
|
||||
ON "mood_entries" ("user_id", "source", "mood_logged_at" DESC);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- v1.15.18 — per-dose configurable intake WINDOW.
|
||||
--
|
||||
-- Marc's "07:00–09:00" lever: a dose time can carry an explicit on-time RANGE
|
||||
-- instead of the symmetric ±1h default the band minter derives. The column is
|
||||
-- a nullable JSON array of `{ timeOfDay, start, end }` HH:mm triples, one per
|
||||
-- dose time the user gave an explicit window for, e.g.
|
||||
-- [{ "timeOfDay": "07:00", "start": "07:00", "end": "09:00" }]
|
||||
--
|
||||
-- The band minter reads it to build the on-time band from `[start, end]` for
|
||||
-- the matching `timeOfDay`; a time with no entry keeps the default ±1h
|
||||
-- derivation, and a NULL column leaves every slot on the default (unchanged
|
||||
-- behaviour). The late tail stays cadence-derived.
|
||||
--
|
||||
-- Additive + non-destructive: a new nullable column with no backfill. Existing
|
||||
-- rows read NULL and behave exactly as before; the iOS contract gains an
|
||||
-- optional field.
|
||||
ALTER TABLE "medication_schedules"
|
||||
ADD COLUMN "dose_windows" JSONB;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- v1.15.20 — per-user hour-cycle display preference.
|
||||
--
|
||||
-- The UI locale alone decided whether times rendered as AM/PM or 24-hour,
|
||||
-- which left e.g. an en-locale user no way to read 24-hour times. The new
|
||||
-- column carries an explicit preference: AUTO follows the locale convention,
|
||||
-- H12 forces AM/PM, H24 forces 24-hour. Display-time only — stored instants
|
||||
-- stay UTC.
|
||||
--
|
||||
-- Additive + non-destructive: a new NOT NULL column with a default, no
|
||||
-- backfill needed. Existing rows read AUTO.
|
||||
CREATE TYPE "time_format_preference" AS ENUM ('AUTO', 'H12', 'H24');
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "time_format" "time_format_preference" NOT NULL DEFAULT 'AUTO';
|
||||
@@ -0,0 +1,16 @@
|
||||
-- v1.15.20 — provenance of an intake row's slot binding.
|
||||
--
|
||||
-- An off-window take can now be deliberately pinned onto a scheduled slot
|
||||
-- ("diesem Slot zuordnen"). The column records HOW the row's `scheduled_for`
|
||||
-- binding came to be: AUTO = window-band attribution (or a skip / pending
|
||||
-- mint anchored on its slot), USER_PIN = a deliberate user pin. The read
|
||||
-- ledger binds USER_PIN rows by their stored anchor (like skips) instead of
|
||||
-- re-running takenAt-band attribution, so a pinned take outside the late
|
||||
-- tail stays on its slot — rendered taken-late, never on-time-washed.
|
||||
--
|
||||
-- Additive + non-destructive: a new NOT NULL column with a default, no
|
||||
-- backfill needed. Existing rows read AUTO.
|
||||
CREATE TYPE "intake_attribution_source" AS ENUM ('AUTO', 'USER_PIN');
|
||||
|
||||
ALTER TABLE "medication_intake_events"
|
||||
ADD COLUMN "attribution_source" "intake_attribution_source" NOT NULL DEFAULT 'AUTO';
|
||||
@@ -0,0 +1,22 @@
|
||||
-- v1.15.20 — user-authored "about me" self-description for the AI surfaces.
|
||||
--
|
||||
-- 1:1 with users, lazily created on first save. The free text is encrypted
|
||||
-- at rest (AES-256-GCM through the shared Bytes codec, the CoachFact
|
||||
-- precedent) — `about_me_encrypted` carries the `"<keyId>.<base64>"`
|
||||
-- ciphertext as UTF-8 bytes and is NULL when the user cleared (or never
|
||||
-- wrote) the text. Additive + non-destructive.
|
||||
CREATE TABLE "user_health_profiles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"about_me_encrypted" BYTEA,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "user_health_profiles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "user_health_profiles_user_id_key" ON "user_health_profiles"("user_id");
|
||||
|
||||
ALTER TABLE "user_health_profiles"
|
||||
ADD CONSTRAINT "user_health_profiles_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- v1.15.20 — registration invite tokens.
|
||||
--
|
||||
-- Admits a signup while open registration is disabled. Only the
|
||||
-- HMAC-SHA256 hash of the raw token is stored (the ApiToken scheme);
|
||||
-- `expires_at` is mandatory (route-capped at 30 days), `uses`/`max_uses`
|
||||
-- carry the consumption budget, `used_by` tracks the most recent consumer
|
||||
-- and survives account deletion as NULL. Additive + non-destructive.
|
||||
CREATE TABLE "invite_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"created_by" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"used_at" TIMESTAMP(3),
|
||||
"used_by" TEXT,
|
||||
"max_uses" INTEGER NOT NULL DEFAULT 1,
|
||||
"uses" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "invite_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "invite_tokens_token_hash_key" ON "invite_tokens"("token_hash");
|
||||
|
||||
CREATE INDEX "invite_tokens_expires_at_idx" ON "invite_tokens"("expires_at");
|
||||
|
||||
ALTER TABLE "invite_tokens"
|
||||
ADD CONSTRAINT "invite_tokens_created_by_fkey"
|
||||
FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "invite_tokens"
|
||||
ADD CONSTRAINT "invite_tokens_used_by_fkey"
|
||||
FOREIGN KEY ("used_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,44 @@
|
||||
-- v1.16.0 — invite redemption ledger + soft revocation.
|
||||
--
|
||||
-- `invite_tokens.used_by` keeps only the LAST consumer (multi-use
|
||||
-- invites overwrite it); `invite_redemptions` carries one row per
|
||||
-- admitted signup so the admin table can show the full history with
|
||||
-- timestamps. `user_id` survives account deletion as NULL — the row
|
||||
-- still documents that a use was consumed.
|
||||
--
|
||||
-- `revoked_at` turns revocation into a soft state: a revoked invite
|
||||
-- keeps its history visible instead of vanishing, and the consume path
|
||||
-- refuses it like an expired one. Additive + non-destructive.
|
||||
ALTER TABLE "invite_tokens" ADD COLUMN "revoked_at" TIMESTAMP(3);
|
||||
|
||||
CREATE TABLE "invite_redemptions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"invite_id" TEXT NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"redeemed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "invite_redemptions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "invite_redemptions_invite_id_idx" ON "invite_redemptions"("invite_id");
|
||||
|
||||
ALTER TABLE "invite_redemptions"
|
||||
ADD CONSTRAINT "invite_redemptions_invite_id_fkey"
|
||||
FOREIGN KEY ("invite_id") REFERENCES "invite_tokens"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "invite_redemptions"
|
||||
ADD CONSTRAINT "invite_redemptions_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Backfill: every invite that already recorded a consumer becomes one
|
||||
-- redemption row, stamped with the invite's `used_at`. Invites whose
|
||||
-- consumer account was deleted (`used_by` NULL but `uses` > 0) cannot
|
||||
-- be reconstructed and stay ledger-less — the use counter still counts.
|
||||
INSERT INTO "invite_redemptions" ("id", "invite_id", "user_id", "redeemed_at")
|
||||
SELECT
|
||||
'invred_' || md5(random()::text || clock_timestamp()::text),
|
||||
"id",
|
||||
"used_by",
|
||||
COALESCE("used_at", "created_at")
|
||||
FROM "invite_tokens"
|
||||
WHERE "used_by" IS NOT NULL;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- v1.16.0 — structured self-context fields on the health profile.
|
||||
--
|
||||
-- Three short structured companions to the free-text "about me"
|
||||
-- (chronic conditions, allergies/intolerances, what the Coach should
|
||||
-- watch), each encrypted at rest through the shared Bytes codec and
|
||||
-- capped at 500 chars BEFORE encryption. Age/gender deliberately do
|
||||
-- NOT live here — they come from the users table and are merged into
|
||||
-- the prompt block at assembly time.
|
||||
--
|
||||
-- `pending_questions_encrypted` carries up to 3 clarifying questions
|
||||
-- the server derives after a save (encrypted JSON string[]); the Coach
|
||||
-- composer renders them as tappable suggestion chips. Additive +
|
||||
-- non-destructive.
|
||||
ALTER TABLE "user_health_profiles" ADD COLUMN "conditions_encrypted" BYTEA;
|
||||
ALTER TABLE "user_health_profiles" ADD COLUMN "allergies_encrypted" BYTEA;
|
||||
ALTER TABLE "user_health_profiles" ADD COLUMN "coach_focus_encrypted" BYTEA;
|
||||
ALTER TABLE "user_health_profiles" ADD COLUMN "pending_questions_encrypted" BYTEA;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- v1.16.3 — schedule-revision archive (effective dating).
|
||||
--
|
||||
-- A medication whose schedule times are edited used to lose its history: the
|
||||
-- wholesale replace (deleteMany + create) left only the CURRENT rows, so
|
||||
-- bands for PAST days were minted from the new times — the entire old era
|
||||
-- read "unscheduled" and the new times' past slots read "missed".
|
||||
--
|
||||
-- This table archives each SUPERSEDED schedule state as one revision row
|
||||
-- covering `[valid_from, valid_until)`. The LIVE rows stay in
|
||||
-- `medication_schedules`; a revision never describes the current state, so
|
||||
-- `valid_until` is NOT NULL by construction. `payload` holds the full
|
||||
-- snapshot of the replaced schedule rows (timesOfDay, windows, cadence
|
||||
-- fields, doseWindows, label, dose, reminderGraceMinutes) — enough to
|
||||
-- rebuild the canonical schedule list for era minting.
|
||||
--
|
||||
-- `starts_on` stays the global floor: the first era begins at
|
||||
-- max(medication.created_at, starts_on).
|
||||
--
|
||||
-- Additive + non-destructive: a new empty table; no existing row changes.
|
||||
CREATE TABLE "medication_schedule_revisions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"medication_id" TEXT NOT NULL,
|
||||
"valid_from" TIMESTAMPTZ(6) NOT NULL,
|
||||
"valid_until" TIMESTAMPTZ(6) NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "medication_schedule_revisions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "medication_schedule_revisions_medication_id_valid_from_idx"
|
||||
ON "medication_schedule_revisions"("medication_id", "valid_from");
|
||||
|
||||
ALTER TABLE "medication_schedule_revisions"
|
||||
ADD CONSTRAINT "medication_schedule_revisions_medication_id_fkey"
|
||||
FOREIGN KEY ("medication_id") REFERENCES "medications"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- v1.16.4 — per-intake dose override.
|
||||
--
|
||||
-- A user who takes half a tablet (or a doubled dose on a titration day)
|
||||
-- could only record the take as-is; the ledger then implied the scheduled
|
||||
-- dose was consumed. `dose_taken` records the free-text dose actually
|
||||
-- consumed for THIS event when it deviates from (or simply documents) the
|
||||
-- medication / schedule dose. NULL = no override recorded — every legacy
|
||||
-- row and every take logged without touching the dose field stays NULL,
|
||||
-- and read paths fall back to the medication's configured dose.
|
||||
ALTER TABLE "medication_intake_events" ADD COLUMN "dose_taken" TEXT;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- v1.16.5 — schedule-revision provenance.
|
||||
--
|
||||
-- The Zeitplan tab now lets a user append a PRE-tracking era by hand
|
||||
-- ("the medication dosed at 07:00/19:00 from March to June, before I
|
||||
-- edited it here"). Those rows are user-entered, not write-path
|
||||
-- archives, so they must stay deletable without touching the archive
|
||||
-- the schedule-replace path mints.
|
||||
--
|
||||
-- `source` records the provenance:
|
||||
-- 'ARCHIVED' — minted by the wholesale-replace write path (immutable
|
||||
-- history; never deletable through the API).
|
||||
-- 'MANUAL' — entered through POST
|
||||
-- /api/medications/{id}/schedule-revisions (deletable by
|
||||
-- the owner).
|
||||
--
|
||||
-- Additive + non-destructive: every existing row is a write-path
|
||||
-- archive, so the default backfills correctly.
|
||||
ALTER TABLE "medication_schedule_revisions"
|
||||
ADD COLUMN "source" TEXT NOT NULL DEFAULT 'ARCHIVED';
|
||||
@@ -0,0 +1,14 @@
|
||||
-- v1.16.6 — audit trail for corrected schedule archives.
|
||||
--
|
||||
-- The Zeitplan tab now lets the owner CORRECT a recorded era. A MANUAL
|
||||
-- era is edited in place; an ARCHIVED era (minted by the write path)
|
||||
-- stays immutable — the correction is written as a new MANUAL row and
|
||||
-- this column on the original points at it. Every era consumer (the
|
||||
-- splitter, the list endpoint, live-boundary reads) skips superseded
|
||||
-- rows, so the correction takes the original's place while the
|
||||
-- original remains as the audit record.
|
||||
--
|
||||
-- Additive + non-destructive: NULL means "active era", which is what
|
||||
-- every existing row is.
|
||||
ALTER TABLE "medication_schedule_revisions"
|
||||
ADD COLUMN "superseded_by_revision_id" TEXT;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- v1.16.8 — content-hash gate for insight regeneration.
|
||||
--
|
||||
-- `insights_snapshot_hash` stores the SHA-256 fingerprint of the
|
||||
-- compacted feature snapshot the cached comprehensive insight was
|
||||
-- generated from. Nightly, forced, and lazy regenerations compare the
|
||||
-- fresh snapshot's hash against it and skip the provider call
|
||||
-- (refreshing only `insights_cached_at`) when nothing the prompt sees
|
||||
-- has changed.
|
||||
--
|
||||
-- `insights_warm_failed_at` records the last failed/timed-out forced
|
||||
-- warm of the comprehensive insight, so the forced path can back off
|
||||
-- for an hour instead of re-attempting a broken provider chain on
|
||||
-- every page-open. Additive + non-destructive.
|
||||
ALTER TABLE "users" ADD COLUMN "insights_snapshot_hash" TEXT;
|
||||
ALTER TABLE "users" ADD COLUMN "insights_warm_failed_at" TIMESTAMP(3);
|
||||
@@ -0,0 +1,36 @@
|
||||
-- v1.16.10 — multi-unit inventory consumption.
|
||||
--
|
||||
-- `units_per_dose` (medications): how many inventory UNITS one dose
|
||||
-- consumes (2 × 2 mg tablets for a 4 mg dose). Default 1 keeps every
|
||||
-- existing medication on the one-unit-per-dose behaviour.
|
||||
--
|
||||
-- `container_type` (medication_inventory_items): what kind of physical
|
||||
-- container the item is (pen / ampoule / blister pack / inhaler /
|
||||
-- bottle). Display-level only. Backfilled PEN for items whose
|
||||
-- medication is an INJECTION (the pre-v1.16.10 surface was pen-only),
|
||||
-- OTHER for everything else.
|
||||
--
|
||||
-- `inventory_consumption` (medication_intake_events): the per-event
|
||||
-- consumption stamp `[{itemId, units}]` — the exactly-once gate for
|
||||
-- the consume hook and the refund ledger for the restore hook. NULL =
|
||||
-- never consumed (pending / skipped / pre-v1.16.10 rows).
|
||||
--
|
||||
-- The Prisma fields `dosesTotal` / `dosesRemaining` are renamed to
|
||||
-- `unitsTotal` / `unitsRemaining` at the client level only; the DB
|
||||
-- columns keep their names via @map — zero data migration.
|
||||
|
||||
CREATE TYPE "medication_container_type" AS ENUM ('PEN', 'AMPOULE', 'BLISTER', 'INHALER', 'BOTTLE', 'OTHER');
|
||||
|
||||
ALTER TABLE "medications" ADD COLUMN "units_per_dose" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
ALTER TABLE "medication_inventory_items" ADD COLUMN "container_type" "medication_container_type" NOT NULL DEFAULT 'OTHER';
|
||||
|
||||
ALTER TABLE "medication_intake_events" ADD COLUMN "inventory_consumption" JSONB;
|
||||
|
||||
-- Backfill: every existing item was registered through the pen-framed
|
||||
-- surface; items on injection medications are pens.
|
||||
UPDATE "medication_inventory_items" i
|
||||
SET "container_type" = 'PEN'
|
||||
FROM "medications" m
|
||||
WHERE i."medication_id" = m."id"
|
||||
AND m."delivery_form" = 'INJECTION';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- v1.16.10 — medications list presentation preference.
|
||||
--
|
||||
-- `medication_list_layout_json` (users): the per-user card/table view
|
||||
-- choice for /medications plus the user-defined manual medication order
|
||||
-- shared by both views. Follows the per-surface-column convention of
|
||||
-- `dashboard_widgets_json` / `insights_layout_json`. NULL = defaults
|
||||
-- (cards, alphabetical order); the GET endpoint never lazy-writes.
|
||||
--
|
||||
-- Shape: { "version": 1, "view": "table", "order": ["<medicationId>", ...] }
|
||||
|
||||
ALTER TABLE "users" ADD COLUMN "medication_list_layout_json" JSONB;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- v1.16.11 — low-stock notification stamp (medications).
|
||||
--
|
||||
-- The daily low-stock pass notifies once per threshold crossing:
|
||||
-- `low_stock_notified_at` records the dispatch instant,
|
||||
-- `low_stock_notified_threshold_days` the user threshold (days of
|
||||
-- remaining runway) it was written against. The pass clears both when
|
||||
-- the projected runway rises back to / above the threshold (refill),
|
||||
-- and treats a differing stamped threshold as re-armed. NULL = armed.
|
||||
|
||||
ALTER TABLE "medications" ADD COLUMN "low_stock_notified_at" TIMESTAMP(3);
|
||||
ALTER TABLE "medications" ADD COLUMN "low_stock_notified_threshold_days" INTEGER;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- v1.17.0 — per-user custom mood-tag groups + per-user tag layout.
|
||||
--
|
||||
-- Additive, non-destructive (mirrors 0127_v1130_custom_mood_tags):
|
||||
-- 1. `mood_tag_categories` gains `user_id` (NULL = global seeded category,
|
||||
-- set = a user's custom group carrying a `customcat:<uuid>` key) and
|
||||
-- `label_encrypted` (AES-256-GCM ciphertext of a custom group's
|
||||
-- free-text label; NULL for seeded rows, which resolve `label_key`
|
||||
-- against the locale).
|
||||
-- 2. `users` gains `mood_tag_layout_json` — the per-user presentation blob
|
||||
-- (group order + per-group tag placement), display-only, following the
|
||||
-- `medication_list_layout_json` per-surface-column convention.
|
||||
--
|
||||
-- Idempotent: ADD COLUMN IF NOT EXISTS, constraint adds guarded by
|
||||
-- duplicate_object, CREATE INDEX IF NOT EXISTS.
|
||||
|
||||
-- ─── 1. mood_tag_categories: owner + encrypted custom label ──────────
|
||||
ALTER TABLE "mood_tag_categories" ADD COLUMN IF NOT EXISTS "user_id" TEXT;
|
||||
ALTER TABLE "mood_tag_categories" ADD COLUMN IF NOT EXISTS "label_encrypted" TEXT;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "mood_tag_categories"
|
||||
ADD CONSTRAINT "mood_tag_categories_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "mood_tag_categories_user_id_idx" ON "mood_tag_categories"("user_id");
|
||||
|
||||
-- ─── 2. users: per-user mood-tag presentation blob ───────────────────
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "mood_tag_layout_json" JSONB;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- v1.16.11 (#316) — as-needed (PRN) medications.
|
||||
--
|
||||
-- `as_needed = true` marks a medication taken only when the user needs
|
||||
-- it: no fixed schedule (zero medication_schedules rows — enforced at
|
||||
-- the route layer), never due, never reminded, excluded from
|
||||
-- compliance, active indefinitely. Intakes still log, inventory still
|
||||
-- consumes. Additive: every existing row backfills `false`.
|
||||
|
||||
ALTER TABLE "medications" ADD COLUMN "as_needed" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- v1.16.12 (#316) — fractional dosing.
|
||||
--
|
||||
-- A dose can consume a FRACTION of a unit (½ / ⅓ / ¼ of a tablet for a
|
||||
-- split pill), so the unit↔dose factor and the per-container stock must
|
||||
-- hold non-integer values: 30 tablets dosed at ½ each reads 29.5 after
|
||||
-- one dose. Widen the three integer unit columns to NUMERIC; every
|
||||
-- existing integer value casts losslessly. Additive and reversible in
|
||||
-- value terms (all current rows are whole numbers).
|
||||
|
||||
ALTER TABLE "medications"
|
||||
ALTER COLUMN "units_per_dose" TYPE numeric(10,4) USING "units_per_dose"::numeric(10,4);
|
||||
|
||||
ALTER TABLE "medication_inventory_items"
|
||||
ALTER COLUMN "doses_total" TYPE numeric(12,4) USING "doses_total"::numeric(12,4),
|
||||
ALTER COLUMN "doses_remaining" TYPE numeric(12,4) USING "doses_remaining"::numeric(12,4);
|
||||
@@ -0,0 +1,43 @@
|
||||
-- v1.16.13 — operator mood-tag catalog curation.
|
||||
--
|
||||
-- Reference-data only on top of 0101 / 0118 / 0119 / 0126. No schema
|
||||
-- change, no per-user data touched, no destructive delete — every change
|
||||
-- is an `is_active` flip or an idempotent upsert keyed by `key`, so a
|
||||
-- re-run is a no-op and historical `mood_entry_tag_links` rows keep
|
||||
-- resolving their labels (the insights read joins the catalog row by id
|
||||
-- and does NOT filter `is_active`).
|
||||
--
|
||||
-- Operator walked the capture sheet and asked for:
|
||||
-- - deactivate the redundant default tags `happy`, `excited`,
|
||||
-- `hydrated` ("Wasser getrunken"), `music`, and the
|
||||
-- `factor_sleep_quality` RATED factor;
|
||||
-- - keep `overtime` active — 0126 had deactivated it as a low-signal
|
||||
-- day-type tag, but the operator wants it back in the work picker;
|
||||
-- - add a new default tag `praise` ("Lob" / "Praise") under the work
|
||||
-- category, Lucide `ThumbsUp` (already in the iOS Lucide→SF table).
|
||||
--
|
||||
-- factor_sleep_quality is deactivated alongside the rest: a manual 1–5
|
||||
-- sleep rating is redundant with the measured sleep-tracking surface, so
|
||||
-- it leaves the seeded factor set — work / sadness stay as the RATED
|
||||
-- factors from 0126. Historical entries keep resolving (read joins by id,
|
||||
-- not `is_active`).
|
||||
|
||||
-- ─── Deactivate the redundant default tags + sleep-quality factor ─────
|
||||
UPDATE "mood_tags"
|
||||
SET "is_active" = false
|
||||
WHERE "key" IN ('happy', 'excited', 'hydrated', 'music', 'factor_sleep_quality');
|
||||
|
||||
-- ─── Re-activate overtime in the work category ───────────────────────
|
||||
UPDATE "mood_tags"
|
||||
SET "is_active" = true
|
||||
WHERE "key" = 'overtime';
|
||||
|
||||
-- ─── New default tag: praise (work) ──────────────────────────────────
|
||||
-- Sort after the existing work tags (productive 0, overtime 1, day_off 2,
|
||||
-- travel 3, sick_day 4). ThumbsUp keeps the iOS render native (no app
|
||||
-- update needed). Idempotent upsert by `key`, mirroring 0101 / 0118.
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_praise', 'mtc_work', 'praise', 'mood.tag.praise', 'ThumbsUp', 5, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon",
|
||||
"sort_order" = EXCLUDED."sort_order", "is_active" = EXCLUDED."is_active";
|
||||
+329
-6
@@ -60,6 +60,17 @@ model User {
|
||||
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
|
||||
insightsCachedAt DateTime? @map("insights_cached_at")
|
||||
insightsCachedText String? @map("insights_cached_text") // cached JSON output
|
||||
// v1.16.8 — SHA-256 fingerprint of the compacted feature snapshot the
|
||||
// cached comprehensive text was generated from. Nightly / forced / lazy
|
||||
// regenerations compare the fresh snapshot's hash against this and skip
|
||||
// the provider call (refreshing only `insightsCachedAt`) when nothing
|
||||
// the prompt sees has changed.
|
||||
insightsSnapshotHash String? @map("insights_snapshot_hash")
|
||||
// v1.16.8 — last time a forced full warm's comprehensive step failed or
|
||||
// timed out. The forced warm path backs off for an hour after a failure
|
||||
// so a persistently broken provider chain cannot be re-driven into a
|
||||
// full generation attempt by every page-open.
|
||||
insightsWarmFailedAt DateTime? @map("insights_warm_failed_at")
|
||||
// v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors
|
||||
// `coachPrefsJson.excludeMetrics`. The /api/insights/generate route
|
||||
// filters matching keys off `features` before JSON.stringify so the
|
||||
@@ -126,12 +137,31 @@ model User {
|
||||
// the user has explicitly saved a layout.
|
||||
// Shape: { "tiles": [{ "id": "blutdruck", "visible": true, "order": 0 }, ...], "version": 1 }
|
||||
insightsLayoutJson Json? @map("insights_layout_json")
|
||||
// Per-user medications list presentation — the card/table view choice
|
||||
// plus the user-defined manual medication order shared by BOTH views.
|
||||
// Follows the per-surface-column convention of `dashboardWidgetsJson` /
|
||||
// `insightsLayoutJson`. Null = defaults (cards, alphabetical order).
|
||||
// The GET endpoint never lazy-writes — the column only carries data
|
||||
// once the user has explicitly toggled the view or saved an order.
|
||||
// Shape: { "version": 1, "view": "table", "order": ["<medicationId>", ...] }
|
||||
medicationListLayoutJson Json? @map("medication_list_layout_json")
|
||||
// v1.17.0 — per-user mood-tag presentation: group order + per-group tag
|
||||
// placement (catalogue tags "moved" into a group live here only — their
|
||||
// `categoryId` never changes). Display-only, preserve-when-absent PUT,
|
||||
// unknown keys dropped at read. Null = seeded defaults.
|
||||
// Shape: { "groupOrder": ["<categoryKey>", ...],
|
||||
// "placements": { "<categoryKey>": ["<tagKey>", ...] } }
|
||||
moodTagLayoutJson Json? @map("mood_tag_layout_json")
|
||||
// Blood-glucose display unit preference. Canonical storage is mg/dL.
|
||||
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
|
||||
// v1.7.0 unit pref — global metric/imperial display preference.
|
||||
// Canonical storage stays SI; this only selects the display-time
|
||||
// transform branch (km/h vs mph, km vs mi). Null = default "metric".
|
||||
unitPreference String? @map("unit_preference") // "metric" | "imperial" | null = default "metric"
|
||||
// Hour-cycle display preference. AUTO follows the locale convention
|
||||
// (en → AM/PM, de → 24h), H12 forces AM/PM, H24 forces 24-hour.
|
||||
// Display-time only — canonical storage stays UTC instants.
|
||||
timeFormat TimeFormatPreference @default(AUTO) @map("time_format")
|
||||
// AI provider selection for insights. Null = OPENAI fallback.
|
||||
aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH"
|
||||
aiModel String? @map("ai_model")
|
||||
@@ -379,6 +409,7 @@ model User {
|
||||
fitbitOAuthStates FitbitOAuthState[]
|
||||
moodEntries MoodEntry[]
|
||||
moodTagsCustom MoodTag[] @relation("UserMoodTags")
|
||||
moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
|
||||
moodTagsHidden MoodTagHidden[]
|
||||
notificationChannels NotificationChannel[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
@@ -507,6 +538,18 @@ model User {
|
||||
// seeded catalogue).
|
||||
cycleSymptomsCustom CycleSymptom[] @relation("UserCycleSymptoms")
|
||||
|
||||
// v1.15.20 — user-authored "about me" context for the AI surfaces
|
||||
// (1:1, encrypted free text). See `UserHealthProfile`.
|
||||
healthProfile UserHealthProfile?
|
||||
// v1.15.20 — registration invite tokens (closed-registration flow).
|
||||
// `InviteCreator` = admin who minted the invite; `InviteConsumer` =
|
||||
// the account that registered through it (last consumer wins on a
|
||||
// multi-use invite).
|
||||
invitesCreated InviteToken[] @relation("InviteCreator")
|
||||
invitesUsed InviteToken[] @relation("InviteConsumer")
|
||||
// v1.16.0 — per-signup invite redemption ledger (multi-use history).
|
||||
inviteRedemptions InviteRedemption[] @relation("InviteRedemptions")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
@@ -716,6 +759,16 @@ enum GlucoseContext {
|
||||
@@map("glucose_context")
|
||||
}
|
||||
|
||||
// Hour-cycle display preference for rendered times. AUTO defers to the
|
||||
// active locale's convention; H12/H24 pin the cycle regardless of locale.
|
||||
enum TimeFormatPreference {
|
||||
AUTO
|
||||
H12
|
||||
H24
|
||||
|
||||
@@map("time_format_preference")
|
||||
}
|
||||
|
||||
enum MeasurementSource {
|
||||
MANUAL
|
||||
WITHINGS
|
||||
@@ -850,6 +903,10 @@ model Measurement {
|
||||
// (Postgres semantics) so manual entries don't collide here.
|
||||
@@unique([userId, type, source, externalId])
|
||||
@@index([userId, type, measuredAt])
|
||||
// v1.15.13 — leads with `(userId, source)` so the management list's
|
||||
// source filter + recency sort is an index range scan instead of a
|
||||
// `userId` scan + in-memory sort. See migration 0136.
|
||||
@@index([userId, source, measuredAt(sort: Desc)])
|
||||
@@index([externalId])
|
||||
// v1.7.0 — supports the `/api/sync/changes` keyset feed, which walks
|
||||
// `where userId` ordered by `(updatedAt asc, id asc)` with `take
|
||||
@@ -1220,6 +1277,17 @@ model Medication {
|
||||
/// (Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose
|
||||
/// pen = 1). NULL = inventory tracking off.
|
||||
dosesPerUnit Int? @map("doses_per_unit")
|
||||
/// v1.16.10 — units consumed per dose. The inventory items count UNITS
|
||||
/// (tablets, ampoules, puffs); a single dose may consume several of
|
||||
/// them (2 × 2 mg tablets for a 4 mg dose). The intake consumption
|
||||
/// hook multiplies by this factor; dose-level readouts divide by it.
|
||||
/// Default 1 = one unit per dose (the overwhelmingly common case).
|
||||
/// v1.16.12 (#316) — Decimal, so a dose can consume a FRACTION of a
|
||||
/// unit (½ / ⅓ / ¼ tablet for a split pill). The UI offers a curated
|
||||
/// set of common fractions; the column stores their decimal value
|
||||
/// (¼=0.25, ⅓≈0.3333, ½=0.5, ⅔≈0.6667, ¾=0.75) plus whole numbers.
|
||||
/// Existing integer rows cast losslessly.
|
||||
unitsPerDose Decimal @default(1) @map("units_per_dose") @db.Decimal(10, 4)
|
||||
active Boolean @default(true)
|
||||
notificationsEnabled Boolean @default(true) @map("notifications_enabled")
|
||||
pausedAt DateTime? @map("paused_at")
|
||||
@@ -1242,6 +1310,16 @@ model Medication {
|
||||
/// the migration backfills `oneShot = false` for every existing
|
||||
/// row.
|
||||
oneShot Boolean @default(false) @map("one_shot")
|
||||
/// v1.16.11 (#316) — as-needed (PRN) medication. Taken only when the
|
||||
/// user needs it (pain relief, rescue inhaler): no fixed schedule,
|
||||
/// never due, never reminded, never counted in compliance, stays
|
||||
/// active indefinitely — but intakes log, inventory consumes, and the
|
||||
/// history renders. An as-needed medication carries ZERO
|
||||
/// MedicationSchedule rows (the create/update routes 422 on a
|
||||
/// schedules array alongside `asNeeded: true`); every "never due /
|
||||
/// never reminded" surface follows structurally from the empty
|
||||
/// schedule list. Mutually exclusive with `oneShot`.
|
||||
asNeeded Boolean @default(false) @map("as_needed")
|
||||
/// v1.6.0 — route of administration. Decoupled from `treatmentClass`
|
||||
/// so the injection-site picker can surface for any INJECTION dose.
|
||||
/// A one-time injection is `oneShot = true` + `deliveryForm =
|
||||
@@ -1282,6 +1360,15 @@ model Medication {
|
||||
/// under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any
|
||||
/// ATC code, never instead of the free-text `.text` anchor.
|
||||
rxNormCode String? @map("rxnorm_code")
|
||||
/// v1.16.11 — low-stock notification stamp. Set when the daily
|
||||
/// low-stock pass dispatched a notification for the current
|
||||
/// threshold crossing; cleared (re-armed) when the projected runway
|
||||
/// rises back to / above the user's threshold (refill). NULL = armed.
|
||||
lowStockNotifiedAt DateTime? @map("low_stock_notified_at")
|
||||
/// v1.16.11 — the user threshold (days) the stamp was written
|
||||
/// against. A changed threshold re-arms the medication even while
|
||||
/// the runway stays low, so the user hears about the new crossing.
|
||||
lowStockNotifiedThresholdDays Int? @map("low_stock_notified_threshold_days")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -1305,6 +1392,12 @@ model Medication {
|
||||
/// on the linked MedicationIntakeEvent rows refresh the row inline;
|
||||
/// the read paths consume this instead of the per-event findMany.
|
||||
complianceRollups MedicationComplianceRollup[]
|
||||
/// v1.16.3 — archived schedule eras (effective dating). Superseded
|
||||
/// schedule states land here when a wholesale schedule replace changes
|
||||
/// cadence-relevant fields; the band minter segments historical minting
|
||||
/// ranges into eras so past days read against the schedule that was
|
||||
/// live THEN, not the current one.
|
||||
scheduleRevisions MedicationScheduleRevision[]
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -1384,6 +1477,16 @@ model MedicationSchedule {
|
||||
/// v1.7.0 — cyclic "off" weeks. Only meaningful when
|
||||
/// `scheduleType = CYCLIC`.
|
||||
cyclicOffWeeks Int? @map("cyclic_off_weeks")
|
||||
/// v1.15.18 — per-dose on-time intake WINDOW (the configurable lever).
|
||||
/// A JSON array of `{ timeOfDay, start, end }` HH:mm triples, one per
|
||||
/// dose time the user wants an explicit range for (e.g.
|
||||
/// `[{ "timeOfDay": "07:00", "start": "07:00", "end": "09:00" }]`).
|
||||
/// The band minter reads this to build the on-time band from the
|
||||
/// explicit `[start, end]` range for the matching `timeOfDay`; a
|
||||
/// `timeOfDay` with no entry falls back to the symmetric ±1h default.
|
||||
/// The late tail stays cadence-derived. NULL = every slot uses the
|
||||
/// default derivation (unchanged behaviour). Additive + iOS-safe.
|
||||
doseWindows Json? @map("dose_windows")
|
||||
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
telegramMessages TelegramReminderMessage[]
|
||||
@@ -1391,6 +1494,60 @@ model MedicationSchedule {
|
||||
@@map("medication_schedules")
|
||||
}
|
||||
|
||||
/// v1.16.3 — schedule-revision archive (effective dating). One row per
|
||||
/// SUPERSEDED schedule state of a medication: when the schedule set is
|
||||
/// wholesale-replaced with materially different cadence fields, the old
|
||||
/// rows are archived here as a single revision covering
|
||||
/// `[validFrom, validUntil)`. The LIVE rows stay in
|
||||
/// `medication_schedules` — a revision never describes the current
|
||||
/// state, so `validUntil` is always set (no open-ended rows).
|
||||
///
|
||||
/// The band minter segments any historical minting range into eras:
|
||||
/// each revision interval mints with ITS archived schedules, the
|
||||
/// remainder (from the newest `validUntil` onward) mints with the live
|
||||
/// rows. Era boundary rule: a slot belongs to the era its anchor lies
|
||||
/// in; an on-time/late tail may reach past the boundary, but no new
|
||||
/// anchor is minted beyond it.
|
||||
///
|
||||
/// `startsOn` stays the global floor for ALL eras: the first era begins
|
||||
/// at `max(medication.createdAt, startsOn)`; a times-only edit must
|
||||
/// never move `startsOn`.
|
||||
model MedicationScheduleRevision {
|
||||
id String @id @default(cuid())
|
||||
medicationId String @map("medication_id")
|
||||
/// Inclusive start of the era this archived state was live for.
|
||||
/// Chains from the previous revision's `validUntil`, or from
|
||||
/// `medication.createdAt` for the first revision.
|
||||
validFrom DateTime @map("valid_from") @db.Timestamptz(6)
|
||||
/// Exclusive end of the era — the instant the replace happened.
|
||||
validUntil DateTime @map("valid_until") @db.Timestamptz(6)
|
||||
/// Full snapshot of the superseded schedule rows: an array of
|
||||
/// `{ timesOfDay, windowStart, windowEnd, daysOfWeek, rrule,
|
||||
/// rollingIntervalDays, scheduleType, cyclicOnWeeks, cyclicOffWeeks,
|
||||
/// doseWindows, label, dose, reminderGraceMinutes }` objects — enough
|
||||
/// to rebuild a `CanonicalSchedule` list for era minting.
|
||||
payload Json
|
||||
/// v1.16.5 — provenance. 'ARCHIVED' = minted by the schedule-replace
|
||||
/// write path (immutable history). 'MANUAL' = user-entered pre-tracking
|
||||
/// era via POST /api/medications/{id}/schedule-revisions (deletable by
|
||||
/// the owner).
|
||||
source String @default("ARCHIVED")
|
||||
/// v1.16.6 — audit trail for corrected archives. When the owner
|
||||
/// corrects an ARCHIVED era through PATCH
|
||||
/// /api/medications/{id}/schedule-revisions/{revisionId}, the route
|
||||
/// mints a MANUAL replacement and points this column at it. The
|
||||
/// original row stays as the audit record; every era consumer
|
||||
/// (splitter, list endpoint, live-boundary reads) skips rows where
|
||||
/// this is set.
|
||||
supersededByRevisionId String? @map("superseded_by_revision_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([medicationId, validFrom])
|
||||
@@map("medication_schedule_revisions")
|
||||
}
|
||||
|
||||
enum IntakeSource {
|
||||
WEB
|
||||
API
|
||||
@@ -1400,6 +1557,19 @@ enum IntakeSource {
|
||||
@@map("intake_source")
|
||||
}
|
||||
|
||||
/// v1.15.20 — provenance of an intake row's slot binding. `USER_PIN` marks
|
||||
/// an attribution the user fixed by hand (widened in v1.16.0): either a
|
||||
/// deliberate "diesem Slot zuordnen" pin onto a named slot, or its released
|
||||
/// ("Zuordnung lösen") counterpart that stays a standalone ad-hoc take
|
||||
/// (`scheduledFor === takenAt`). The nightly slot dedup never folds a
|
||||
/// USER_PIN row into a snap cluster, so neither decision is reverted.
|
||||
enum IntakeAttributionSource {
|
||||
AUTO
|
||||
USER_PIN
|
||||
|
||||
@@map("intake_attribution_source")
|
||||
}
|
||||
|
||||
enum ReminderPhase {
|
||||
GREEN
|
||||
YELLOW
|
||||
@@ -1451,6 +1621,18 @@ model MedicationIntakeEvent {
|
||||
/// legacy row defaults to false (a user skip or a live pending row,
|
||||
/// neither of which is an auto-miss).
|
||||
autoMissed Boolean @default(false) @map("auto_missed")
|
||||
/// v1.15.20 — how this row's `scheduledFor` slot binding came to be.
|
||||
/// `AUTO` (default) = the write path attributed the take by window-band
|
||||
/// membership (or the row is a skip / pending mint anchored on its slot).
|
||||
/// `USER_PIN` = the user fixed the attribution by hand (v1.16.0): a
|
||||
/// deliberate pin onto a named slot ("diesem Slot zuordnen") OR the
|
||||
/// released ("Zuordnung lösen") row that stays deliberately ad-hoc
|
||||
/// (`scheduledFor === takenAt`). The read ledger binds slot-anchored
|
||||
/// USER_PIN rows by their `scheduledFor` anchor (like skips) instead of
|
||||
/// re-running takenAt-band attribution — visibly marked, counted late,
|
||||
/// never on-time-washed — and surfaces released rows as ad-hoc; the
|
||||
/// nightly dedup leaves both standalone.
|
||||
attributionSource IntakeAttributionSource @default(AUTO) @map("attribution_source")
|
||||
source IntakeSource @default(WEB)
|
||||
idempotencyKey String? @unique @map("idempotency_key")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
@@ -1458,6 +1640,20 @@ model MedicationIntakeEvent {
|
||||
/// is the only surface that writes this column). Existing rows stay
|
||||
/// NULL forever and the read paths treat NULL as "no site recorded".
|
||||
injectionSite InjectionSite? @map("injection_site")
|
||||
/// v1.16.4 — per-intake dose override. Free text (mirrors
|
||||
/// `Medication.dose`), populated only when the user edits the dose at
|
||||
/// log time (half tablet, titration day). NULL = the configured
|
||||
/// medication / schedule dose applies; read paths fall back to it.
|
||||
doseTaken String? @map("dose_taken")
|
||||
/// v1.16.10 — inventory consumption stamp: `[{itemId, units}]` for the
|
||||
/// containers this taken event decremented. NULL = never consumed
|
||||
/// (pending / skipped / pre-v1.16.10 rows). The stamp is the exactly-
|
||||
/// once gate for the consume hook (a stamped row never decrements
|
||||
/// again, however often a sync replays it) and the refund ledger for
|
||||
/// the restore hook (taken → skipped / deleted refunds per item). The
|
||||
/// stamp freezes history: editing `Medication.unitsPerDose` later
|
||||
/// never changes what an already-taken dose consumed.
|
||||
inventoryConsumption Json? @map("inventory_consumption")
|
||||
// v1.7.0 sync — server-set mutation timestamp. The model had no
|
||||
// `updatedAt`; the offline-sync keyset feed orders on it, so it is
|
||||
// added here. `@updatedAt` makes Prisma maintain it on every write.
|
||||
@@ -1565,16 +1761,41 @@ enum MedicationInventoryState {
|
||||
@@map("medication_inventory_state")
|
||||
}
|
||||
|
||||
/// v1.16.10 — what kind of physical container an inventory item is.
|
||||
/// Display-level only (no state-machine behaviour hangs off it); the
|
||||
/// register dialog defaults it from the medication's delivery form
|
||||
/// (INJECTION → PEN, ORAL → BLISTER, else OTHER).
|
||||
enum MedicationContainerType {
|
||||
PEN
|
||||
AMPOULE
|
||||
BLISTER
|
||||
INHALER
|
||||
BOTTLE
|
||||
OTHER
|
||||
|
||||
@@map("medication_container_type")
|
||||
}
|
||||
|
||||
model MedicationInventoryItem {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
medicationId String @map("medication_id")
|
||||
state MedicationInventoryState @default(ACTIVE)
|
||||
/// Doses the pen / vial shipped with. Mounjaro KwikPen = 4,
|
||||
/// Trulicity single-dose = 1, Byetta = 60.
|
||||
dosesTotal Int @map("doses_total")
|
||||
/// Remaining doses; decremented by the intake hook.
|
||||
dosesRemaining Int @map("doses_remaining")
|
||||
/// v1.16.10 — container kind (pen / ampoule / blister pack / …).
|
||||
/// Backfilled PEN for items on INJECTION medications, OTHER otherwise.
|
||||
containerType MedicationContainerType @default(OTHER) @map("container_type")
|
||||
/// Units the container shipped with (v1.16.10 — renamed from
|
||||
/// `dosesTotal`; the DB column stays `doses_total`). A unit is one
|
||||
/// tablet / ampoule / puff; `Medication.unitsPerDose` maps units to
|
||||
/// doses. Mounjaro KwikPen = 4, Trulicity single-dose = 1.
|
||||
/// v1.16.12 (#316) — Decimal so a split-pill consumption can leave a
|
||||
/// fractional remainder (30 tablets dosed at ½ each reads 29.5 after
|
||||
/// one dose). Existing integer rows cast losslessly.
|
||||
unitsTotal Decimal @map("doses_total") @db.Decimal(12, 4)
|
||||
/// Remaining units; decremented by the intake consumption hook
|
||||
/// (v1.16.10 — renamed from `dosesRemaining`; column stays
|
||||
/// `doses_remaining`). v1.16.12 (#316) — Decimal for fractional dosing.
|
||||
unitsRemaining Decimal @map("doses_remaining") @db.Decimal(12, 4)
|
||||
/// NULL until the pen is first opened. Sets the 30-day in-use
|
||||
/// clock per EMA EPAR §6.3.
|
||||
firstUseAt DateTime? @map("first_use_at")
|
||||
@@ -1772,6 +1993,58 @@ model ApiToken {
|
||||
@@map("api_tokens")
|
||||
}
|
||||
|
||||
// v1.15.20 — registration invite tokens. When the operator has disabled
|
||||
// open registration (`AppSettings.registrationEnabled = false`), a valid,
|
||||
// unexpired invite token still admits a signup. The raw `hlv_<hex>` token
|
||||
// is shown exactly once at mint time; only its HMAC-SHA256 hash (keyed by
|
||||
// `API_TOKEN_HMAC_KEY`, the same scheme as `ApiToken.tokenHash`) is
|
||||
// stored. `expiresAt` is mandatory and capped at 30 days by the admin
|
||||
// route. `uses`/`maxUses` make multi-use invites possible; the register
|
||||
// path consumes a use with an atomic guarded increment so a concurrent
|
||||
// signup can never overshoot `maxUses`. `usedBy` tracks the most recent
|
||||
// consumer (informational; SetNull on account deletion so the invite
|
||||
// audit row outlives the account).
|
||||
model InviteToken {
|
||||
id String @id @default(cuid())
|
||||
tokenHash String @unique @map("token_hash")
|
||||
createdBy String @map("created_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
usedAt DateTime? @map("used_at")
|
||||
usedBy String? @map("used_by")
|
||||
maxUses Int @default(1) @map("max_uses")
|
||||
uses Int @default(0)
|
||||
// v1.16.0 — soft revocation. A revoked invite keeps its redemption
|
||||
// history visible in the admin table instead of vanishing; the
|
||||
// consume path refuses it like an expired one.
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
|
||||
creator User @relation("InviteCreator", fields: [createdBy], references: [id], onDelete: Cascade)
|
||||
consumer User? @relation("InviteConsumer", fields: [usedBy], references: [id], onDelete: SetNull)
|
||||
redemptions InviteRedemption[]
|
||||
|
||||
@@index([expiresAt])
|
||||
@@map("invite_tokens")
|
||||
}
|
||||
|
||||
// v1.16.0 — one row per signup an invite admitted. `InviteToken.usedBy`
|
||||
// keeps only the LAST consumer (it overwrites on multi-use invites);
|
||||
// this ledger carries the full history so the admin table can show
|
||||
// every redemption with its timestamp. `userId` survives account
|
||||
// deletion as NULL — the row still documents that a use was consumed.
|
||||
model InviteRedemption {
|
||||
id String @id @default(cuid())
|
||||
inviteId String @map("invite_id")
|
||||
userId String? @map("user_id")
|
||||
redeemedAt DateTime @default(now()) @map("redeemed_at")
|
||||
|
||||
invite InviteToken @relation(fields: [inviteId], references: [id], onDelete: Cascade)
|
||||
user User? @relation("InviteRedemptions", fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([inviteId])
|
||||
@@map("invite_redemptions")
|
||||
}
|
||||
|
||||
// ─── Refresh Tokens (native client short-lived access + rotating refresh) ─
|
||||
|
||||
model RefreshToken {
|
||||
@@ -2082,6 +2355,10 @@ model MoodEntry {
|
||||
// null, moodLoggedAt: { gte } }` ordered by `moodLoggedAt desc`; this
|
||||
// index turns that into a range scan. See migration 0123.
|
||||
@@index([userId, moodLoggedAt])
|
||||
// v1.15.13 — leads with `(userId, source)` so the mood management
|
||||
// list's source filter + recency sort is an index range scan. See
|
||||
// migration 0136.
|
||||
@@index([userId, source, moodLoggedAt(sort: Desc)])
|
||||
// v1.7.0 sync — keyset support for the `/api/sync/changes` mood feed
|
||||
// (`where userId` ordered by `(updatedAt asc, id asc)`).
|
||||
@@index([userId, updatedAt, id])
|
||||
@@ -2120,8 +2397,19 @@ model MoodTagCategory {
|
||||
/// Soft-hide a category without deleting its historical links.
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
tags MoodTag[]
|
||||
/// v1.17.0 — owner of a per-user custom group. NULL = the global seeded
|
||||
/// catalogue (resolves `labelKey` against the locale). Set = a user's own
|
||||
/// group, carrying a `customcat:<uuid>` key and a free-text label held in
|
||||
/// `labelEncrypted` (mirrors the MoodTag custom-tag precedent).
|
||||
userId String? @map("user_id")
|
||||
/// v1.17.0 — AES-256-GCM ciphertext of a custom group's free-text label
|
||||
/// (user prose, possibly health-adjacent). NULL for seeded rows.
|
||||
labelEncrypted String? @map("label_encrypted")
|
||||
|
||||
tags MoodTag[]
|
||||
user User? @relation("UserMoodTagCategories", fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@map("mood_tag_categories")
|
||||
}
|
||||
|
||||
@@ -2975,6 +3263,41 @@ model CoachFact {
|
||||
@@map("coach_facts")
|
||||
}
|
||||
|
||||
// v1.15.20 — user-authored "about me" self-description the AI surfaces
|
||||
// (Coach system prompt + daily-briefing user prompt) inject as a clearly
|
||||
// delimited, user-provided context block. 1:1 with User and lazily
|
||||
// created on first save so existing accounts pay nothing. The free text
|
||||
// is encrypted at rest (AES-256-GCM via the shared Bytes codec, exactly
|
||||
// like `CoachFact.factEncrypted`) — it is unstructured health prose and
|
||||
// must never land in the table as plaintext. A cleared profile keeps the
|
||||
// row with a NULL payload so `updatedAt` documents the deletion instant.
|
||||
model UserHealthProfile {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique @map("user_id")
|
||||
// Free text, hard-capped at 4 000 chars by the route's Zod schema
|
||||
// BEFORE encryption. NULL = cleared / never written.
|
||||
aboutMeEncrypted Bytes? @map("about_me_encrypted")
|
||||
// v1.16.0 — structured self-context companions to the free text.
|
||||
// Short answers (each capped at 500 chars BEFORE encryption).
|
||||
// Age/gender deliberately do NOT live here — they come from the User
|
||||
// profile and are merged into the prompt block at assembly time so
|
||||
// the data is never duplicated.
|
||||
conditionsEncrypted Bytes? @map("conditions_encrypted")
|
||||
allergiesEncrypted Bytes? @map("allergies_encrypted")
|
||||
coachFocusEncrypted Bytes? @map("coach_focus_encrypted")
|
||||
// v1.16.0 — up to 3 clarifying questions the server derives after a
|
||||
// save (AI-generated when a provider + budget allow, deterministic
|
||||
// hints otherwise). Encrypted JSON string[]; the Coach composer
|
||||
// renders them as tappable suggestion chips. NULL = none pending.
|
||||
pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("user_health_profiles")
|
||||
}
|
||||
|
||||
// Per-user, per-day token-spend ledger for the AI Coach. The dispatcher
|
||||
// reads `totalTokens` against `MAX_TOKENS_PER_USER_PER_DAY` before
|
||||
// hitting any provider, so a runaway client cannot drain the operator's
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
+36
-2
@@ -13,12 +13,46 @@
|
||||
{
|
||||
"src": "/logo-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/logo-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/logo-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/logo-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Add measurement",
|
||||
"short_name": "Add",
|
||||
"url": "/measurements?add=WEIGHT&source=shortcut",
|
||||
"icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
|
||||
},
|
||||
{
|
||||
"name": "Today",
|
||||
"short_name": "Today",
|
||||
"url": "/?source=shortcut",
|
||||
"icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
|
||||
},
|
||||
{
|
||||
"name": "Medications",
|
||||
"short_name": "Meds",
|
||||
"url": "/medications?source=shortcut",
|
||||
"icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
+85
-23
@@ -27,8 +27,16 @@ try {
|
||||
} catch {
|
||||
// Fall through to the literal fallback below.
|
||||
}
|
||||
// The `|| "..."` fallback literal is the value used only when the
|
||||
// `importScripts('/sw-version.js')` above is absent (dev with no `prebuild`
|
||||
// step, or a 404 on the generated file). `scripts/generate-sw-version.mjs`
|
||||
// rewrites it to the current `package.json` version on every prebuild — the
|
||||
// marker below is the rewrite anchor — so in any shipped image the literal
|
||||
// matches the active release and can never drift stale the way it did across
|
||||
// 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__) || "v1.4.43";
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.16.13";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
const MAX_STATIC_ENTRIES = 150;
|
||||
@@ -50,16 +58,24 @@ self.addEventListener("install", (event) => {
|
||||
// ── Activate: clean up old caches ────────────────────────────────────────────
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE)
|
||||
.map((k) => caches.delete(k)),
|
||||
Promise.all([
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) =>
|
||||
Promise.all(
|
||||
keys
|
||||
.filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE)
|
||||
.map((k) => caches.delete(k)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.then(() => self.clients.claim()),
|
||||
// Navigation preload lets the browser start the navigation request
|
||||
// in parallel with service-worker startup; `networkFirst` consumes
|
||||
// it via `event.preloadResponse`, shaving SW boot latency off every
|
||||
// page navigation. Guarded — older engines lack the API.
|
||||
self.registration.navigationPreload
|
||||
? self.registration.navigationPreload.enable()
|
||||
: Promise.resolve(),
|
||||
]).then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -86,9 +102,10 @@ self.addEventListener("fetch", (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML pages — network-first with cache fallback
|
||||
// HTML pages — network-first with cache fallback. The whole event is
|
||||
// passed so `networkFirst` can consume the navigation-preload response.
|
||||
if (request.headers.get("accept")?.includes("text/html")) {
|
||||
event.respondWith(networkFirst(request, PAGE_CACHE));
|
||||
event.respondWith(networkFirst(event, PAGE_CACHE));
|
||||
return;
|
||||
}
|
||||
});
|
||||
@@ -110,20 +127,63 @@ async function cacheFirst(request, cacheName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Network-first: try network, fall back to cache.
|
||||
* Privacy gate for the navigation page cache. Most app routes are
|
||||
* client-fetch-only shells with no server-rendered PII (health JSON loads
|
||||
* over `/api/*`, which is network-only here), so caching their HTML is
|
||||
* safe. Two carve-outs:
|
||||
*
|
||||
* 1. A `Cache-Control: no-store` response opts itself out — the
|
||||
* principled, future-proof rule for any current/future server RSC
|
||||
* that renders user data and sets the header.
|
||||
* 2. `/c/*` (the clinician-share view) renders health values + wellness
|
||||
* scores straight into the HTML and emits `no-store`; the explicit
|
||||
* path skip is belt-and-braces so a revoked share can never linger in
|
||||
* CacheStorage and render back offline.
|
||||
*/
|
||||
async function networkFirst(request, cacheName) {
|
||||
function isCacheableNavigation(request, response) {
|
||||
const cacheControl = response.headers.get("Cache-Control") || "";
|
||||
if (/no-store/i.test(cacheControl)) return false;
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const { pathname } = new URL(request.url);
|
||||
if (pathname === "/c" || pathname.startsWith("/c/")) return false;
|
||||
} catch {
|
||||
// Unparseable URL — fall through; the no-store check already ran.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Network-first: try network (preferring the navigation-preload response
|
||||
* when the browser already started it), fall back to cache.
|
||||
*
|
||||
* The offline fallback only serves a shell cached under the CURRENT
|
||||
* `CACHE_VERSION` cache names. The previous global `caches.match()` could
|
||||
* resolve from a stale pre-update cache in the activation gap and serve a
|
||||
* shell whose `/_next/static/*` chunk graph no longer exists
|
||||
* (`ChunkLoadError` on the first lazy navigation).
|
||||
*/
|
||||
async function networkFirst(event, cacheName) {
|
||||
const { request } = event;
|
||||
try {
|
||||
const response =
|
||||
(event.preloadResponse ? await event.preloadResponse : null) ||
|
||||
(await fetch(request));
|
||||
if (response.ok && isCacheableNavigation(request, response)) {
|
||||
const cache = await caches.open(cacheName);
|
||||
cache.put(request, response.clone());
|
||||
trimCache(cacheName, MAX_PAGE_ENTRIES);
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
// Version-scoped fallback: the page cache first, then the precached
|
||||
// app shell — both names embed CACHE_VERSION, so a stale shell from a
|
||||
// previous release can never be served here.
|
||||
const pageCache = await caches.open(cacheName);
|
||||
const cachedPage = await pageCache.match(request);
|
||||
if (cachedPage) return cachedPage;
|
||||
const staticCache = await caches.open(STATIC_CACHE);
|
||||
const precached = await staticCache.match(request);
|
||||
if (precached) return precached;
|
||||
|
||||
// Offline fallback. F-7 (mobile security audit, 2026-05-16): the
|
||||
// previous fallback rendered hard-coded German body copy, which is
|
||||
@@ -143,14 +203,16 @@ async function networkFirst(request, cacheName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim cache to maxEntries by removing oldest entries.
|
||||
* Trim cache to maxEntries by removing oldest entries. `keys()` returns
|
||||
* insertion order, so one read + a single pass over the excess prefix
|
||||
* replaces the previous re-fetch-keys-per-deletion loop (O(n²) reads).
|
||||
*/
|
||||
async function trimCache(cacheName, maxEntries) {
|
||||
const cache = await caches.open(cacheName);
|
||||
let keys = await cache.keys();
|
||||
while (keys.length > maxEntries) {
|
||||
await cache.delete(keys[0]);
|
||||
keys = await cache.keys();
|
||||
const keys = await cache.keys();
|
||||
const excess = keys.length - maxEntries;
|
||||
for (let i = 0; i < excess; i++) {
|
||||
await cache.delete(keys[i]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ Use `pnpm dlx tsx`, not bare `pnpm tsx`. The production standalone image strips
|
||||
- **`assert-deploy.ts`** — post-deploy `/api/version` assertion.
|
||||
- **`rotate-encryption-key.ts`** — AES-256-GCM key rotation; see `docs/ops/encryption-key-rotation.md`.
|
||||
- **`restore-backup.ts`** — restore an off-host AES-GCM backup from an S3-compatible bucket.
|
||||
- **`repair-intake-anomalies.ts`** — repair historic medication-ledger defects (duplicate dose-slot rows, implausible `taken_at`); dry-run by default, see `docs/ops/intake-repair.md`.
|
||||
- **`backfill-rollups.ts`**, **`drain-per-sample-cumulative.ts`**, **`backfill-mood-note-column.ts`** — historical data backfills (note: the boot-time `rollup-full-backfill` queue handles new accounts automatically; the CLI is the manual fallback).
|
||||
- **`seed-demo.ts`** + **`seed-demo-v15.sql`** — populate a demo tenant.
|
||||
- **`test-notifications.ts`** — exercise the notification dispatcher + reminder check.
|
||||
|
||||
@@ -43,8 +43,11 @@ describe("public/sw.js CACHE_VERSION wiring", () => {
|
||||
const sw = readFileSync(SW_PATH, "utf8");
|
||||
// The fallback OR-expression keeps the SW functional in dev mode
|
||||
// where the build script has not run. Pin the shape so the fallback
|
||||
// can never silently vanish.
|
||||
expect(sw).toMatch(/self\.__APP_VERSION__\s*\)?\s*\|\|\s*"v\d/);
|
||||
// can never silently vanish. The optional block comment is the
|
||||
// `@sw-version-fallback` rewrite anchor the prebuild generator keys on.
|
||||
expect(sw).toMatch(
|
||||
/self\.__APP_VERSION__\s*\)?\s*\|\|\s*(?:\/\*[^]*?\*\/\s*)?"v\d/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
* Run it after every deploy — the queued-deploy status is not the source
|
||||
* of truth, the served version is:
|
||||
*
|
||||
* pnpm dlx tsx scripts/assert-deploy.ts <expected-version>
|
||||
* HEALTHLOG_PROD_URL=https://healthlog.example.com \
|
||||
* pnpm dlx tsx scripts/assert-deploy.ts <expected-version>
|
||||
*
|
||||
* The prod target's base URL comes from HEALTHLOG_PROD_URL (required
|
||||
* unless --only=demo).
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
@@ -36,8 +40,15 @@ interface Target {
|
||||
url: string;
|
||||
}
|
||||
|
||||
const TARGETS: Record<string, Target> = {
|
||||
prod: { name: "prod", url: "https://healthlog.bombeck.io/api/version" },
|
||||
// The production base URL is instance-specific — there is no meaningful
|
||||
// default for a self-hosted app. Set HEALTHLOG_PROD_URL (e.g.
|
||||
// `https://healthlog.example.com`) before asserting the prod target.
|
||||
const PROD_BASE_URL = process.env.HEALTHLOG_PROD_URL?.replace(/\/+$/, "");
|
||||
|
||||
const TARGETS: Record<string, Target | undefined> = {
|
||||
prod: PROD_BASE_URL
|
||||
? { name: "prod", url: `${PROD_BASE_URL}/api/version` }
|
||||
: undefined,
|
||||
demo: { name: "demo", url: "https://demo.healthlog.dev/api/version" },
|
||||
};
|
||||
|
||||
@@ -140,14 +151,26 @@ async function main(): Promise<void> {
|
||||
|
||||
let selected: Target[];
|
||||
if (only) {
|
||||
if (!(only in TARGETS)) {
|
||||
console.error(`unknown --only target: ${only} (use prod or demo)`);
|
||||
process.exit(2);
|
||||
}
|
||||
const picked = TARGETS[only];
|
||||
if (!picked) {
|
||||
console.error(`unknown --only target: ${only} (use prod or demo)`);
|
||||
console.error(
|
||||
`target "${only}" needs HEALTHLOG_PROD_URL set to the instance base URL (e.g. https://healthlog.example.com)`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
selected = [picked];
|
||||
} else {
|
||||
selected = Object.values(TARGETS);
|
||||
if (!TARGETS.prod) {
|
||||
console.error(
|
||||
"HEALTHLOG_PROD_URL is not set — set it to the instance base URL (e.g. https://healthlog.example.com) or use --only=demo",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
selected = Object.values(TARGETS).filter((t): t is Target => Boolean(t));
|
||||
}
|
||||
|
||||
console.log(
|
||||
|
||||
@@ -87,6 +87,10 @@
|
||||
{
|
||||
"name": "DEPLOY_WEBHOOK_SECRET",
|
||||
"purpose": "Shared secret for the X-Deploy-Webhook-Secret header on POST /api/internal/deploy-webhook. 64 hex chars."
|
||||
},
|
||||
{
|
||||
"name": "DEPLOY_LOGS_URL",
|
||||
"purpose": "URL of the deployment dashboard (e.g. the Coolify UI) linked in deploy-failure notifications. Optional; when unset the notification omits the link."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
* table in `src/lib/openapi/registry.ts`.
|
||||
*
|
||||
* v1.4.23 baseline: only the iOS-touched routes are registered (see
|
||||
* `src/lib/openapi/routes.ts`). The CI step that wraps this script runs
|
||||
* `src/lib/openapi/routes/`). The CI step that wraps this script runs
|
||||
* with `continue-on-error: true` until the registry catches up with the
|
||||
* rest of the hand-maintained `docs/api/openapi.yaml` — once coverage is
|
||||
* complete, the gate flips to hard-fail in v1.4.24+.
|
||||
*
|
||||
* The output is stable across re-runs because the upstream order is
|
||||
* itself deterministic: Zod preserves schema property declaration
|
||||
* order and the path table is a single object literal in
|
||||
* `src/lib/openapi/routes.ts`. `yaml@2`'s `sortMapEntries` option is
|
||||
* order and the path table is assembled with a fixed spread order in
|
||||
* `src/lib/openapi/routes/index.ts`. `yaml@2`'s `sortMapEntries` option is
|
||||
* intentionally NOT set — when enabled, the emitter reorders keys
|
||||
* alphabetically and can place an `*alias` reference before its
|
||||
* `&anchor` definition, which triggers `verifyAliasOrder` and throws
|
||||
|
||||
@@ -23,6 +23,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = join(__dirname, "..");
|
||||
const pkgPath = join(repoRoot, "package.json");
|
||||
const outPath = join(repoRoot, "public", "sw-version.js");
|
||||
const swPath = join(repoRoot, "public", "sw.js");
|
||||
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
||||
const version = `v${pkg.version}`;
|
||||
@@ -33,5 +34,23 @@ const body = `self.__APP_VERSION__ = ${JSON.stringify(version)};\n`;
|
||||
|
||||
writeFileSync(outPath, banner + body, "utf8");
|
||||
|
||||
// Also re-anchor the in-`sw.js` fallback literal to the current version, so
|
||||
// the literal used when `/sw-version.js` is missing (dev, or a 404 on the
|
||||
// generated file) can never drift stale — it bit v1.4.38.4 → v1.4.42. The
|
||||
// `/* @sw-version-fallback */` marker is the rewrite anchor; the literal that
|
||||
// follows it is replaced in place. If the marker is gone the SW source has
|
||||
// been restructured and the build must surface that rather than silently skip
|
||||
// the re-anchor.
|
||||
const sw = readFileSync(swPath, "utf8");
|
||||
const fallbackPattern = /(\/\* @sw-version-fallback \*\/\s*)"v[^"]*"/;
|
||||
if (!fallbackPattern.test(sw)) {
|
||||
process.stderr.write(
|
||||
"generate-sw-version: @sw-version-fallback marker not found in public/sw.js\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const rewritten = sw.replace(fallbackPattern, `$1${JSON.stringify(version)}`);
|
||||
if (rewritten !== sw) writeFileSync(swPath, rewritten, "utf8");
|
||||
|
||||
// One-line log so the build output shows the version we baked in.
|
||||
process.stdout.write(`sw-version.js → ${version}\n`);
|
||||
process.stdout.write(`sw-version.js → ${version} (sw.js fallback re-anchored)\n`);
|
||||
@@ -0,0 +1,156 @@
|
||||
# migrate-demo-edge01.sh
|
||||
|
||||
Copies **exactly one** user — the demo account
|
||||
`usr_demo_cf31025295714ece8d91f5af13afd76d` — from the production apps01
|
||||
Postgres to the **separate** edge01 demo Postgres. No other user's rows are
|
||||
transferred under any circumstance.
|
||||
|
||||
> Review the script first, then run it deliberately. It performs destructive,
|
||||
> demo-scoped deletes on the edge01 demo DB, then loads the apps01 demo slice.
|
||||
> It never writes to apps01 (source is read-only).
|
||||
|
||||
## How it connects
|
||||
|
||||
- **Source (apps01):** SSH alias `apps-01`, container `db-pg8wggwogo8c4gc4ks0kk4ss`
|
||||
(stable), psql `healthlog`/`healthlog`.
|
||||
- **Target (edge01):** SSH alias `edge-01`. The demo Postgres is a separate
|
||||
Coolify resource whose container name is **not hardcoded** — the script
|
||||
resolves it at runtime via `docker ps | grep -iE 'postgres|^db-'` and aborts
|
||||
if it cannot pick a single match (override with `DST_DB_CONTAINER=db-xxxx`).
|
||||
The demo **app** uuid is `ck8cs4osswg8w440gskw08w8`; match its linked
|
||||
Postgres resource, not the docs/landing DB.
|
||||
- **Edge demo user id is resolved at runtime** by `username='demo'` (it may
|
||||
differ from the apps01 id). The wipe phase is scoped to that resolved id; the
|
||||
load then brings the apps01 row in, so afterwards the edge01 demo user
|
||||
carries the apps01 canonical id.
|
||||
|
||||
Each table is transferred with a scoped, server-side `\copy ... TO STDOUT`
|
||||
piped host-to-host into `\copy ... FROM STDIN`, using **explicit column lists
|
||||
on both ends** so secret columns can be NULLed and column order is pinned.
|
||||
|
||||
## What moves (FK-parent-first, 20 tables)
|
||||
|
||||
| # | Table | Scope predicate |
|
||||
|---|---|---|
|
||||
| 1 | `users` | `id = demo` (the one row; secrets NULLed, `role='ADMIN'`) |
|
||||
| 2 | `measurements` | `user_id = demo AND deleted_at IS NULL` (incl. RECOVERY/STRAIN/STRESS_SCORE rows) |
|
||||
| 3 | `cycle_profiles` | `user_id = demo` |
|
||||
| 4 | `menstrual_cycles` | `user_id = demo` |
|
||||
| 5 | `cycle_day_logs` | `user_id = demo` (encrypted cols NULLed) |
|
||||
| 6 | `cycle_symptom_links` | `day_log_id IN (demo's day logs)` |
|
||||
| 7 | `mood_entries` | `user_id = demo AND deleted_at IS NULL` |
|
||||
| 8 | `mood_entry_tag_links` | `mood_entry_id IN (demo's entries)` — global tags only |
|
||||
| 9 | `medications` | `user_id = demo` |
|
||||
| 10 | `medication_schedules` | `medication_id IN (demo's meds)` |
|
||||
| 11 | `medication_intake_events` | `user_id = demo AND deleted_at IS NULL` |
|
||||
| 12 | `medication_dose_changes` | `medication_id IN (demo's meds)` |
|
||||
| 13 | `medication_inventory_items` | `user_id = demo` |
|
||||
| 14 | `medication_inventory_events` | `medication_id IN (demo's meds)` |
|
||||
| 15 | `medication_side_effects` | `user_id = demo` |
|
||||
| 16 | `reminder_phase_configs` | `medication_id IN (demo's meds)` |
|
||||
| 17 | `personal_records` | `user_id = demo` |
|
||||
| 18 | `user_achievements` | `user_id = demo` |
|
||||
| 19 | `consent_receipts` | `user_id = demo` (carries the `ai_full` consent) |
|
||||
| 20 | `audit_logs` | `user_id = demo AND action LIKE 'insights.%'` (plaintext AI assessments) |
|
||||
|
||||
## Secret / credential columns NULLed on the `users` row
|
||||
|
||||
`codex_access_token_encrypted`, `codex_refresh_token_encrypted`,
|
||||
`codex_token_expires_at`, `codex_connected_at` (and `codex_connection_status`
|
||||
reset to `'disconnected'`), `ai_anthropic_key_encrypted`,
|
||||
`ai_local_key_encrypted`, `ai_openai_key_encrypted`,
|
||||
`withings_client_id_encrypted`, `withings_client_secret_encrypted`,
|
||||
`whoop_client_id_encrypted`, `whoop_client_secret_encrypted`,
|
||||
`fitbit_client_id_encrypted`, `fitbit_client_secret_encrypted`,
|
||||
`telegram_bot_token`, `telegram_chat_id`, `mood_log_url_encrypted`,
|
||||
`mood_log_api_key_encrypted`, `mood_log_webhook_secret`,
|
||||
`insurance_number_encrypted`, `insurer_name`, `insurer_ik_number`.
|
||||
|
||||
Forced flags: `telegram_enabled=false`, `mood_log_enabled=false`,
|
||||
`role='ADMIN'`.
|
||||
|
||||
**Kept:** `password_hash` (demo login works), `avatar_bytes` /
|
||||
`avatar_content_type` / `avatar_updated_at` (profile photo), and all
|
||||
display / preference / layout JSON (`thresholds_json`,
|
||||
`dashboard_widgets_json`, `insights_layout_json`, `coach_prefs_json`,
|
||||
`source_priority_json`, `doctor_report_prefs_json`, `notification_prefs`,
|
||||
`healthkit_config_json`, unit prefs, `height_cm`, `gender`, `timezone`,
|
||||
`locale`, `date_of_birth`, `display_name`, onboarding state, research-mode
|
||||
acknowledgement).
|
||||
|
||||
## What is excluded — and why
|
||||
|
||||
- **Auth / device / push** (sessions, passkeys, auth_challenges, api_tokens,
|
||||
refresh_tokens, devices, push_subscriptions, push_attempts,
|
||||
notification_channels, notification_preferences) — per-environment, never
|
||||
portable.
|
||||
- **Integration / OAuth** (withings/whoop/fitbit connections, all
|
||||
`*_oauth_states` / `*_connect_tickets`, telegram scheduled-deletions +
|
||||
reminder messages, clinician_share_links, provider_health) — tied to live
|
||||
credentials.
|
||||
- **Encrypted-at-rest payloads we can't decrypt on edge01:**
|
||||
`insight_narratives` and all `coach_*` (coach_conversations, coach_messages,
|
||||
coach_facts, coach_usage). The edge01 instance does not hold the prod
|
||||
encryption key, so the AES-256-GCM ciphertext would be unreadable. The same
|
||||
reason drives NULLing `cycle_day_logs.sensitive_encrypted` /
|
||||
`notes_encrypted` while still carrying that table's plaintext cycle fields.
|
||||
The plaintext AI assessment text is preserved through the `insights.%`
|
||||
`audit_logs` rows (the text lives in the `details` JSON) instead.
|
||||
- **Transient / regenerable:** idempotency_keys, rate_limits, data_backups,
|
||||
import_jobs, host_metrics, feedback, recommendation_feedback, and all rollup
|
||||
tables (measurement_rollups, mood_entry_rollups,
|
||||
medication_compliance_rollups, strain_trimp_cache, cycle_predictions) — these
|
||||
rebuild on edge01.
|
||||
- **No rows:** workouts / workout_routes / workout_samples (demo has none).
|
||||
- **Instance / global:** app_settings; global catalogues (mood_tags,
|
||||
cycle_symptoms) — these pre-exist on edge01 and the carried link rows
|
||||
reference the global ids only.
|
||||
|
||||
## Encryption note
|
||||
|
||||
The edge01 demo instance is assumed **not** to share the apps01 encryption
|
||||
key. Every column whose value is AES-256-GCM ciphertext under the prod key is
|
||||
either NULLed (secrets on `users`, `cycle_day_logs.*_encrypted`) or skipped
|
||||
entirely (`insight_narratives`, `coach_messages.encryptedContent`). Carrying
|
||||
that ciphertext would produce undecryptable rows that the app's fail-closed
|
||||
loader would reject.
|
||||
|
||||
## role = ADMIN + DEMO_MODE expectations
|
||||
|
||||
The demo user is loaded with `role='ADMIN'` so the admin surfaces are
|
||||
viewable in the demo. This is **only safe because DEMO_MODE must stay ON** on
|
||||
edge01 — the proxy layer (`src/proxy.ts`) blocks every mutating request in
|
||||
demo mode, so an admin demo session can browse admin pages but cannot change
|
||||
anything. If DEMO_MODE were ever off on edge01, this admin demo account would
|
||||
be a write-capable admin. Keep DEMO_MODE enabled.
|
||||
|
||||
## Safety mechanisms built into the script
|
||||
|
||||
- `set -euo pipefail` and `ON_ERROR_STOP=1` on every psql call.
|
||||
- **Pre-flight guard (apps01):** for each carried set, a query that must
|
||||
return `0` foreign-owned rows — aborts on any non-zero. Includes a check
|
||||
that the demo owns **0 custom mood_tags** (so every carried
|
||||
`mood_entry_tag_link` points at a global tag that pre-exists on edge01) plus
|
||||
a defensive "non-global tag" link count.
|
||||
- **Scoped wipe-first (edge01):** FK-child-first deletes, every one scoped to
|
||||
the runtime-resolved edge01 demo id. No unscoped delete anywhere. Refuses to
|
||||
proceed if `username='demo'` on edge01 resolves to the operator id.
|
||||
- **Post-load verification (edge01):** per-table demo row counts; a hard
|
||||
assertion that the operator id `cmlupy4tn000001rpzx1pxvz7` appears in zero
|
||||
rows; a check that user_id-bearing carried tables hold only the demo id; and
|
||||
a confirmation the demo user exists with `role=ADMIN`.
|
||||
|
||||
## Caveats / things to confirm before running
|
||||
|
||||
- **`FOREIGN_HITS` assertion** counts *all* non-demo rows in the carried
|
||||
tables on edge01, not just what this run inserted. On a clean
|
||||
single-demo-user DB it is `0`. If edge01 legitimately hosts other demo-only
|
||||
accounts, treat a non-zero value as a warning to inspect, not an automatic
|
||||
failure (the script prints it as a WARNING, not an abort).
|
||||
- Confirm edge01 psql credentials are `healthlog`/`healthlog` (the script
|
||||
verifies connectivity before any write; adjust `PG_USER` / `PG_DB` if the
|
||||
demo Postgres resource uses different ones).
|
||||
- The script assumes edge01's schema is at the same (or a compatible) Prisma
|
||||
migration level as apps01. A column present on apps01 but missing on edge01
|
||||
fails the `\copy` loudly rather than silently corrupting data — run
|
||||
migrations on edge01 first if the two have drifted.
|
||||
Executable
+661
@@ -0,0 +1,661 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# migrate-demo-edge01.sh — copy EXACTLY ONE user (the demo account) from the
|
||||
# production apps01 Postgres to the SEPARATE edge01 demo Postgres.
|
||||
#
|
||||
# ============================================================================
|
||||
# WHAT THIS SCRIPT DOES (and the isolation guarantees it makes)
|
||||
# ============================================================================
|
||||
#
|
||||
# It transfers a single, demo-scoped slice of health data so the public demo
|
||||
# instance on edge01 shows a realistic-looking account WITHOUT carrying any
|
||||
# real-operator data. The ONLY user copied is:
|
||||
#
|
||||
# $DEMO_USER_ID (the single demo account, supplied via env)
|
||||
#
|
||||
# ABSOLUTE INVARIANTS enforced by this script:
|
||||
# 1. ZERO rows from any other user are transferred. Every source SELECT is
|
||||
# scoped to the demo user id (directly via user_id, or join-scoped via a
|
||||
# child FK that resolves back to the demo's own parent rows). A PRE-FLIGHT
|
||||
# guard counts any row in each carried set whose ownership resolves to a
|
||||
# user other than the demo id and ABORTS if the count is non-zero.
|
||||
# 2. A protected account id ($PROTECT_USER_ID, e.g. the operator's real
|
||||
# account) must NEVER appear in any transferred row. A POST-LOAD verification
|
||||
# asserts that id is absent from every carried table, and that every
|
||||
# user_id-bearing carried table contains ONLY the demo id.
|
||||
# 3. No secret / credential column is transferred. The users SELECT NULLs
|
||||
# every encrypted / token / integration-secret column explicitly. The
|
||||
# cycle-day-log SELECT NULLs the two app-encrypted columns (they are
|
||||
# keyed to the prod encryption key, which is NOT present on edge01).
|
||||
#
|
||||
# The transfer mechanism per table is a scoped server-side COPY piped
|
||||
# host-to-host:
|
||||
#
|
||||
# ssh "$SRC_SSH" "<psql> -c \"\\copy (SELECT <cols> FROM <t> WHERE <scope>)
|
||||
# TO STDOUT (FORMAT csv, HEADER false)\""
|
||||
# | ssh "$DST_SSH" "<psql> -c \"\\copy <t>(<cols>)
|
||||
# FROM STDIN (FORMAT csv, HEADER false)\""
|
||||
#
|
||||
# Explicit column lists are used on BOTH ends so:
|
||||
# - secret columns can be omitted / NULLed,
|
||||
# - column order is pinned (independent of physical table layout),
|
||||
# - an added/dropped column on either side fails loudly rather than
|
||||
# silently shifting data into the wrong column.
|
||||
#
|
||||
# Tables load in FK-parent-first order. The edge01 demo rows are wiped first
|
||||
# (FK-child-first), scoped to the edge01 demo user id resolved at runtime.
|
||||
#
|
||||
# THIS SCRIPT IS FOR REVIEW. Read it, then run it deliberately. It performs
|
||||
# DESTRUCTIVE deletes on the edge01 demo DB (scoped to the demo user) followed
|
||||
# by inserts. It NEVER writes to apps01 (source is read-only).
|
||||
#
|
||||
# ----------------------------------------------------------------------------
|
||||
# EXCLUDED ENTIRELY (intentionally NOT copied)
|
||||
# ----------------------------------------------------------------------------
|
||||
# - Auth / session / device surfaces: sessions, passkeys, auth_challenges,
|
||||
# api_tokens, refresh_tokens, devices, push_subscriptions, push_attempts,
|
||||
# notification_channels, notification_preferences.
|
||||
# - Integration / OAuth: withings_connections, whoop_connections,
|
||||
# fitbit_connections, all *_oauth_states / *_connect_tickets,
|
||||
# telegram_scheduled_deletions, telegram_reminder_messages,
|
||||
# clinician_share_links, provider_health.
|
||||
# - Encrypted-at-rest payloads we cannot decrypt on edge01:
|
||||
# insight_narratives, coach_messages (coach_* entirely).
|
||||
# - Coach: coach_conversations, coach_messages, coach_facts, coach_usage.
|
||||
# - Ops / transient / regenerable: idempotency_keys, rate_limits,
|
||||
# data_backups, import_jobs, host_metrics, feedback,
|
||||
# recommendation_feedback, all rollup tables (measurement_rollups,
|
||||
# mood_entry_rollups, medication_compliance_rollups, strain_trimp_cache,
|
||||
# cycle_predictions) — these REGENERATE on edge01.
|
||||
# - workouts / workout_routes / workout_samples — demo has none.
|
||||
# - app_settings — instance-level, not user data.
|
||||
# - Global catalogues (mood_tags, cycle_symptoms) — pre-exist on edge01;
|
||||
# carried link rows reference these GLOBAL ids only (see guards below).
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# ============================================================================
|
||||
# CONFIG
|
||||
# ============================================================================
|
||||
# All instance-specific values come from the environment at runtime — NO real
|
||||
# account ids, host aliases, or container identifiers are baked into this file
|
||||
# (it lives in a public repo; see the project's voice/privacy rules).
|
||||
# DEMO_USER_ID — the single user id to migrate (required)
|
||||
# PROTECT_USER_ID — an id that must NEVER appear in the result (optional guard;
|
||||
# e.g. the operator's real account). Post-load asserts absence.
|
||||
# SRC_SSH / DST_SSH — ssh aliases for the source + target hosts (required)
|
||||
# SRC_DB_CONTAINER_PATTERN — grep pattern to resolve the source DB container (required)
|
||||
# DST_DB_CONTAINER — target DB container (optional; auto-resolved if unset)
|
||||
readonly DEMO_USER_ID="${DEMO_USER_ID:?set DEMO_USER_ID to the user id to migrate}"
|
||||
readonly REAL_USER_ID="${PROTECT_USER_ID:-__no_protect_id__}"
|
||||
|
||||
readonly SRC_SSH="${SRC_SSH:?set SRC_SSH to the source host ssh alias}"
|
||||
readonly DST_SSH="${DST_SSH:?set DST_SSH to the target host ssh alias}"
|
||||
|
||||
# Source DB container — resolved dynamically from SRC_DB_CONTAINER_PATTERN (the
|
||||
# Coolify container name carries a rotating suffix, e.g. db-<uuid>-<digits>).
|
||||
readonly SRC_DB_CONTAINER_PATTERN="${SRC_DB_CONTAINER_PATTERN:?set SRC_DB_CONTAINER_PATTERN (a grep -E pattern for the source DB container name)}"
|
||||
SRC_DB_CONTAINER="$(ssh "$SRC_SSH" "docker ps --format '{{.Names}}' | grep -E '$SRC_DB_CONTAINER_PATTERN' | head -1" 2>/dev/null | tr -d '[:space:]')"
|
||||
if [[ -z "$SRC_DB_CONTAINER" ]]; then echo "ABORT: could not resolve source DB container (pattern: $SRC_DB_CONTAINER_PATTERN)."; exit 1; fi
|
||||
readonly SRC_DB_CONTAINER
|
||||
|
||||
readonly PG_USER="healthlog"
|
||||
readonly PG_DB="healthlog"
|
||||
|
||||
# psql invocation builders. -v ON_ERROR_STOP=1 makes any SQL error a non-zero
|
||||
# exit so `set -e` aborts the pipeline. -X skips ~/.psqlrc.
|
||||
psql_src() {
|
||||
# Usage: psql_src "<sql>"
|
||||
ssh "$SRC_SSH" "docker exec -i $SRC_DB_CONTAINER psql -X -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB $*"
|
||||
}
|
||||
|
||||
# DST container name is resolved at runtime into DST_DB_CONTAINER (see below),
|
||||
# so psql_dst is defined after resolution.
|
||||
|
||||
echo "=============================================================="
|
||||
echo " HealthLog demo-user migration apps01 -> edge01"
|
||||
echo " demo user : $DEMO_USER_ID"
|
||||
echo " src host : $SRC_SSH (container $SRC_DB_CONTAINER)"
|
||||
echo " dst host : $DST_SSH (container resolved below)"
|
||||
echo "=============================================================="
|
||||
|
||||
# ============================================================================
|
||||
# RESOLVE the edge01 demo DB container (NOT hardcoded — it is a separate
|
||||
# Coolify Postgres resource whose container name can change on redeploy).
|
||||
# ============================================================================
|
||||
echo
|
||||
if [[ -n "${DST_DB_CONTAINER:-}" ]]; then
|
||||
echo "## Using DST_DB_CONTAINER from env: $DST_DB_CONTAINER"
|
||||
else
|
||||
echo "## Resolving edge01 demo Postgres container ..."
|
||||
DST_DB_CONTAINER="$(ssh "$DST_SSH" "docker ps --format '{{.Names}}' | grep -iE 'postgres|^db-' || true")"
|
||||
echo "candidate containers on $DST_SSH:"
|
||||
echo "$DST_DB_CONTAINER" | sed 's/^/ /'
|
||||
# Only the demo Postgres is the correct target. Pick the single match or abort.
|
||||
DST_DB_CONTAINER_COUNT="$(printf '%s\n' "$DST_DB_CONTAINER" | grep -c . || true)"
|
||||
if [[ "$DST_DB_CONTAINER_COUNT" -ne 1 ]]; then
|
||||
echo
|
||||
echo "ABORT: expected exactly one Postgres container on $DST_SSH, found $DST_DB_CONTAINER_COUNT."
|
||||
echo " Set DST_DB_CONTAINER explicitly and re-run, e.g.:"
|
||||
echo " DST_DB_CONTAINER=db-xxxx $0"
|
||||
exit 1
|
||||
fi
|
||||
DST_DB_CONTAINER="$(printf '%s\n' "$DST_DB_CONTAINER" | head -n1)"
|
||||
fi
|
||||
readonly DST_DB_CONTAINER
|
||||
echo "resolved edge01 demo DB container: $DST_DB_CONTAINER"
|
||||
|
||||
# Confirm the psql user/db actually work on the resolved container before we
|
||||
# touch anything (fail fast on a wrong container guess).
|
||||
psql_dst() {
|
||||
# Usage: psql_dst "<sql>"
|
||||
ssh "$DST_SSH" "docker exec -i $DST_DB_CONTAINER psql -X -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB $*"
|
||||
}
|
||||
echo "## Verifying edge01 DB connectivity ..."
|
||||
psql_dst "-c 'SELECT current_database(), current_user;'"
|
||||
|
||||
# ============================================================================
|
||||
# RESOLVE the edge01 demo user id (it MAY differ from the apps01 id!).
|
||||
# We resolve by username='demo'. After this migration the edge01 demo user
|
||||
# will carry the apps01 canonical id (we delete the old row + load the apps01
|
||||
# row). Scope EVERY wipe to whatever the edge01 id is right now.
|
||||
# ============================================================================
|
||||
echo
|
||||
echo "## Resolving edge01 demo user id (by username='demo') ..."
|
||||
EDGE_DEMO_ID="$(psql_dst "-tA -c \"SELECT id FROM users WHERE username = 'demo';\"" | tr -d '[:space:]')"
|
||||
if [[ -z "$EDGE_DEMO_ID" ]]; then
|
||||
echo "note: no existing demo user on edge01 (username='demo') — wipe phase is a no-op."
|
||||
else
|
||||
echo "edge01 existing demo user id: $EDGE_DEMO_ID"
|
||||
if [[ "$EDGE_DEMO_ID" == "$REAL_USER_ID" ]]; then
|
||||
echo "ABORT: edge01 username='demo' resolves to the REAL operator id. Refusing."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
readonly EDGE_DEMO_ID
|
||||
|
||||
# ============================================================================
|
||||
# PRE-FLIGHT GUARD (on apps01 source): assert every carried set is demo-only.
|
||||
# Each query MUST return 0. A non-zero result means a scope predicate is wrong
|
||||
# and we would leak another user's rows — ABORT immediately.
|
||||
# ============================================================================
|
||||
echo
|
||||
echo "## PRE-FLIGHT guard: assert source SELECTs are demo-scoped (each must be 0)"
|
||||
|
||||
preflight() {
|
||||
local label="$1" sql="$2"
|
||||
local n
|
||||
n="$(psql_src "-tA -c \"$sql\"" | tr -d '[:space:]')"
|
||||
printf ' %-40s leaked-rows=%s\n' "$label" "$n"
|
||||
if [[ "$n" != "0" ]]; then
|
||||
echo "ABORT: pre-flight guard '$label' returned $n rows owned by a non-demo user."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# user_id-bearing tables: assert the scoped set contains no foreign user_id.
|
||||
# (The predicate is the same one the COPY SELECT uses; this is a self-check
|
||||
# that the scope expression cannot admit a non-demo row.)
|
||||
preflight "measurements" "SELECT count(*) FROM measurements WHERE deleted_at IS NULL AND user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
|
||||
preflight "cycle_profiles" "SELECT count(*) FROM cycle_profiles WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
|
||||
preflight "menstrual_cycles" "SELECT count(*) FROM menstrual_cycles WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
|
||||
preflight "cycle_day_logs" "SELECT count(*) FROM cycle_day_logs WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
|
||||
preflight "mood_entries" "SELECT count(*) FROM mood_entries WHERE deleted_at IS NULL AND user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
|
||||
preflight "medications" "SELECT count(*) FROM medications WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
|
||||
preflight "medication_intake_events" "SELECT count(*) FROM medication_intake_events WHERE deleted_at IS NULL AND user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
|
||||
preflight "medication_side_effects" "SELECT count(*) FROM medication_side_effects WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
|
||||
preflight "medication_inventory_items" "SELECT count(*) FROM medication_inventory_items WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
|
||||
preflight "personal_records" "SELECT count(*) FROM personal_records WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
|
||||
preflight "user_achievements" "SELECT count(*) FROM user_achievements WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
|
||||
preflight "consent_receipts" "SELECT count(*) FROM consent_receipts WHERE user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
|
||||
preflight "audit_logs(insights)" "SELECT count(*) FROM audit_logs WHERE action LIKE 'insights.%' AND user_id = '$DEMO_USER_ID' AND user_id <> '$DEMO_USER_ID';"
|
||||
|
||||
# Join-scoped child tables: assert the child's parent resolves to the demo user
|
||||
# (i.e. no child row in the carried set whose parent is owned by another user).
|
||||
preflight "medication_schedules(join)" \
|
||||
"SELECT count(*) FROM medication_schedules s JOIN medications m ON m.id = s.medication_id WHERE m.user_id = '$DEMO_USER_ID' AND m.user_id <> '$DEMO_USER_ID';"
|
||||
preflight "medication_dose_changes(join)" \
|
||||
"SELECT count(*) FROM medication_dose_changes d JOIN medications m ON m.id = d.medication_id WHERE m.user_id = '$DEMO_USER_ID' AND m.user_id <> '$DEMO_USER_ID';"
|
||||
preflight "medication_inventory_events(join)" \
|
||||
"SELECT count(*) FROM medication_inventory_events e JOIN medications m ON m.id = e.medication_id WHERE m.user_id = '$DEMO_USER_ID' AND m.user_id <> '$DEMO_USER_ID';"
|
||||
preflight "reminder_phase_configs(join)" \
|
||||
"SELECT count(*) FROM reminder_phase_configs c JOIN medications m ON m.id = c.medication_id WHERE m.user_id = '$DEMO_USER_ID' AND m.user_id <> '$DEMO_USER_ID';"
|
||||
preflight "cycle_symptom_links(join)" \
|
||||
"SELECT count(*) FROM cycle_symptom_links l JOIN cycle_day_logs d ON d.id = l.day_log_id WHERE d.user_id = '$DEMO_USER_ID' AND d.user_id <> '$DEMO_USER_ID';"
|
||||
preflight "mood_entry_tag_links(join)" \
|
||||
"SELECT count(*) FROM mood_entry_tag_links l JOIN mood_entries e ON e.id = l.mood_entry_id WHERE e.user_id = '$DEMO_USER_ID' AND e.user_id <> '$DEMO_USER_ID';"
|
||||
|
||||
# GUARD: mood_entry_tag_links must reference ONLY global mood_tags (user_id IS
|
||||
# NULL on the tag). The demo is asserted to have 0 custom mood_tags; if a link
|
||||
# referenced a per-user tag id, the FK would break on edge01 (that tag row is
|
||||
# not carried). Custom-tag count MUST be 0.
|
||||
DEMO_CUSTOM_MOODTAGS="$(psql_src "-tA -c \"SELECT count(*) FROM mood_tags WHERE user_id = '$DEMO_USER_ID';\"" | tr -d '[:space:]')"
|
||||
echo " demo custom mood_tags = $DEMO_CUSTOM_MOODTAGS (must be 0 — all links point to GLOBAL tags)"
|
||||
if [[ "$DEMO_CUSTOM_MOODTAGS" != "0" ]]; then
|
||||
echo "ABORT: demo user owns $DEMO_CUSTOM_MOODTAGS custom mood_tags. mood_entry_tag_links"
|
||||
echo " would reference non-global tag ids that are NOT carried -> FK break on edge01."
|
||||
echo " Carry mood_tags (user-scoped) before mood_entry_tag_links, or skip the links."
|
||||
exit 1
|
||||
fi
|
||||
# Defensive: assert every carried link points at a GLOBAL tag (user_id IS NULL).
|
||||
preflight "mood_entry_tag_links(non-global tag)" \
|
||||
"SELECT count(*) FROM mood_entry_tag_links l JOIN mood_entries e ON e.id = l.mood_entry_id JOIN mood_tags t ON t.id = l.mood_tag_id WHERE e.user_id = '$DEMO_USER_ID' AND t.user_id IS NOT NULL;"
|
||||
|
||||
echo "## PRE-FLIGHT guard passed — every carried set is demo-only."
|
||||
|
||||
# ============================================================================
|
||||
# WIPE-FIRST on edge01 (idempotent, demo-scoped ONLY). FK-child-first order.
|
||||
# Every DELETE is scoped to the resolved edge01 demo user id ($EDGE_DEMO_ID)
|
||||
# or via a join back to it. NEVER an unscoped DELETE. Skipped entirely if no
|
||||
# existing demo user was found.
|
||||
# ============================================================================
|
||||
echo
|
||||
if [[ -n "$EDGE_DEMO_ID" ]]; then
|
||||
echo "## WIPE edge01 demo rows (scoped to $EDGE_DEMO_ID), FK-child-first ..."
|
||||
# NOTE: edge01 FKs are ON DELETE CASCADE for most child rows; the explicit
|
||||
# child-first deletes below are belt-and-braces (some child tables hang off
|
||||
# medications/mood_entries, not the user) and keep the operation transparent.
|
||||
psql_dst <<SQL
|
||||
\set ON_ERROR_STOP on
|
||||
BEGIN;
|
||||
-- join-scoped children of the demo's medications
|
||||
DELETE FROM reminder_phase_configs WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$EDGE_DEMO_ID');
|
||||
DELETE FROM medication_inventory_events WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$EDGE_DEMO_ID');
|
||||
DELETE FROM medication_dose_changes WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$EDGE_DEMO_ID');
|
||||
DELETE FROM medication_schedules WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$EDGE_DEMO_ID');
|
||||
-- join-scoped children of the demo's mood_entries / cycle_day_logs
|
||||
DELETE FROM mood_entry_tag_links WHERE mood_entry_id IN (SELECT id FROM mood_entries WHERE user_id = '$EDGE_DEMO_ID');
|
||||
DELETE FROM cycle_symptom_links WHERE day_log_id IN (SELECT id FROM cycle_day_logs WHERE user_id = '$EDGE_DEMO_ID');
|
||||
-- user_id-scoped tables
|
||||
DELETE FROM medication_inventory_items WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM medication_side_effects WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM medication_intake_events WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM medications WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM mood_entries WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM cycle_day_logs WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM menstrual_cycles WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM cycle_profiles WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM personal_records WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM user_achievements WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM consent_receipts WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM audit_logs WHERE user_id = '$EDGE_DEMO_ID';
|
||||
-- regenerable / transient tables that may carry FK rows blocking the user delete
|
||||
DELETE FROM measurement_rollups WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM mood_entry_rollups WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM medication_compliance_rollups WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM strain_trimp_cache WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM cycle_predictions WHERE user_id = '$EDGE_DEMO_ID';
|
||||
DELETE FROM measurements WHERE user_id = '$EDGE_DEMO_ID';
|
||||
-- finally the user row itself (any remaining ON DELETE CASCADE children of
|
||||
-- the EXCLUDED set — sessions, tokens, etc. — go with it).
|
||||
DELETE FROM users WHERE id = '$EDGE_DEMO_ID';
|
||||
COMMIT;
|
||||
SQL
|
||||
echo "## WIPE complete."
|
||||
else
|
||||
echo "## WIPE skipped — no existing edge01 demo user."
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# COLUMN LISTS
|
||||
# ============================================================================
|
||||
#
|
||||
# users — explicit ordered list. Secret/credential columns are NULLed in the
|
||||
# SELECT (literal NULL in the right position). role forced to 'ADMIN'.
|
||||
# telegram_enabled / mood_log_enabled forced false. Kept: password_hash,
|
||||
# avatar_*, all display/prefs/layout JSON, unit prefs, profile fields.
|
||||
#
|
||||
# The SELECT projects literals for the NULLed/forced columns and the real
|
||||
# value for everything else; the COPY target column list matches 1:1.
|
||||
#
|
||||
readonly USERS_COLS="id, username, email, password_hash, role, created_at, updated_at,
|
||||
height_cm, date_of_birth, gender, timezone,
|
||||
codex_access_token_encrypted, codex_refresh_token_encrypted, codex_token_expires_at,
|
||||
codex_connected_at, codex_connection_status,
|
||||
insights_privacy_mode, insights_cached_at, insights_cached_text, insights_exclude_metrics,
|
||||
global_excluded_injection_sites,
|
||||
telegram_bot_token, telegram_chat_id, telegram_enabled,
|
||||
withings_client_id_encrypted, withings_client_secret_encrypted,
|
||||
whoop_client_id_encrypted, whoop_client_secret_encrypted,
|
||||
fitbit_client_id_encrypted, fitbit_client_secret_encrypted,
|
||||
mood_log_url_encrypted, mood_log_api_key_encrypted, mood_log_enabled,
|
||||
mood_log_last_synced_at, mood_log_webhook_secret,
|
||||
locale, thresholds_json, dashboard_widgets_json, insights_layout_json,
|
||||
glucose_unit, unit_preference,
|
||||
ai_provider, ai_model, ai_base_url, ai_anthropic_key_encrypted, ai_local_key_encrypted,
|
||||
ai_openai_key_encrypted, ai_provider_chain,
|
||||
onboarding_completed_at, onboarding_step, onboarding_tour_completed,
|
||||
healthkit_config_json, healthkit_last_synced_at,
|
||||
display_name, last_report_practice_name, coach_prefs_json, source_priority_json,
|
||||
research_mode_enabled, research_mode_acknowledged_at, research_mode_acknowledged_version,
|
||||
last_synced_at, mood_reminder_enabled, doctor_report_prefs_json, notification_prefs,
|
||||
disable_coach, avatar_bytes, avatar_content_type, avatar_updated_at,
|
||||
full_name, insurer_name, insurance_number_encrypted, insurer_ik_number"
|
||||
|
||||
# The SELECT expression list MUST be in the SAME order as USERS_COLS.
|
||||
# Literals: NULL for every secret column, 'ADMIN' for role, false for the two
|
||||
# integration-enable flags.
|
||||
# NOTE: no inline SQL comments here — this expression is flattened into a
|
||||
# single-line psql -c command, where a -- comment would swallow the rest of
|
||||
# the line (the closing paren + TO STDOUT). Keep it comment-free.
|
||||
readonly USERS_SELECT="id, username, email, password_hash,
|
||||
'ADMIN' AS role,
|
||||
created_at, updated_at,
|
||||
height_cm, date_of_birth, gender, timezone,
|
||||
NULL::text AS codex_access_token_encrypted,
|
||||
NULL::text AS codex_refresh_token_encrypted,
|
||||
NULL::timestamptz AS codex_token_expires_at,
|
||||
NULL::timestamptz AS codex_connected_at,
|
||||
'disconnected' AS codex_connection_status,
|
||||
insights_privacy_mode, insights_cached_at, insights_cached_text, insights_exclude_metrics,
|
||||
global_excluded_injection_sites,
|
||||
NULL::text AS telegram_bot_token,
|
||||
NULL::text AS telegram_chat_id,
|
||||
false AS telegram_enabled,
|
||||
NULL::text AS withings_client_id_encrypted,
|
||||
NULL::text AS withings_client_secret_encrypted,
|
||||
NULL::text AS whoop_client_id_encrypted,
|
||||
NULL::text AS whoop_client_secret_encrypted,
|
||||
NULL::text AS fitbit_client_id_encrypted,
|
||||
NULL::text AS fitbit_client_secret_encrypted,
|
||||
NULL::text AS mood_log_url_encrypted,
|
||||
NULL::text AS mood_log_api_key_encrypted,
|
||||
false AS mood_log_enabled,
|
||||
NULL::timestamptz AS mood_log_last_synced_at,
|
||||
NULL::text AS mood_log_webhook_secret,
|
||||
locale, thresholds_json, dashboard_widgets_json, insights_layout_json,
|
||||
glucose_unit, unit_preference,
|
||||
ai_provider, ai_model, ai_base_url,
|
||||
NULL::text AS ai_anthropic_key_encrypted,
|
||||
NULL::text AS ai_local_key_encrypted,
|
||||
NULL::text AS ai_openai_key_encrypted,
|
||||
ai_provider_chain,
|
||||
onboarding_completed_at, onboarding_step, onboarding_tour_completed,
|
||||
healthkit_config_json, healthkit_last_synced_at,
|
||||
display_name, last_report_practice_name, coach_prefs_json, source_priority_json,
|
||||
research_mode_enabled, research_mode_acknowledged_at, research_mode_acknowledged_version,
|
||||
last_synced_at, mood_reminder_enabled, doctor_report_prefs_json, notification_prefs,
|
||||
disable_coach, avatar_bytes, avatar_content_type, avatar_updated_at,
|
||||
full_name,
|
||||
NULL::text AS insurer_name,
|
||||
NULL::text AS insurance_number_encrypted,
|
||||
NULL::text AS insurer_ik_number"
|
||||
|
||||
readonly MEAS_COLS="id, user_id, type, value, unit, source, measured_at, notes,
|
||||
external_id, external_source_version, glucose_context, sleep_stage,
|
||||
rhythm_classification, device_type, sync_version, deleted_at, created_at, updated_at"
|
||||
|
||||
readonly CYCLE_PROFILE_COLS="id, user_id, goal, cycle_tracking_enabled,
|
||||
typical_cycle_length, typical_period_length, luteal_phase_length,
|
||||
prediction_enabled, raw_chart_mode, discreet_notifications,
|
||||
sensitive_category_encryption, created_at, updated_at"
|
||||
|
||||
readonly MENSTRUAL_CYCLE_COLS="id, user_id, start_date, end_date, period_end_date,
|
||||
length_days, ovulation_date, ovulation_confirmed, is_predicted, tz,
|
||||
sync_version, deleted_at, created_at, updated_at"
|
||||
|
||||
# cycle_day_logs — NULL the two app-encrypted columns (keyed to prod key,
|
||||
# absent on edge01). Carry the plaintext cycle fields.
|
||||
readonly CYCLE_DAY_LOG_COLS="id, user_id, date, cycle_id, flow, intermenstrual_bleeding,
|
||||
basal_body_temp_c, ovulation_test, cervical_mucus, sexual_activity, protected_sex,
|
||||
pregnancy_test, progesterone_test, contraceptive, sensitive_encrypted, notes_encrypted,
|
||||
source, external_id, tz, sync_version, deleted_at, created_at, updated_at"
|
||||
readonly CYCLE_DAY_LOG_SELECT="id, user_id, date, cycle_id, flow, intermenstrual_bleeding,
|
||||
basal_body_temp_c, ovulation_test, cervical_mucus, sexual_activity, protected_sex,
|
||||
pregnancy_test, progesterone_test, contraceptive,
|
||||
NULL::bytea AS sensitive_encrypted,
|
||||
NULL::bytea AS notes_encrypted,
|
||||
source, external_id, tz, sync_version, deleted_at, created_at, updated_at"
|
||||
|
||||
readonly CYCLE_SYMPTOM_LINK_COLS="day_log_id, symptom_id, severity, created_at"
|
||||
|
||||
readonly MOOD_ENTRY_COLS="id, user_id, date, mood, score, tags, note, source,
|
||||
external_id, mood_logged_at, tz, synced_at, created_at, updated_at,
|
||||
sync_version, deleted_at"
|
||||
|
||||
readonly MOOD_ENTRY_TAG_LINK_COLS="mood_entry_id, mood_tag_id, created_at, rating"
|
||||
|
||||
readonly MEDICATION_COLS="id, user_id, name, dose, treatment_class, doses_per_unit,
|
||||
active, notifications_enabled, paused_at, snoozed_until, starts_on, ends_on, one_shot,
|
||||
delivery_form, track_injection_sites, allowed_injection_sites, live_activity_enabled,
|
||||
critical_alarm_enabled, atc_code, rxnorm_code, created_at, updated_at"
|
||||
|
||||
readonly MED_SCHEDULE_COLS="id, medication_id, window_start, window_end, label, dose,
|
||||
days_of_week, times_of_day, reminder_grace_minutes, rrule, rolling_interval_days,
|
||||
schedule_type, cyclic_on_weeks, cyclic_off_weeks"
|
||||
|
||||
readonly MED_INTAKE_COLS="id, user_id, medication_id, scheduled_for, taken_at, skipped,
|
||||
auto_missed, source, idempotency_key, created_at, injection_site, updated_at,
|
||||
sync_version, deleted_at"
|
||||
|
||||
readonly MED_DOSE_CHANGE_COLS="id, medication_id, effective_from, dose_value, dose_unit, note, created_at"
|
||||
|
||||
readonly MED_INV_ITEM_COLS="id, user_id, medication_id, state, doses_total, doses_remaining,
|
||||
first_use_at, expires_at, printed_expiry, purchased_at, notes, created_at, updated_at"
|
||||
|
||||
readonly MED_INV_EVENT_COLS="id, medication_id, delta, reason, occurred_at"
|
||||
|
||||
readonly MED_SIDE_EFFECT_COLS="id, user_id, medication_id, occurred_at, category, entry,
|
||||
severity, notes, created_at"
|
||||
|
||||
readonly REMINDER_PHASE_COLS="id, medication_id, green_value, green_mode, yellow_value,
|
||||
yellow_mode, orange_value, orange_mode, red_value, red_mode"
|
||||
|
||||
readonly PERSONAL_RECORD_COLS="id, user_id, metric_type, metric_slot, direction, value, unit,
|
||||
achieved_at, source_measurement_id, source, external_id, created_at"
|
||||
|
||||
readonly USER_ACHIEVEMENT_COLS="id, user_id, achievement_id, unlocked_at, created_at"
|
||||
|
||||
readonly CONSENT_RECEIPT_COLS="id, user_id, kind, artefact, signed_at, revoked_at, created_at"
|
||||
|
||||
readonly AUDIT_LOG_COLS="id, user_id, action, details, ip_address, location, asn, carrier, created_at"
|
||||
|
||||
# ============================================================================
|
||||
# TRANSFER helper — scoped COPY piped host-to-host. Echoes row count after.
|
||||
# ============================================================================
|
||||
copy_table() {
|
||||
# Usage: copy_table "<table>" "<src_select_expr_list>" "<src_from_where>" "<dst_col_list>"
|
||||
local table="$1" select_expr="$2" from_where="$3" dst_cols="$4"
|
||||
echo
|
||||
echo "## TRANSFER $table ..."
|
||||
ssh "$SRC_SSH" "docker exec -i $SRC_DB_CONTAINER psql -X -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB \
|
||||
-c \"\\copy (SELECT $select_expr FROM $from_where) TO STDOUT (FORMAT csv, HEADER false)\"" \
|
||||
| ssh "$DST_SSH" "docker exec -i $DST_DB_CONTAINER psql -X -v ON_ERROR_STOP=1 -U $PG_USER -d $PG_DB \
|
||||
-c \"\\copy $table($dst_cols) FROM STDIN (FORMAT csv, HEADER false)\""
|
||||
local n
|
||||
n="$(psql_dst "-tA -c \"SELECT count(*) FROM $table;\"" | tr -d '[:space:]' || echo '?')"
|
||||
echo " loaded; edge01 $table total rows now: $n"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# LOAD in FK-parent-first order.
|
||||
# ============================================================================
|
||||
|
||||
# 1. users — the ONE row.
|
||||
copy_table "users" "$USERS_SELECT" \
|
||||
"users WHERE id = '$DEMO_USER_ID'" "$USERS_COLS"
|
||||
|
||||
# 2. measurements (live rows only).
|
||||
copy_table "measurements" "$MEAS_COLS" \
|
||||
"measurements WHERE user_id = '$DEMO_USER_ID' AND deleted_at IS NULL" "$MEAS_COLS"
|
||||
|
||||
# 3. cycle_profiles
|
||||
copy_table "cycle_profiles" "$CYCLE_PROFILE_COLS" \
|
||||
"cycle_profiles WHERE user_id = '$DEMO_USER_ID'" "$CYCLE_PROFILE_COLS"
|
||||
|
||||
# 4. menstrual_cycles
|
||||
copy_table "menstrual_cycles" "$MENSTRUAL_CYCLE_COLS" \
|
||||
"menstrual_cycles WHERE user_id = '$DEMO_USER_ID'" "$MENSTRUAL_CYCLE_COLS"
|
||||
|
||||
# 5. cycle_day_logs (NULL the two encrypted columns).
|
||||
copy_table "cycle_day_logs" "$CYCLE_DAY_LOG_SELECT" \
|
||||
"cycle_day_logs WHERE user_id = '$DEMO_USER_ID'" "$CYCLE_DAY_LOG_COLS"
|
||||
|
||||
# 6. cycle_symptom_links (join-scoped to demo's day logs).
|
||||
copy_table "cycle_symptom_links" "$CYCLE_SYMPTOM_LINK_COLS" \
|
||||
"cycle_symptom_links WHERE day_log_id IN (SELECT id FROM cycle_day_logs WHERE user_id = '$DEMO_USER_ID')" \
|
||||
"$CYCLE_SYMPTOM_LINK_COLS"
|
||||
|
||||
# 7. mood_entries (live rows only).
|
||||
copy_table "mood_entries" "$MOOD_ENTRY_COLS" \
|
||||
"mood_entries WHERE user_id = '$DEMO_USER_ID' AND deleted_at IS NULL" "$MOOD_ENTRY_COLS"
|
||||
|
||||
# 8. mood_entry_tag_links (join-scoped; pre-flight asserted all tags are GLOBAL).
|
||||
copy_table "mood_entry_tag_links" "$MOOD_ENTRY_TAG_LINK_COLS" \
|
||||
"mood_entry_tag_links WHERE mood_entry_id IN (SELECT id FROM mood_entries WHERE user_id = '$DEMO_USER_ID')" \
|
||||
"$MOOD_ENTRY_TAG_LINK_COLS"
|
||||
|
||||
# 9. medications
|
||||
copy_table "medications" "$MEDICATION_COLS" \
|
||||
"medications WHERE user_id = '$DEMO_USER_ID'" "$MEDICATION_COLS"
|
||||
|
||||
# 10. medication_schedules (join-scoped).
|
||||
copy_table "medication_schedules" "$MED_SCHEDULE_COLS" \
|
||||
"medication_schedules WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$DEMO_USER_ID')" \
|
||||
"$MED_SCHEDULE_COLS"
|
||||
|
||||
# 11. medication_intake_events (live rows only).
|
||||
copy_table "medication_intake_events" "$MED_INTAKE_COLS" \
|
||||
"medication_intake_events WHERE user_id = '$DEMO_USER_ID' AND deleted_at IS NULL" "$MED_INTAKE_COLS"
|
||||
|
||||
# 12. medication_dose_changes (join-scoped).
|
||||
copy_table "medication_dose_changes" "$MED_DOSE_CHANGE_COLS" \
|
||||
"medication_dose_changes WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$DEMO_USER_ID')" \
|
||||
"$MED_DOSE_CHANGE_COLS"
|
||||
|
||||
# 13. medication_inventory_items (user-scoped).
|
||||
copy_table "medication_inventory_items" "$MED_INV_ITEM_COLS" \
|
||||
"medication_inventory_items WHERE user_id = '$DEMO_USER_ID'" "$MED_INV_ITEM_COLS"
|
||||
|
||||
# 14. medication_inventory_events (join-scoped).
|
||||
copy_table "medication_inventory_events" "$MED_INV_EVENT_COLS" \
|
||||
"medication_inventory_events WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$DEMO_USER_ID')" \
|
||||
"$MED_INV_EVENT_COLS"
|
||||
|
||||
# 15. medication_side_effects (user-scoped).
|
||||
copy_table "medication_side_effects" "$MED_SIDE_EFFECT_COLS" \
|
||||
"medication_side_effects WHERE user_id = '$DEMO_USER_ID'" "$MED_SIDE_EFFECT_COLS"
|
||||
|
||||
# 16. reminder_phase_configs (join-scoped).
|
||||
copy_table "reminder_phase_configs" "$REMINDER_PHASE_COLS" \
|
||||
"reminder_phase_configs WHERE medication_id IN (SELECT id FROM medications WHERE user_id = '$DEMO_USER_ID')" \
|
||||
"$REMINDER_PHASE_COLS"
|
||||
|
||||
# 17. personal_records (user-scoped).
|
||||
copy_table "personal_records" "$PERSONAL_RECORD_COLS" \
|
||||
"personal_records WHERE user_id = '$DEMO_USER_ID'" "$PERSONAL_RECORD_COLS"
|
||||
|
||||
# 18. user_achievements (user-scoped).
|
||||
copy_table "user_achievements" "$USER_ACHIEVEMENT_COLS" \
|
||||
"user_achievements WHERE user_id = '$DEMO_USER_ID'" "$USER_ACHIEVEMENT_COLS"
|
||||
|
||||
# 19. consent_receipts (user-scoped — carries the ai_full consent).
|
||||
copy_table "consent_receipts" "$CONSENT_RECEIPT_COLS" \
|
||||
"consent_receipts WHERE user_id = '$DEMO_USER_ID'" "$CONSENT_RECEIPT_COLS"
|
||||
|
||||
# 20. audit_logs — only the plaintext AI insight assessment rows.
|
||||
copy_table "audit_logs" "$AUDIT_LOG_COLS" \
|
||||
"audit_logs WHERE user_id = '$DEMO_USER_ID' AND action LIKE 'insights.%'" "$AUDIT_LOG_COLS"
|
||||
|
||||
# ============================================================================
|
||||
# POST-LOAD VERIFICATION (on edge01).
|
||||
# - per-table demo-user row counts (informational),
|
||||
# - HARD assertion: the REAL operator id appears in NO carried table,
|
||||
# - HARD assertion: every user_id-bearing carried table contains ONLY the
|
||||
# demo id.
|
||||
# ============================================================================
|
||||
echo
|
||||
echo "=============================================================="
|
||||
echo "## POST-LOAD verification on edge01"
|
||||
echo "=============================================================="
|
||||
|
||||
echo
|
||||
echo "Per-table demo row counts (user_id = $DEMO_USER_ID):"
|
||||
psql_dst <<SQL
|
||||
\pset pager off
|
||||
SELECT 'users' AS tbl, count(*) FROM users WHERE id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'measurements', count(*) FROM measurements WHERE user_id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'cycle_profiles', count(*) FROM cycle_profiles WHERE user_id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'menstrual_cycles', count(*) FROM menstrual_cycles WHERE user_id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'cycle_day_logs', count(*) FROM cycle_day_logs WHERE user_id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'cycle_symptom_links', count(*) FROM cycle_symptom_links l JOIN cycle_day_logs d ON d.id = l.day_log_id WHERE d.user_id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'mood_entries', count(*) FROM mood_entries WHERE user_id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'mood_entry_tag_links', count(*) FROM mood_entry_tag_links l JOIN mood_entries e ON e.id = l.mood_entry_id WHERE e.user_id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'medications', count(*) FROM medications WHERE user_id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'medication_schedules', count(*) FROM medication_schedules s JOIN medications m ON m.id = s.medication_id WHERE m.user_id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'medication_intake_events', count(*) FROM medication_intake_events WHERE user_id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'medication_dose_changes', count(*) FROM medication_dose_changes d JOIN medications m ON m.id = d.medication_id WHERE m.user_id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'medication_inventory_items', count(*) FROM medication_inventory_items WHERE user_id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'medication_inventory_events', count(*) FROM medication_inventory_events e JOIN medications m ON m.id = e.medication_id WHERE m.user_id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'medication_side_effects', count(*) FROM medication_side_effects WHERE user_id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'reminder_phase_configs', count(*) FROM reminder_phase_configs c JOIN medications m ON m.id = c.medication_id WHERE m.user_id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'personal_records', count(*) FROM personal_records WHERE user_id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'user_achievements', count(*) FROM user_achievements WHERE user_id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'consent_receipts', count(*) FROM consent_receipts WHERE user_id = '$DEMO_USER_ID'
|
||||
UNION ALL SELECT 'audit_logs(insights)', count(*) FROM audit_logs WHERE user_id = '$DEMO_USER_ID' AND action LIKE 'insights.%'
|
||||
ORDER BY tbl;
|
||||
SQL
|
||||
|
||||
echo
|
||||
echo "HARD assertion 1: REAL operator id ($REAL_USER_ID) appears NOWHERE."
|
||||
REAL_HITS="$(psql_dst "-tA -c \"
|
||||
SELECT
|
||||
(SELECT count(*) FROM users WHERE id = '$REAL_USER_ID')
|
||||
+ (SELECT count(*) FROM measurements WHERE user_id = '$REAL_USER_ID')
|
||||
+ (SELECT count(*) FROM cycle_profiles WHERE user_id = '$REAL_USER_ID')
|
||||
+ (SELECT count(*) FROM menstrual_cycles WHERE user_id = '$REAL_USER_ID')
|
||||
+ (SELECT count(*) FROM cycle_day_logs WHERE user_id = '$REAL_USER_ID')
|
||||
+ (SELECT count(*) FROM mood_entries WHERE user_id = '$REAL_USER_ID')
|
||||
+ (SELECT count(*) FROM medications WHERE user_id = '$REAL_USER_ID')
|
||||
+ (SELECT count(*) FROM medication_intake_events WHERE user_id = '$REAL_USER_ID')
|
||||
+ (SELECT count(*) FROM medication_inventory_items WHERE user_id = '$REAL_USER_ID')
|
||||
+ (SELECT count(*) FROM medication_side_effects WHERE user_id = '$REAL_USER_ID')
|
||||
+ (SELECT count(*) FROM personal_records WHERE user_id = '$REAL_USER_ID')
|
||||
+ (SELECT count(*) FROM user_achievements WHERE user_id = '$REAL_USER_ID')
|
||||
+ (SELECT count(*) FROM consent_receipts WHERE user_id = '$REAL_USER_ID')
|
||||
+ (SELECT count(*) FROM audit_logs WHERE user_id = '$REAL_USER_ID');
|
||||
\"" | tr -d '[:space:]')"
|
||||
echo " real-id occurrences across carried tables = $REAL_HITS"
|
||||
if [[ "$REAL_HITS" != "0" ]]; then
|
||||
echo "ABORT/FAIL: operator id present in $REAL_HITS rows on edge01. Investigate immediately."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "HARD assertion 2: every user_id-bearing carried table holds ONLY the demo id."
|
||||
FOREIGN_HITS="$(psql_dst "-tA -c \"
|
||||
SELECT
|
||||
(SELECT count(*) FROM measurements WHERE user_id <> '$DEMO_USER_ID')
|
||||
+ (SELECT count(*) FROM cycle_profiles WHERE user_id <> '$DEMO_USER_ID')
|
||||
+ (SELECT count(*) FROM menstrual_cycles WHERE user_id <> '$DEMO_USER_ID')
|
||||
+ (SELECT count(*) FROM cycle_day_logs WHERE user_id <> '$DEMO_USER_ID')
|
||||
+ (SELECT count(*) FROM mood_entries WHERE user_id <> '$DEMO_USER_ID')
|
||||
+ (SELECT count(*) FROM medications WHERE user_id <> '$DEMO_USER_ID')
|
||||
+ (SELECT count(*) FROM medication_intake_events WHERE user_id <> '$DEMO_USER_ID')
|
||||
+ (SELECT count(*) FROM medication_inventory_items WHERE user_id <> '$DEMO_USER_ID')
|
||||
+ (SELECT count(*) FROM medication_side_effects WHERE user_id <> '$DEMO_USER_ID')
|
||||
+ (SELECT count(*) FROM personal_records WHERE user_id <> '$DEMO_USER_ID')
|
||||
+ (SELECT count(*) FROM user_achievements WHERE user_id <> '$DEMO_USER_ID')
|
||||
+ (SELECT count(*) FROM consent_receipts WHERE user_id <> '$DEMO_USER_ID')
|
||||
+ (SELECT count(*) FROM audit_logs WHERE user_id <> '$DEMO_USER_ID');
|
||||
\"" | tr -d '[:space:]')"
|
||||
echo " non-demo user_id rows across carried tables = $FOREIGN_HITS"
|
||||
echo " NOTE: this counts EVERY non-demo row in these tables on edge01, not just"
|
||||
echo " what this script loaded. On a clean demo DB it should be 0. If the"
|
||||
echo " edge01 demo DB legitimately holds other demo-only accounts, scope"
|
||||
echo " this assertion to the rows this run inserted before treating >0 as fatal."
|
||||
if [[ "$FOREIGN_HITS" != "0" ]]; then
|
||||
echo "WARNING: $FOREIGN_HITS non-demo rows exist in carried tables on edge01."
|
||||
echo " Inspect before serving the demo (see note above)."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "HARD assertion 3: the demo user exists on edge01 with role=ADMIN and the apps01 id."
|
||||
psql_dst "-c \"SELECT id, username, role, telegram_enabled, mood_log_enabled FROM users WHERE id = '$DEMO_USER_ID';\""
|
||||
|
||||
echo
|
||||
echo "=============================================================="
|
||||
echo " DONE. Review the counts above. Demo user is role=ADMIN; the"
|
||||
echo " app's DEMO_MODE must remain ON on edge01 so all mutations are"
|
||||
echo " blocked at the proxy layer while admin pages stay viewable."
|
||||
echo "=============================================================="
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* promote-digest.ts — pin a released image to its immutable digest.
|
||||
*
|
||||
* After `docker-publish.yml` pushes `vX.Y.Z` (+ `:latest`) to GHCR, this
|
||||
* script turns the mutable tag into a deterministic deploy reference:
|
||||
*
|
||||
* 1. Resolves the multi-arch index digest for the tag straight from
|
||||
* the GHCR registry API (anonymous pull token — the image is
|
||||
* public; no docker daemon needed).
|
||||
* 2. Verifies the keyless cosign signature the publish workflow
|
||||
* attached to that digest (GitHub Actions OIDC identity). Fails
|
||||
* closed: no cosign binary or a failed verification aborts the
|
||||
* promotion.
|
||||
* 3. Emits the `HEALTHLOG_IMAGE_REF=@sha256:…` value the compose
|
||||
* file interpolates into `image:` — and, when a Coolify API token
|
||||
* is present in the environment, writes it to the application's
|
||||
* env vars via the documented `/envs` endpoint. Without a token
|
||||
* it prints the exact curl commands instead of guessing.
|
||||
*
|
||||
* Why an env var and not the compose file itself: for a git-based
|
||||
* "Docker Compose" application Coolify re-reads the compose file from
|
||||
* the repository checkout on every deploy (`docker_compose_raw` is its
|
||||
* stored copy of that file and is not part of the application-update
|
||||
* API surface), so editing the stored compose is not durable. The
|
||||
* application env vars ARE durable and are interpolated into the
|
||||
* compose at deploy time — `image: ghcr.io/mbombeck/healthlog${HEALTHLOG_IMAGE_REF:-:latest}`
|
||||
* resolves to the pinned digest when the var is set and to `:latest`
|
||||
* when it is not.
|
||||
*
|
||||
* Usage (one-shot, not a recurring job):
|
||||
* pnpm dlx tsx scripts/promote-digest.ts v1.16.5
|
||||
*
|
||||
* Env (all optional — without them the script prints commands only):
|
||||
* COOLIFY_API_TOKEN API token (Coolify → Keys & Tokens → API tokens)
|
||||
* COOLIFY_API_URL Base URL of the Coolify instance, e.g. https://coolify.example.com
|
||||
* COOLIFY_APP_UUID UUID of the HealthLog application resource
|
||||
* PROMOTE_DEPLOY=1 Additionally trigger a deploy after the env update
|
||||
*
|
||||
* See docs/ops/deploy.md ("Digest pinning") for the full runbook,
|
||||
* including rollback (re-point the var at the previous digest).
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const IMAGE_REPO = "mbombeck/healthlog";
|
||||
const IMAGE = `ghcr.io/${IMAGE_REPO}`;
|
||||
const ENV_KEY = "HEALTHLOG_IMAGE_REF";
|
||||
const COSIGN_ISSUER = "https://token.actions.githubusercontent.com";
|
||||
const COSIGN_IDENTITY_RE = "^https://github.com/MBombeck/HealthLog/";
|
||||
|
||||
// The OCI index media types a multi-arch publish can produce. GHCR
|
||||
// answers the manifest request with the digest of whichever it stores;
|
||||
// accepting both keeps the script working across buildx versions.
|
||||
const INDEX_ACCEPT = [
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
].join(", ");
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(`\nERROR: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function resolveDigest(tag: string): Promise<string> {
|
||||
// Anonymous pull token — the package is public, so no credentials.
|
||||
const tokenRes = await fetch(
|
||||
`https://ghcr.io/token?service=ghcr.io&scope=repository:${IMAGE_REPO}:pull`,
|
||||
);
|
||||
if (!tokenRes.ok) {
|
||||
fail(`GHCR token request failed: HTTP ${tokenRes.status}`);
|
||||
}
|
||||
const { token } = (await tokenRes.json()) as { token?: string };
|
||||
if (!token) fail("GHCR token response carried no token");
|
||||
|
||||
// HEAD is enough — the digest rides the Docker-Content-Digest header.
|
||||
const manifestRes = await fetch(
|
||||
`https://ghcr.io/v2/${IMAGE_REPO}/manifests/${encodeURIComponent(tag)}`,
|
||||
{
|
||||
method: "HEAD",
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: INDEX_ACCEPT },
|
||||
},
|
||||
);
|
||||
if (!manifestRes.ok) {
|
||||
fail(
|
||||
`manifest lookup for ${IMAGE}:${tag} failed: HTTP ${manifestRes.status} — does the tag exist on GHCR?`,
|
||||
);
|
||||
}
|
||||
const digest = manifestRes.headers.get("docker-content-digest");
|
||||
if (!digest || !/^sha256:[0-9a-f]{64}$/.test(digest)) {
|
||||
fail(`registry returned no usable digest (got: ${digest ?? "nothing"})`);
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
function verifyCosign(digest: string): void {
|
||||
const ref = `${IMAGE}@${digest}`;
|
||||
try {
|
||||
execFileSync(
|
||||
"cosign",
|
||||
[
|
||||
"verify",
|
||||
"--certificate-oidc-issuer",
|
||||
COSIGN_ISSUER,
|
||||
"--certificate-identity-regexp",
|
||||
COSIGN_IDENTITY_RE,
|
||||
ref,
|
||||
],
|
||||
{ stdio: ["ignore", "ignore", "pipe"] },
|
||||
);
|
||||
} catch (err) {
|
||||
const e = err as NodeJS.ErrnoException & { stderr?: Buffer };
|
||||
if (e.code === "ENOENT") {
|
||||
fail(
|
||||
"cosign binary not found. Install it (https://docs.sigstore.dev/cosign/system_config/installation/) — the promotion fails closed without signature verification.",
|
||||
);
|
||||
}
|
||||
fail(
|
||||
`cosign verification FAILED for ${ref} — do not deploy this digest.\n${e.stderr?.toString() ?? ""}`,
|
||||
);
|
||||
}
|
||||
console.log(`cosign signature verified for ${ref}`);
|
||||
}
|
||||
|
||||
interface CoolifyEnvRow {
|
||||
uuid: string;
|
||||
key: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
async function coolifyRequest(
|
||||
base: string,
|
||||
token: string,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
return fetch(`${base.replace(/\/$/, "")}/api/v1${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
}
|
||||
|
||||
async function updateCoolifyEnv(
|
||||
base: string,
|
||||
token: string,
|
||||
appUuid: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const list = await coolifyRequest(base, token, `/applications/${appUuid}/envs`);
|
||||
if (!list.ok) {
|
||||
fail(`Coolify env list failed: HTTP ${list.status} — check token/UUID`);
|
||||
}
|
||||
const rows = (await list.json()) as CoolifyEnvRow[];
|
||||
const existing = Array.isArray(rows)
|
||||
? rows.find((r) => r.key === ENV_KEY)
|
||||
: undefined;
|
||||
|
||||
const method = existing ? "PATCH" : "POST";
|
||||
const res = await coolifyRequest(
|
||||
base,
|
||||
token,
|
||||
`/applications/${appUuid}/envs`,
|
||||
{ method, body: JSON.stringify({ key: ENV_KEY, value }) },
|
||||
);
|
||||
if (!res.ok) {
|
||||
fail(`Coolify env ${method} failed: HTTP ${res.status}`);
|
||||
}
|
||||
console.log(
|
||||
`Coolify env ${ENV_KEY} ${existing ? "updated" : "created"} (was: ${existing?.value ?? "unset"})`,
|
||||
);
|
||||
}
|
||||
|
||||
async function triggerDeploy(
|
||||
base: string,
|
||||
token: string,
|
||||
appUuid: string,
|
||||
): Promise<void> {
|
||||
const res = await coolifyRequest(base, token, `/deploy?uuid=${appUuid}`);
|
||||
if (!res.ok) fail(`deploy trigger failed: HTTP ${res.status}`);
|
||||
console.log("Deploy triggered. Verify /api/version after it settles.");
|
||||
}
|
||||
|
||||
function printManualCommands(value: string): void {
|
||||
console.log(`
|
||||
No COOLIFY_API_TOKEN in the environment — manual path (fill in host,
|
||||
token and application UUID; the UUID is in the Coolify URL of the app):
|
||||
|
||||
# set / update the pin (POST creates, PATCH updates an existing key)
|
||||
curl -sS -X PATCH "https://<coolify-host>/api/v1/applications/<app-uuid>/envs" \\
|
||||
-H "Authorization: Bearer <token>" -H "Content-Type: application/json" \\
|
||||
-d '{"key":"${ENV_KEY}","value":"${value}"}'
|
||||
|
||||
# redeploy with the pinned digest
|
||||
curl -sS "https://<coolify-host>/api/v1/deploy?uuid=<app-uuid>" \\
|
||||
-H "Authorization: Bearer <token>"
|
||||
`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const tag = process.argv[2];
|
||||
if (!tag || !/^v\d+\.\d+\.\d+/.test(tag)) {
|
||||
fail("usage: pnpm dlx tsx scripts/promote-digest.ts vX.Y.Z");
|
||||
}
|
||||
|
||||
console.log(`Resolving multi-arch digest for ${IMAGE}:${tag} …`);
|
||||
const digest = await resolveDigest(tag);
|
||||
console.log(`digest: ${digest}`);
|
||||
|
||||
verifyCosign(digest);
|
||||
|
||||
const value = `@${digest}`;
|
||||
console.log(`\n${ENV_KEY}=${value}`);
|
||||
|
||||
const token = process.env.COOLIFY_API_TOKEN;
|
||||
const base = process.env.COOLIFY_API_URL;
|
||||
const appUuid = process.env.COOLIFY_APP_UUID;
|
||||
|
||||
if (token && base && appUuid) {
|
||||
await updateCoolifyEnv(base, token, appUuid, value);
|
||||
if (process.env.PROMOTE_DEPLOY === "1") {
|
||||
await triggerDeploy(base, token, appUuid);
|
||||
} else {
|
||||
console.log(
|
||||
"Env updated only — trigger the deploy from Coolify (or re-run with PROMOTE_DEPLOY=1).",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (token || base || appUuid) {
|
||||
console.log(
|
||||
"Partial Coolify config — need COOLIFY_API_TOKEN + COOLIFY_API_URL + COOLIFY_APP_UUID together; printing the manual path instead.",
|
||||
);
|
||||
}
|
||||
printManualCommands(value);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|
||||
File diff suppressed because it is too large.
Load diff
@@ -230,6 +230,50 @@ async function main() {
|
||||
results.push(r);
|
||||
}
|
||||
|
||||
// ───── MoodTag.labelEncrypted ─────
|
||||
// v1.13.0 — AES-256-GCM ciphertext of a custom mood tag's free-text
|
||||
// label (catalogue rows carry NULL and are skipped by `shouldRotate`).
|
||||
const moodTags = await prisma.moodTag.findMany({
|
||||
where: { labelEncrypted: { not: null } },
|
||||
select: { id: true, labelEncrypted: true },
|
||||
});
|
||||
results.push(
|
||||
await rotateField(
|
||||
"MoodTag",
|
||||
"labelEncrypted",
|
||||
moodTags,
|
||||
(t) => t.labelEncrypted,
|
||||
async (id, ciphertext) => {
|
||||
await prisma.moodTag.update({
|
||||
where: { id },
|
||||
data: { labelEncrypted: ciphertext },
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ───── MoodTagCategory.labelEncrypted ─────
|
||||
// v1.17.0 — AES-256-GCM ciphertext of a custom mood-tag group's
|
||||
// free-text label (seeded rows carry NULL and are skipped).
|
||||
const moodTagCategories = await prisma.moodTagCategory.findMany({
|
||||
where: { labelEncrypted: { not: null } },
|
||||
select: { id: true, labelEncrypted: true },
|
||||
});
|
||||
results.push(
|
||||
await rotateField(
|
||||
"MoodTagCategory",
|
||||
"labelEncrypted",
|
||||
moodTagCategories,
|
||||
(c) => c.labelEncrypted,
|
||||
async (id, ciphertext) => {
|
||||
await prisma.moodTagCategory.update({
|
||||
where: { id },
|
||||
data: { labelEncrypted: ciphertext },
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ───── NotificationChannel.config ─────
|
||||
// Channel config (Telegram chat id, ntfy topic, etc.) is encrypted JSON.
|
||||
// Skipping these on rotation would leave channels permanently undecryptable
|
||||
|
||||
@@ -14,7 +14,7 @@ BEGIN;
|
||||
|
||||
-- ── User (FEMALE, USER role) ──────────────────────────────────────
|
||||
INSERT INTO users (id, username, email, password_hash, role, height_cm, date_of_birth, gender, timezone, locale, onboarding_completed_at, created_at, updated_at)
|
||||
SELECT 'usr_demo_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
SELECT 'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
'demo', 'demo@healthlog.app',
|
||||
'$argon2id$v=19$m=19456,t=2,p=1$3KvrEIuI5UnwG53GPbS71w$mL8ZQotIXn9iLFIw3yHXbh6SitX1w7TXKI5ahWcKkF8',
|
||||
'USER', 168, TIMESTAMP '1992-03-15', 'FEMALE', 'Europe/Berlin', 'en', NOW(), NOW(), NOW()
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
-- HealthLog demo medications for apps01 — canonical ids the 1-year extender
|
||||
-- (scripts/seed-demo-extend-1y.sql) references for its intake history.
|
||||
-- Idempotent. Creates the two demo meds + schedules so the extender's
|
||||
-- medication_intake_events FK resolves and the medication cards/compliance
|
||||
-- surfaces populate. Demo user id is the canonical seeded id.
|
||||
--
|
||||
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-meds-apps01.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM users WHERE id = 'usr_demo_cf31025295714ece8d91f5af13afd76d') THEN
|
||||
RAISE EXCEPTION 'seed-demo-meds: demo user not found';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── Med A — Mounjaro (GLP-1, weekly on Sundays @ 08:00) ────────────
|
||||
INSERT INTO medications
|
||||
(id, user_id, name, dose, treatment_class, doses_per_unit, active, notifications_enabled, starts_on, created_at, updated_at)
|
||||
VALUES
|
||||
('cmq2lzjqh005x01nrys2crzg9', 'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
'Mounjaro', '5 mg', 'GLP1', 4, true, true,
|
||||
(NOW() - INTERVAL '380 days')::date, NOW() - INTERVAL '380 days', NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name, dose = EXCLUDED.dose, treatment_class = EXCLUDED.treatment_class,
|
||||
doses_per_unit = EXCLUDED.doses_per_unit, active = true, starts_on = EXCLUDED.starts_on,
|
||||
updated_at = NOW();
|
||||
|
||||
INSERT INTO medication_schedules
|
||||
(id, medication_id, window_start, window_end, label, times_of_day, days_of_week, rrule)
|
||||
VALUES
|
||||
('sched_demo_medA_mounjaro', 'cmq2lzjqh005x01nrys2crzg9',
|
||||
'08:00', '10:00', 'Wöchentlich', ARRAY['08:00'], '7', 'FREQ=WEEKLY;BYDAY=SU')
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
window_start = EXCLUDED.window_start, window_end = EXCLUDED.window_end,
|
||||
label = EXCLUDED.label, times_of_day = EXCLUDED.times_of_day,
|
||||
days_of_week = EXCLUDED.days_of_week, rrule = EXCLUDED.rrule;
|
||||
|
||||
-- ── Med B — Ramipril (twice daily @ 08:00 + 18:00) ─────────────────
|
||||
INSERT INTO medications
|
||||
(id, user_id, name, dose, treatment_class, active, notifications_enabled, starts_on, created_at, updated_at)
|
||||
VALUES
|
||||
('cmq2m0mzk006101nrdpijbij0', 'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
'Ramipril', '5 mg', 'GENERIC', true, true,
|
||||
(NOW() - INTERVAL '380 days')::date, NOW() - INTERVAL '380 days', NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name, dose = EXCLUDED.dose, treatment_class = EXCLUDED.treatment_class,
|
||||
active = true, starts_on = EXCLUDED.starts_on, updated_at = NOW();
|
||||
|
||||
INSERT INTO medication_schedules
|
||||
(id, medication_id, window_start, window_end, label, times_of_day, days_of_week, rrule)
|
||||
VALUES
|
||||
('sched_demo_medB_ramipril', 'cmq2m0mzk006101nrdpijbij0',
|
||||
'08:00', '18:00', 'Morgens & Abends', ARRAY['08:00','18:00'], NULL, 'FREQ=DAILY')
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
window_start = EXCLUDED.window_start, window_end = EXCLUDED.window_end,
|
||||
label = EXCLUDED.label, times_of_day = EXCLUDED.times_of_day,
|
||||
days_of_week = EXCLUDED.days_of_week, rrule = EXCLUDED.rrule;
|
||||
|
||||
COMMIT;
|
||||
|
||||
\echo === demo meds ===
|
||||
SELECT id, name, dose, treatment_class, active FROM medications
|
||||
WHERE user_id = 'usr_demo_cf31025295714ece8d91f5af13afd76d' ORDER BY name;
|
||||
@@ -18,7 +18,7 @@ import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/timezone";
|
||||
import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/tz/local-day";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL!;
|
||||
|
||||
@@ -33,7 +33,7 @@ function parseTimeToMinutes(value: string): number {
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
// getUserTodayBounds and getDayOfWeekInTz imported from @/lib/timezone
|
||||
// getUserTodayBounds and getDayOfWeekInTz imported from @/lib/tz/local-day
|
||||
|
||||
const dayLabels = ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"];
|
||||
|
||||
|
||||
+1
-1
@@ -23,4 +23,4 @@ client → src/proxy.ts → src/app/<route>/route.ts → src/lib/api-handler.ts
|
||||
|
||||
## Conventions
|
||||
|
||||
See [`../CLAUDE.md`](../CLAUDE.md) for the full set. The load-bearing ones: RSC by default (`"use client"` only when a hook/state/browser API needs it); every API route wraps `apiHandler` and parses its body with Zod `safeParse`; `userId` is always narrowed from session or Bearer, never accepted as a body field; TanStack Query keys come only from `src/lib/query-keys.ts`; outbound HTTP goes through `src/lib/safe-fetch.ts`.
|
||||
See [`../CLAUDE.md`](../CLAUDE.md) for the full set. The load-bearing ones: RSC by default (`"use client"` only when a hook/state/browser API needs it); every API route wraps `apiHandler` and parses its body with Zod `safeParse`; `userId` is always narrowed from session or Bearer, never accepted as a body field; TanStack Query keys come only from `src/lib/query-keys/`; outbound HTTP goes through `src/lib/safe-fetch.ts`.
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* The v1.4.38.7 perf audit (`.planning/round-v1438-perf-analysis.md`
|
||||
* §1 A2) flagged the per-type `fetchMeasurementSeriesChunked` loop
|
||||
* inside `/api/analytics` as the dominant cost of Marc's 74.6 s cold
|
||||
* inside `/api/analytics` as the dominant cost of the maintainer's 74.6 s cold
|
||||
* full-slice mount. v1.4.38.8 (commit 8a8150d2) relaxed the
|
||||
* `isFullyCovered` gate inside the three downstream fast-paths so the
|
||||
* walk became unreachable in the common case — but the A2 loop itself
|
||||
@@ -129,7 +129,7 @@ const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-since-1",
|
||||
username: "marc",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
timezone: "Europe/Berlin",
|
||||
|
||||
@@ -119,7 +119,7 @@ describe("POST /api/auth/check-user", () => {
|
||||
passwordHash: null,
|
||||
_count: { passkeys: 2 },
|
||||
} as never);
|
||||
const res = await POST(makeRequest({ identifier: "marc" }));
|
||||
const res = await POST(makeRequest({ identifier: "testuser" }));
|
||||
const body = (await res.json()) as CheckUserBody;
|
||||
expect(body.data.branch).toBe("passkey_only");
|
||||
expect(body.data.hasPasskey).toBe(true);
|
||||
@@ -132,7 +132,7 @@ describe("POST /api/auth/check-user", () => {
|
||||
passwordHash: "argon2id$…",
|
||||
_count: { passkeys: 1 },
|
||||
} as never);
|
||||
const res = await POST(makeRequest({ identifier: "marc" }));
|
||||
const res = await POST(makeRequest({ identifier: "testuser" }));
|
||||
const body = (await res.json()) as CheckUserBody;
|
||||
expect(body.data.branch).toBe("email_fallback");
|
||||
expect(body.data.hasPasskey).toBe(true);
|
||||
@@ -145,7 +145,7 @@ describe("POST /api/auth/check-user", () => {
|
||||
passwordHash: null,
|
||||
_count: { passkeys: 0 },
|
||||
} as never);
|
||||
const res = await POST(makeRequest({ identifier: "marc" }));
|
||||
const res = await POST(makeRequest({ identifier: "testuser" }));
|
||||
const body = (await res.json()) as CheckUserBody;
|
||||
expect(body.data.branch).toBe("exists");
|
||||
expect(body.data.hasPasskey).toBe(false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* v1.4.49.3 — call-site i18n coverage guard.
|
||||
*
|
||||
* Marc surfaced raw `insights.relativeHoursAgo`, `notifications.event
|
||||
* The maintainer surfaced raw `insights.relativeHoursAgo`, `notifications.event
|
||||
* MoodReminder`, and `notifications.eventMoodReminderDesc` strings
|
||||
* leaking into the UI in v1.4.49. Audit found 28 keys called from real
|
||||
* code that never existed in any locale bundle — silent regressions
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* v1.16.10 — inventory write-path isolation guard.
|
||||
*
|
||||
* The consumption stamp on `MedicationIntakeEvent.inventoryConsumption`
|
||||
* is only an exactly-once ledger as long as EVERY stock movement flows
|
||||
* through `src/lib/medications/inventory/consumption.ts` (intake-driven
|
||||
* consume / restore) or the dedicated inventory surfaces (the CRUD
|
||||
* routes, the create-input builder, and the expire cron in
|
||||
* `service.ts`). A `medicationInventoryItem` write sprinkled into some
|
||||
* future intake path would bypass the stamp and re-open the
|
||||
* double-decrement / stranded-stock class of bugs this release closed.
|
||||
*
|
||||
* This test walks every TypeScript / TSX file under `src/` (excluding
|
||||
* tests + generated code) and asserts that no file outside the
|
||||
* allowlist invokes a Prisma write method on `medicationInventoryItem`.
|
||||
* Reads (`findFirst`, `findMany`, …) stay legal everywhere.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, relative, sep } from "node:path";
|
||||
|
||||
const ROOT = join(__dirname, "../..");
|
||||
const SRC = join(ROOT, "src");
|
||||
|
||||
const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]);
|
||||
|
||||
/**
|
||||
* Files allowed to write `medicationInventoryItem` rows:
|
||||
* - the consumption module (intake-driven consume / restore);
|
||||
* - the inventory persistence helpers (expire cron `updateMany`);
|
||||
* - the two inventory CRUD routes (register / mutate / delete).
|
||||
*/
|
||||
const ALLOWED_WRITERS = new Set(
|
||||
[
|
||||
"src/lib/medications/inventory/consumption.ts",
|
||||
"src/lib/medications/inventory/service.ts",
|
||||
"src/app/api/medications/[id]/inventory/route.ts",
|
||||
"src/app/api/medications/[id]/inventory/[itemId]/route.ts",
|
||||
].map((p) => p.split("/").join(sep)),
|
||||
);
|
||||
|
||||
const WRITE_PATTERN =
|
||||
/medicationInventoryItem\s*\.\s*(create|createMany|update|updateMany|upsert|delete|deleteMany)\s*\(/g;
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (SKIP_DIRS.has(name)) continue;
|
||||
const p = join(dir, name);
|
||||
const stat = statSync(p);
|
||||
if (stat.isDirectory()) {
|
||||
walk(p, out);
|
||||
} else if (name.endsWith(".ts") || name.endsWith(".tsx")) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("medicationInventoryItem write isolation", () => {
|
||||
it("no file outside the consumption module + inventory surfaces writes inventory items", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const rel = relative(ROOT, file);
|
||||
if (ALLOWED_WRITERS.has(rel)) continue;
|
||||
const content = readFileSync(file, "utf8");
|
||||
if (WRITE_PATTERN.test(content)) {
|
||||
offenders.push(rel);
|
||||
WRITE_PATTERN.lastIndex = 0;
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("the allowlisted writers actually exist (allowlist stays honest)", () => {
|
||||
for (const rel of ALLOWED_WRITERS) {
|
||||
expect(() => statSync(join(ROOT, rel))).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import { NextRequest } from "next/server";
|
||||
* needed to harden against first-visit MITM and to scope the
|
||||
* Withings backend host to its surface only.
|
||||
*
|
||||
* - HSTS now carries `; preload` so `healthlog.bombeck.io` is
|
||||
* - HSTS now carries `; preload` so the served host is
|
||||
* eligible for the Chromium HSTS preload list.
|
||||
* - The `wbsapi.withings.net` `connect-src` entry only ships on
|
||||
* the Withings surfaces (`/settings/integrations/withings/*`
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* Demo-mode mutation allowlist. On a `DEMO_MODE=true` deploy the proxy
|
||||
* 403s every non-GET `/api/*` request except a narrow allowlist. Login
|
||||
* is the historical baseline; the two dashboard display-pref writes
|
||||
* (chart-overlay toggles + comparison-baseline selector) were added so
|
||||
* the above-chart toggles work in the demo. They touch nothing but the
|
||||
* caller's own `User.dashboardWidgetsJson` blob.
|
||||
*
|
||||
* This guard locks the allowlist shape in place so a refactor can't
|
||||
* silently widen it (admitting a data-bearing mutation) or narrow it
|
||||
* (re-breaking the demo toggles). The whole block is gated on
|
||||
* `DEMO_MODE === "true"`, so production is unaffected by construction —
|
||||
* the final case proves the gate.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
}));
|
||||
|
||||
import { proxy } from "../proxy";
|
||||
|
||||
function makeRequest(pathname: string, method: string): NextRequest {
|
||||
return new NextRequest(`http://localhost${pathname}`, { method });
|
||||
}
|
||||
|
||||
const ORIGINAL_DEMO_MODE = process.env.DEMO_MODE;
|
||||
|
||||
describe("proxy.ts DEMO_MODE mutation allowlist", () => {
|
||||
beforeEach(() => {
|
||||
process.env.DEMO_MODE = "true";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_DEMO_MODE === undefined) {
|
||||
delete process.env.DEMO_MODE;
|
||||
} else {
|
||||
process.env.DEMO_MODE = ORIGINAL_DEMO_MODE;
|
||||
}
|
||||
});
|
||||
|
||||
it("permits the chart-overlay-prefs PUT (above-chart display toggles)", () => {
|
||||
const res = proxy(
|
||||
makeRequest("/api/dashboard/chart-overlay-prefs", "PUT"),
|
||||
);
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("permits the dashboard-widgets PUT (comparison-baseline selector)", () => {
|
||||
const res = proxy(makeRequest("/api/dashboard/widgets", "PUT"));
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
it("still blocks a health-data mutation (POST /api/measurements)", () => {
|
||||
const res = proxy(makeRequest("/api/measurements", "POST"));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("does not drag in the layout-reset DELETE on the allowlisted widgets path", () => {
|
||||
// The allowlist pins method per path; admitting the widgets PUT
|
||||
// must not open DELETE (which wipes the user's dashboard layout).
|
||||
const res = proxy(makeRequest("/api/dashboard/widgets", "DELETE"));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("is inert when DEMO_MODE is off — production is unaffected", () => {
|
||||
delete process.env.DEMO_MODE;
|
||||
// With the demo gate off, the proxy never short-circuits an API
|
||||
// mutation; the route's own auth/handler runs instead.
|
||||
const res = proxy(makeRequest("/api/measurements", "POST"));
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 1131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user