mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Compare commits
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
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@
|
||||
DATABASE_URL="postgresql://healthlog:CHANGE-ME@db:5432/healthlog?schema=public"
|
||||
|
||||
# Postgres password — used both by the db service AND by DATABASE_URL above.
|
||||
# Pick a strong random password, e.g. `openssl rand -base64 24`.
|
||||
# Pick a strong random password, e.g. `openssl rand -hex 32`.
|
||||
POSTGRES_PASSWORD="CHANGE-ME"
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
# Core -- required for boot
|
||||
# -----------------------------------------------------------------------------
|
||||
DATABASE_URL="postgresql://healthlog:<password>@db:5432/healthlog?schema=public"
|
||||
POSTGRES_PASSWORD="<openssl rand -base64 24>"
|
||||
POSTGRES_PASSWORD="<openssl rand -hex 32>"
|
||||
NEXT_PUBLIC_APP_URL="https://healthlog.example.com"
|
||||
APP_URL="https://healthlog.example.com"
|
||||
ENCRYPTION_KEY="<openssl rand -hex 32>"
|
||||
@@ -65,6 +65,9 @@ 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=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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@v4
|
||||
- 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/*
|
||||
@@ -251,14 +271,14 @@ jobs:
|
||||
|
||||
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 +287,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 +295,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 +319,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 +333,15 @@ 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.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 +397,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 +425,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@v4
|
||||
- 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@v4
|
||||
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@v4
|
||||
- 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@v4
|
||||
- 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@v4
|
||||
- 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@v4
|
||||
- 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
|
||||
+376
-51
@@ -2,6 +2,337 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
- **Blood pressure is scored fairly.** The Health Score's blood-pressure share used to be all-or-nothing per reading: a reading slightly over target counted exactly like one far over, so being consistently a little high collapsed the score even when you were well-controlled. It now grades by how close your readings sit to your target — recent readings weighted more — so "borderline" reads as borderline, not a crisis, while genuinely high pressure still scores low.
|
||||
- **Workout heart rate no longer counts against your resting pulse.** Apple Health files every heart-rate sample — including the hundreds recorded during a workout — under one type, and the resting-pulse view was scoring all of them against a resting range, so an active day looked "elevated." Resting-pulse surfaces now use your true resting heart rate (with a careful estimate when that isn't available), and raw heart rate is no longer judged against a resting target.
|
||||
- **The "in review" section is named "Rückblick"** and the spacing above it matches the rest of the overview.
|
||||
|
||||
### Added
|
||||
|
||||
- **Tap a wellness score for the full picture.** Readiness, Recovery, Sleep, Strain, and Stress now open a detail view with an explanation of what the score means and why it matters, the breakdown of how it's built, and a personal assessment of where you stand.
|
||||
- **A warmer, more motivating assessment voice** across the insight assessments — encouraging and grounded in your data, never generic.
|
||||
- **The running version on the admin overview**, with a hint when a newer release is available.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Premium polish on the wellness detail view** — the detail card carries the score's colour, the ring has room to breathe, and the info control sits in the top corner.
|
||||
- **Self-hosted instances resolve a login's location online by default**, using a bundled offline database only when one is configured — so the admin login overview shows a location out of the box.
|
||||
|
||||
## [1.15.11] — 2026-06-07 — a customizable overview
|
||||
|
||||
### Added
|
||||
|
||||
- **Arrange your overview.** Tap **Anpassen** on the Insights overview to rearrange it: drag sections into the order you want, hide the ones you don't, and — inside Vitals — reorder or hide individual metric tiles. Your layout is saved to your account and carries across devices. Tap **Fertig** to apply, **Zurücksetzen** to return to the default arrangement.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Self-hosting docs now generate a URL-safe Postgres password.** The setup examples used `openssl rand -base64 24`, which can emit characters (`/`, `+`, `=`) that break the unescaped `DATABASE_URL` and stop a fresh instance from connecting to its database. Every example now uses `openssl rand -hex 32`.
|
||||
|
||||
## [1.15.10] — 2026-06-07 — Insights overview, medication follow-ups, health score
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Medication compliance now pairs doses to the right slot.** For a twice-daily medication, a dose logged off its usual time could be matched to the wrong slot — so a deliberately-skipped dose might read as taken, or a late evening dose as missed. Doses now snap to their own scheduled slot first, so the percentage reflects what actually happened.
|
||||
- **The card advances to your next real dose.** After you log a dose, the "next dose" line moves to the next upcoming, unresolved slot instead of lingering on one that's already taken or past.
|
||||
- **A single dose time reads cleanly.** A dose with one target time shows "07:00" rather than "07:00 to 07:00".
|
||||
- **Your weight trend counts toward the Health Score even without a target.** The weight pillar used to need a height/target to score; it now scores your weight trend on its own when no target is set, so it stops dropping out of the score.
|
||||
|
||||
### Changed
|
||||
|
||||
- **A consistent Insights overview.** Every section now has its title above its card with a matching icon and even spacing throughout — the daily-briefing icon matches the rest, Trends has an icon, and the period-in-review and cycle sections carry proper headings.
|
||||
- **A clearer cycle ring for new trackers.** With only a little cycle data the ring now shows the four phases in sensible proportions instead of collapsing to a single arc.
|
||||
- **Mobile polish on Insights and Cycle** — the cycle ring fits narrow screens, the cycle tabs no longer crowd, and calendar days meet the tap-target size.
|
||||
- **Your photo in the top bar.** If you've uploaded a profile photo it now shows in the top-right avatar; otherwise the icon remains.
|
||||
- **The notifications area is just "Notifications"** (shorter), and admins get a link to the admin area from the mobile menu too.
|
||||
- **Dark theme by default** for a fresh visitor; an explicit theme choice is still remembered.
|
||||
|
||||
## [1.15.9] — 2026-06-07 — medication compliance and cards
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Compliance now counts a forgotten dose as missed.** A scheduled dose that was never taken or skipped used to quietly drop out of the calculation after a day, which made adherence read higher than it was. It now counts as missed, so your 7 / 30 / 90 / 365-day figures reflect what actually happened — and genuinely differ from one another instead of all looking the same.
|
||||
- **The dashboard medication chart is computed against your schedule.** It previously measured taken doses against the doses you had logged (not the doses you were due), which pushed every window toward 100%. It now measures against the schedule.
|
||||
- **Consistent skip handling.** A dose you deliberately skip is treated the same everywhere (a deliberate pause, not a miss); a dose simply forgotten counts as missed.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The two medication cards are now truly identical.** Both styles render from one shared card body, so structure, spacing, labels, and the streak row line up exactly — only the value content differs (a weekly injectable keeps its relative-day phrasing, a daily dose shows a time). The bottom-spacing mismatch between cards is gone.
|
||||
- **Cards highlight the dose that's due.** When a dose is in its take-now window the card is highlighted; once it is past due the card shows an overdue note, escalating to "Stark überfällig" near the cutoff. After the cutoff the dose counts as missed and the next dose becomes the focus.
|
||||
- **Dose windows are cadence-aware.** A daily dose has a short on-time window then an overdue grace before it counts as missed; a weekly injectable follows a wider, clinically-appropriate window.
|
||||
- **Tidier card.** The compliance bars show the percentage on its own, and the injection-site line was removed from the card (site tracking is unchanged).
|
||||
|
||||
## [1.15.8.1] — 2026-06-07 — large Apple Health imports
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Large Apple Health `export.zip` uploads no longer fail with "Could not locate ZIP End-Of-Central-Directory record".** The multipart upload reader could drop part of a file body when a network chunk ended exactly on the multipart boundary — which only showed up on larger, multi-chunk uploads (a small export imported fine). The reader now handles the boundary across chunks correctly, so the uploaded archive arrives byte-for-byte intact. (#281)
|
||||
|
||||
## [1.15.8] — 2026-06-07 — medication cards
|
||||
|
||||
### Changed
|
||||
|
||||
- **Consistent medication cards.** Both medication card styles now use the same structure and the same labels for the same thing — "Next intake" and "Last intake" everywhere — so the two cards read the same way. The GLP-1 card keeps its relative-day hint ("Saturday 13 Jul (in 7 days)").
|
||||
- **Compliance bars show the counts.** Each compliance bar now shows the doses behind the percentage (for example "100% · 12 / 12"), so two windows that genuinely land at the same percentage are legible at a glance instead of looking like a glitch.
|
||||
|
||||
## [1.15.7] — 2026-06-07 — data import in the browser
|
||||
|
||||
### Added
|
||||
|
||||
- **Import your data from the browser.** Settings → Export is now **Export & Import** and carries a proper import area with two paths:
|
||||
- **Apple Health** — upload your `export.zip` directly. It streams server-side (handles multi-GB archives) and is idempotent, so re-uploading the same archive merges rather than duplicating; progress and the imported/skipped totals are shown as it runs.
|
||||
- **Generic JSON** — upload or paste a JSON document of measurements and mood entries (with a downloadable example and a documented schema) for manually-prepared or converted data.
|
||||
- The data-import schema, the full measurement-type/unit reference, and a "convert a CSV into this" how-to are documented at `docs/integrations/data-import.md`.
|
||||
- **Cycle export.** When cycle tracking is on, the Export area offers an explicit cycle export.
|
||||
|
||||
### Changed
|
||||
|
||||
- **A more compact health-record export card.** The health-record export keeps its prominence but takes far less vertical space.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The Apple Health import is reachable again.** The importer existed on the backend, but the web page the docs pointed to was never present; there is now a real import UI in Settings → Export & Import. (#281)
|
||||
|
||||
## [1.15.6] — 2026-06-06 — settings, insights, and medication-card polish
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The notifications settings panel no longer crashes.** Opening Settings → Notifications could throw a client-side error on some accounts; the panel now renders safely whatever shape the data arrives in.
|
||||
- **Even chevron in dropdowns.** The arrow on the right of selects (timezone, language, units, and everywhere else) now sits with the same inset from the right edge as the label has from the left, instead of pushing against the edge.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Clearer Insights overview.** The period-in-review card now carries a proper heading, and the "signals of the day" section only appears when there's an actual anomaly to flag — when every vital is within your personal range it stays hidden rather than showing an empty header.
|
||||
- **Tidier medication cards.** The next-dose line no longer appears twice on a card, and the injection-site suggestion text has been dropped from the card surface (site tracking and the post-dose picker are unchanged).
|
||||
- **The avatar menu lands in Account.** Clicking your avatar now opens the Account settings directly.
|
||||
- **Shorter integration save button.** The integrations credential form's button now simply reads "Save".
|
||||
- **Tidier API-tokens panel.** The token endpoint URL is shown inline with a copy button, and the "generate another token" button matches the rest of the page rather than standing out in a different colour.
|
||||
- **The export panel's create button is right-aligned**, consistent with other primary actions.
|
||||
|
||||
### Removed
|
||||
|
||||
- **The moodLog integration** has been removed from the Integrations page; it is no longer used.
|
||||
- **The standalone sharing (create-link) feature** has been removed from the Export area.
|
||||
- **The doctor report is no longer offered twice** — it has been removed from the main Export page, where it duplicated the health-record export's own doctor report.
|
||||
|
||||
## [1.15.5] — 2026-06-06 — insights layout, navigation, password-reset CLI
|
||||
|
||||
### Added
|
||||
|
||||
- **Reset a password from the command line.** Operators can reset a user's password from inside the container — useful when someone is locked out — via `docker compose exec app node scripts/reset-password.mjs <username-or-email>`. See `docs/ops/password-reset.md`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **A tidier health-scores row.** With cycle tracking on, your cycle ring takes the place of the strain ring in the Insights health-scores row, so the row stays compact rather than growing an extra tile.
|
||||
- **Cycle sits with your other trackers.** The Cycle entry in the sidebar now appears between Medications and Insights, rather than at the very bottom.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **No more empty gap on the Insights overview.** The cycle summary card on the Insights page was held invisible by a style rule yet still took up space, leaving an odd gap above and below it; it now appears as intended and the spacing is even.
|
||||
|
||||
### Added
|
||||
|
||||
- **Your cycle ring in the health-scores strip.** With cycle tracking on, the Insights health-scores row now includes your cycle ring — current phase and day — alongside the other scores. It shows only when cycle tracking is enabled.
|
||||
|
||||
### Changed
|
||||
|
||||
- **A calmer cycle calendar and cards.** Predicted-period days are now a soft filled marker instead of a thin underline; the "what's happening now" card sits only under the ring (no duplicate above the calendar) and drops its own log button since logging is one tap away at the top; and the predictions and insights tabs lost their repeated "estimates / associations" footers (the caveat is stated once, where it matters).
|
||||
- **Tidier settings.** Section descriptions line up under their headings; the web-push enable action sits in the card header; and the standalone cycle-data export/delete actions are gone from the cycle settings (cycle data is already in the full export).
|
||||
- **Calendar tiles keep their size.** The mood and medication calendar tiles no longer balloon to several times the height of their neighbours when only a short window or a single medication is shown.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Per-score explanations load faster.** The dashboard's batch read now carries each score's deterministic explanation, so the score sheets show their "why" without an extra request.
|
||||
|
||||
### Added
|
||||
|
||||
- **Your cycle at a glance on Insights.** If you track your cycle, the Insights page now shows a compact card with your current phase and cycle day plus the one standout phase relation, with a tap-through to the full cycle insights. It appears only when cycle tracking is on, and the detailed phase analysis stays in the cycle section.
|
||||
|
||||
## [1.15.2] — 2026-06-06 — cycle insights crash fix
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cycle insights no longer crash on mood or blood-glucose relations.** The insights tab could throw and fail to render when a mood or blood-glucose pattern cleared the significance check, because those two metrics were missing their on-screen labels and units. They now render correctly, and an unrecognised metric can never crash the tab again.
|
||||
|
||||
## [1.15.1] — 2026-06-06 — cycle premium parity, reminder polish, fixes
|
||||
|
||||
### Added
|
||||
|
||||
- **Know your phase.** A "what's happening now" card next to the cycle wheel names your current phase, describes it in plain language, and surfaces the symptoms you yourself tend to log in that phase — drawn from your own history, never generic claims — with a one-tap shortcut to log today. It stays in a calm "still learning" state until there's enough of your data to be honest about.
|
||||
- **Your own symptoms.** Add custom symptoms beyond the built-in set, with a name and an icon, straight from the log sheet. Custom symptom names are encrypted at rest, included in export, and removed by the cycle purge.
|
||||
- **Fertile-window reminder.** An opt-in reminder a couple of days before your predicted fertile window — shown only when your goal is to conceive, off by default, and discreet-mode aware. It is an estimate, never a contraceptive or "safe day" claim.
|
||||
- **A richer cycle calendar and history.** Period days now shade by flow intensity, a confirmed ovulation is marked distinctly from a predicted one, and a new cycle-length history chart shows your recent cycles with average, variability, and a regular/irregular read.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Reminders read as plain text.** Medication, mood, and cycle reminder notifications no longer carry decorative emoji; emoji are kept only for system and failure alerts.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Blood-pressure-in-target reads accurately.** The "in target" share on the home health score now pairs systolic and diastolic readings the same way the rest of the app does, so imported readings whose timestamps differ by more than a few minutes are no longer dropped from the calculation.
|
||||
- **A dose is never shown as taken before its time.** Logging a dose can no longer attribute it to a later slot still in the future — a late morning dose will not appear as the evening dose taken early.
|
||||
|
||||
### Security
|
||||
|
||||
- **Closed a server-side request risk in Web Push.** Push subscription endpoints are now validated against internal-network addresses on save and again before delivery, and the cycle-insights endpoint is rate-limited.
|
||||
|
||||
## [1.15.0] — 2026-06-06 — cycle tracking
|
||||
|
||||
### Added
|
||||
|
||||
- **Cycle tracking.** A new, private menstrual-cycle vertical. Log your period (flow and intermenstrual spotting), basal body temperature, cervical mucus, ovulation and pregnancy/progesterone tests, contraception, intercourse, and graded symptoms — all from one quick-entry sheet. A month calendar marks logged days, the predicted period window, the fertile window, and ovulation; the cycle wheel shows your current day and phase at a glance, and a logged day can be edited or deleted.
|
||||
- **Honest predictions.** Next-period and fertile-window forecasts are always shown as a range with a confidence level — never a single certain date — and carry a clear, non-medical caveat (with a stronger one when you track to avoid pregnancy). A "read your body" mode suppresses every interpretation if you prefer to see only your raw data. The forecast is computed from your own data so the iOS client produces the identical result offline.
|
||||
- **Basal-body-temperature chart** for the current cycle, with your fertility signs in the tooltip and the estimated — or confirmed — ovulation marked.
|
||||
- **Phase insights.** An insights tab relates your cycle phase to your vitals — resting heart rate, HRV, sleep, steps, weight, blood glucose, mood — showing where each differs between phases with statistical significance, and which of your logged symptoms cluster in which phase. Associations only, never cause. A per-cycle history lists your recent cycle and period lengths.
|
||||
- **Privacy first.** Cycle tracking is off by default except for accounts set to female, and anyone can turn it on or off from settings. The intent-revealing fields are encrypted at rest behind a default-on toggle; a discreet-notifications mode keeps any reminder generic on the lock screen; period reminders are opt-in per channel; and a one-tap purge hard-deletes every cycle row, the cycle audit trail, and reminder records, and clears your tracking intent.
|
||||
- **Apple Health import.** Reproductive-health categories from an Apple Health export fold into your cycle log, and cycle data is included in the health-record export (PDF and FHIR).
|
||||
|
||||
## [1.14.0] — 2026-06-06 — animated health scores, score explanations, mood-factor correlations, log past doses
|
||||
|
||||
### Added
|
||||
|
||||
- **Each health score now explains itself.** The Readiness, Sleep, Recovery, Strain and Stress score sheets carry a short, plain-language read of why the score landed where it did, drawn from the inputs that moved it. It fills even without an AI provider configured, so the explanation is always there.
|
||||
- **See how your rated factors track your body.** When you rate factors like work, sleep quality or stress on a mood entry, HealthLog now relates those ratings to your vitals — for example how your sleep or resting heart rate differs on the days you rate work low versus high — as a new card that the assistant can also cite. Associations only: each is shown with a confidence level, computed over a stable window, and never presented as cause.
|
||||
- **Log a dose you took earlier.** The medications "Add" button now offers a choice — create a new medication, or log an intake — and the intake path accepts a backdated date and time against an existing medication.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The health scores got a signature redesign.** The score strip reveals with a calm, staggered animation — each ring springs to its value while the number counts up — with a distinct colour per score (readiness green, recovery teal, sleep blue, stress amber, strain violet), a soft glow, and a faint "vs your normal" reference arc on the scores that carry a history. The motion plays once per visit and fully respects reduced-motion; the colours hold their contrast in both light and dark themes.
|
||||
- **Sparse medications show where you stand at a glance.** A weekly injection or other "every N days" medication now shows its open-cycle status on the card — "next dose in N days", "due today", "overdue", or "no cycles yet" — instead of leaning on a percentage that says little between doses.
|
||||
- **Smaller refinements across Insights.** The three trend cards (mood, weight, blood pressure) are now exactly the same height; the daily-briefing heading sits above its card; and the assistant tone and length controls sit side by side to cut scrolling.
|
||||
|
||||
## [1.13.3] — 2026-06-05 — health score scores blood pressure again, steps in Insights
|
||||
|
||||
### Fixed
|
||||
@@ -742,14 +1073,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.
|
||||
@@ -771,7 +1102,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.
|
||||
@@ -788,11 +1119,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
|
||||
@@ -822,7 +1153,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
|
||||
|
||||
@@ -832,7 +1163,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.
|
||||
|
||||
@@ -977,7 +1308,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
|
||||
|
||||
@@ -999,7 +1330,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
|
||||
|
||||
@@ -1009,12 +1340,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.
|
||||
|
||||
@@ -1028,7 +1359,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
|
||||
|
||||
@@ -1048,7 +1379,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
|
||||
@@ -1140,7 +1471,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.
|
||||
|
||||
@@ -1477,7 +1808,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
|
||||
|
||||
@@ -1544,11 +1875,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.
|
||||
@@ -1559,9 +1890,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.
|
||||
@@ -1663,7 +1994,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
|
||||
@@ -1807,7 +2138,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.
|
||||
|
||||
@@ -1900,7 +2231,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
|
||||
@@ -2329,7 +2660,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.
|
||||
|
||||
@@ -2385,7 +2716,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
|
||||
|
||||
@@ -2454,7 +2785,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
|
||||
|
||||
@@ -2704,7 +3035,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
|
||||
@@ -3447,8 +3778,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
|
||||
|
||||
@@ -3643,9 +3973,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
|
||||
@@ -3767,8 +4097,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
|
||||
@@ -3832,9 +4161,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
|
||||
@@ -3860,12 +4188,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
|
||||
@@ -3909,8 +4237,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
|
||||
@@ -4619,8 +4946,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
|
||||
@@ -5077,7 +5403,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
|
||||
@@ -5671,8 +5997,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
|
||||
@@ -6231,7 +6557,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
|
||||
@@ -6247,7 +6573,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}`
|
||||
@@ -6549,8 +6875,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`.
|
||||
|
||||
@@ -6622,9 +6947,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.
|
||||
|
||||
+3
-3
@@ -20,7 +20,7 @@ cp .env.example .env
|
||||
Edit `.env` and configure at minimum:
|
||||
|
||||
```
|
||||
POSTGRES_PASSWORD=<base64, 24 bytes>
|
||||
POSTGRES_PASSWORD=<hex, 32 bytes>
|
||||
DATABASE_URL="postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog"
|
||||
ENCRYPTION_KEY=<64-char hex>
|
||||
API_TOKEN_HMAC_KEY=<64-char hex>
|
||||
@@ -29,7 +29,7 @@ API_TOKEN_HMAC_KEY=<64-char hex>
|
||||
Generate the three secrets:
|
||||
|
||||
```bash
|
||||
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
|
||||
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
|
||||
```
|
||||
@@ -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,651 +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="2316" height="1207" alt="HealthLog dashboard: health metric tiles, trend charts, and medication overview" src="https://github.com/user-attachments/assets/aac0931d-5ea2-45b8-9a12-5f2f013d19e5" />
|
||||
|
||||
## 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.
|
||||
|
||||
## 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 -base64 24)" >> .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 "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
|
||||
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 |
|
||||
| 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -684,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>
|
||||
+21
-1
@@ -15,6 +15,11 @@ services:
|
||||
image: ghcr.io/mbombeck/healthlog: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 +70,13 @@ 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:-}"
|
||||
# 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
|
||||
@@ -118,8 +130,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}"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Apple Health → cycle mapping
|
||||
|
||||
How HealthLog folds Apple Health reproductive samples into the cycle model.
|
||||
Both the `export.xml` importer and the iOS batch contract share one mapping
|
||||
table: `src/lib/cycle/healthkit-mapping.ts`. This page is the human-readable
|
||||
companion — keep the two in sync.
|
||||
|
||||
Reproductive samples route into **cycle day-logs** (`CycleDayLog`), never into
|
||||
`Measurement`. Each Apple Health day collapses into one canonical day-log per
|
||||
`(user, date)`; the importer writes `source = APPLE_HEALTH` with a synthetic
|
||||
per-day `externalId` (`hkcycle:<YYYY-MM-DD>`) so a re-import upserts the same
|
||||
row. The fold only runs when cycle tracking is enabled for the account.
|
||||
|
||||
## Day-log fields
|
||||
|
||||
| HealthKit category type | CycleDayLog field | Values |
|
||||
|---|---|---|
|
||||
| `MenstrualFlow` | `flow` | unspecified→LIGHT, light→LIGHT, medium→MEDIUM, heavy→HEAVY, none→NONE |
|
||||
| `IntermenstrualBleeding` | `intermenstrualBleeding` | presence → `true` |
|
||||
| `CervicalMucusQuality` | `cervicalMucus` | dry→DRY, sticky→STICKY, creamy→CREAMY, watery→WATERY, eggWhite→EGG_WHITE |
|
||||
| `OvulationTestResult` | `ovulationTest` | negative→NEGATIVE, lhSurge→POSITIVE_LH_SURGE, indeterminate→INDETERMINATE, estrogenSurge→ESTROGEN_SURGE |
|
||||
| `SexualActivity` | `sexualActivity` + `protectedSex` | presence → `true`; `HKMetadataKeySexualActivityProtectionUsed` → `protectedSex` |
|
||||
| `PregnancyTestResult` | `pregnancyTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
|
||||
| `ProgesteroneTestResult` | `progesteroneTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
|
||||
| `Contraceptive` | `contraceptive` (+ profile nudge) | unspecified→UNSPECIFIED, implant→IMPLANT, injection→INJECTION, iud→IUD, ring→INTRAVAGINAL_RING, oral→ORAL, patch→PATCH, emergency→EMERGENCY |
|
||||
|
||||
HealthLog's `SPOTTING` flow level has no HealthKit counterpart; it is only ever
|
||||
produced by a manual entry. HealthKit `unspecified` flow maps to `LIGHT` (a
|
||||
logged-but-unspecified bleeding day is at least light flow).
|
||||
|
||||
## Symptom category types → seeded symptom keys
|
||||
|
||||
Symptom category samples link to the seeded `CycleSymptom` catalogue by key. A
|
||||
sample whose severity is *not present* (codepoint 1) does NOT create a link.
|
||||
|
||||
| HealthKit category type | CycleSymptom key |
|
||||
|---|---|
|
||||
| `AbdominalCramps` | `cramps` |
|
||||
| `Headache` | `headache` |
|
||||
| `Bloating` | `bloating` |
|
||||
| `Acne` | `acne` |
|
||||
| `BreastPain` | `breast_tenderness` |
|
||||
| `Fatigue` | `fatigue` |
|
||||
| `LowerBackPain` | `back_pain` |
|
||||
| `SleepChanges` | `insomnia` |
|
||||
| `MoodChanges` | `mood_swings` |
|
||||
| `AppetiteChanges` | `food_cravings` |
|
||||
| `Nausea` | `nausea` |
|
||||
|
||||
## Profile-level signals
|
||||
|
||||
A `Contraceptive` sample asserting an active method also nudges the
|
||||
`CycleProfile.goal` to `AVOID_PREGNANCY` — but only when the goal is still on
|
||||
its `GENERAL_HEALTH` default, so an explicit user choice is never overwritten.
|
||||
|
||||
## Basal body temperature
|
||||
|
||||
`basalBodyTemperature` is **not** routed here. It stays a
|
||||
`Measurement(BODY_TEMPERATURE)` (the system of record for the rollup tier and
|
||||
the cross-source source-priority ladder) and is mirrored onto
|
||||
`CycleDayLog.basalBodyTempC` for the cycle chart. Apple Watch wrist/skin
|
||||
temperature already flows to `WRIST_TEMPERATURE` / `SKIN_TEMPERATURE` and feeds
|
||||
the temperature-trend ovulation method directly from `Measurement`.
|
||||
|
||||
## Deferred
|
||||
|
||||
| HealthKit category type | Status |
|
||||
|---|---|
|
||||
| `Pregnancy`, `Lactation` | Deferred — no `CycleProfile` column yet (pregnancy-mode is a later release). |
|
||||
| `BleedingDuringPregnancy`, `BleedingAfterPregnancy` | Deferred — pregnancy-mode. |
|
||||
| `IrregularMenstrualCycles`, `InfrequentMenstrualCycles`, `ProlongedMenstrualPeriods`, `PersistentIntermenstrualBleeding` | Server-derived from the cycle engine, never ingested. |
|
||||
+2773
-197
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
|
||||
|
||||
|
||||
@@ -35,10 +35,11 @@ clinical records, electrocardiograms). The importer reads
|
||||
|
||||
## 2. Upload to HealthLog
|
||||
|
||||
Open the import page from the in-app settings tree. Drop the
|
||||
`export.zip` onto the upload control. The browser streams the body
|
||||
directly to the server; nothing is buffered into memory on either
|
||||
side.
|
||||
Open **Settings → Export & Import** in the app and drop the
|
||||
`export.zip` onto the Apple Health upload control (or click to pick the
|
||||
file). The browser streams the body directly to the server; nothing is
|
||||
buffered into memory on either side. A progress indicator polls the
|
||||
job until it reports the imported / skipped counts.
|
||||
|
||||
The endpoint is `POST /api/import/apple-health-export`. The handler:
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
# Data import
|
||||
|
||||
HealthLog has two ways to bring data in, both reachable from
|
||||
**Settings → Export & Import**:
|
||||
|
||||
1. An **Apple Health `export.zip`** upload — the full archive from the
|
||||
iPhone Health app, parsed server-side. See
|
||||
[`apple-health.md`](./apple-health.md) for the end-to-end walkthrough,
|
||||
the HealthKit type mapping, and the streaming / idempotency model.
|
||||
2. A **generic JSON import** — a small, explicit payload of measurements
|
||||
and mood entries, useful for restoring a partial export or migrating
|
||||
from another tracker. This page documents that format.
|
||||
|
||||
Both paths skip rows that already exist, so re-running an import is
|
||||
safe — it merges rather than duplicating.
|
||||
|
||||
## 1. Apple Health `export.zip`
|
||||
|
||||
On the iPhone: **Health app → profile picture → Export All Health
|
||||
Data**, then move the resulting `export.zip` somewhere you can reach
|
||||
from a browser. In HealthLog open **Settings → Export & Import** and drop
|
||||
the archive onto the Apple Health control (or click to pick it). The
|
||||
upload streams to the server — a multi-gigabyte archive never buffers in
|
||||
the browser — and a background job parses it while a progress indicator
|
||||
polls for the imported / skipped counts.
|
||||
|
||||
Re-uploading the same archive resolves to the same job by content hash,
|
||||
so it merges instead of creating duplicates. Clinical records
|
||||
(electrocardiograms, lab documents) are intentionally skipped. The full
|
||||
type mapping lives in [`apple-health.md`](./apple-health.md).
|
||||
|
||||
## 2. Generic JSON import
|
||||
|
||||
`POST /api/import` accepts a single JSON object with two optional
|
||||
arrays:
|
||||
|
||||
```json
|
||||
{
|
||||
"measurements": [ /* … */ ],
|
||||
"moodEntries": [ /* … */ ]
|
||||
}
|
||||
```
|
||||
|
||||
Each array holds at most **10 000** entries. The endpoint is rate-limited
|
||||
to **5 imports per hour per account**. Existing rows (matched on the
|
||||
type + timestamp uniqueness, or on `externalId` for mood entries) are
|
||||
skipped and counted under `skipped` in the response.
|
||||
|
||||
### Measurement entry
|
||||
|
||||
| Field | Required | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
|
||||
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
|
||||
| `unit` | yes | string | The canonical unit for the type (see the table). |
|
||||
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
|
||||
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
|
||||
| `notes` | no | string | Optional free text. |
|
||||
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). |
|
||||
|
||||
### Mood entry
|
||||
|
||||
| Field | Required | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `date` | yes | string | `YYYY-MM-DD`. |
|
||||
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
|
||||
| `score` | yes | integer | `1`–`5`. |
|
||||
| `tags` | no | string | Comma-separated tags. |
|
||||
| `loggedAt` | no | string | ISO-8601 datetime; defaults to now. |
|
||||
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
|
||||
|
||||
### `MeasurementType` values and canonical units
|
||||
|
||||
Storage is always in the canonical unit listed here; the UI converts for
|
||||
display per the user's preference. Event-class types record a single
|
||||
fired event (`value` is `1`) and are normally produced by a device, not
|
||||
hand-authored.
|
||||
|
||||
| Type | Canonical unit |
|
||||
|---|---|
|
||||
| `WEIGHT` | kg |
|
||||
| `BLOOD_PRESSURE_SYS` | mmHg |
|
||||
| `BLOOD_PRESSURE_DIA` | mmHg |
|
||||
| `PULSE` | bpm |
|
||||
| `RESTING_HEART_RATE` | bpm |
|
||||
| `WALKING_HEART_RATE_AVERAGE` | bpm |
|
||||
| `AVERAGE_HEART_RATE` | bpm |
|
||||
| `MAX_HEART_RATE` | bpm |
|
||||
| `CARDIO_RECOVERY` | bpm |
|
||||
| `HEART_RATE_VARIABILITY` | ms (SDNN) |
|
||||
| `HRV_RMSSD` | ms (RMSSD) |
|
||||
| `RESPIRATORY_RATE` | count/min |
|
||||
| `OXYGEN_SATURATION` | percent (0–100) |
|
||||
| `BLOOD_GLUCOSE` | mg/dL |
|
||||
| `BODY_FAT` | percent (0–100) |
|
||||
| `FAT_MASS` | kg |
|
||||
| `FAT_FREE_MASS` | kg |
|
||||
| `LEAN_BODY_MASS` | kg |
|
||||
| `MUSCLE_MASS` | kg |
|
||||
| `BONE_MASS` | kg |
|
||||
| `TOTAL_BODY_WATER` | kg |
|
||||
| `VISCERAL_FAT` | rating |
|
||||
| `BODY_MASS_INDEX` | kg/m² |
|
||||
| `BODY_TEMPERATURE` | °C (core) |
|
||||
| `SKIN_TEMPERATURE` | °C (dermal spot) |
|
||||
| `WRIST_TEMPERATURE` | °C (overnight wrist) |
|
||||
| `SLEEP_DURATION` | minutes |
|
||||
| `SLEEP_NEED` | minutes |
|
||||
| `SLEEP_PERFORMANCE` | percent (0–100) |
|
||||
| `SLEEP_EFFICIENCY` | percent (0–100) |
|
||||
| `SLEEP_CONSISTENCY` | percent (0–100) |
|
||||
| `SLEEP_DISTURBANCE_COUNT` | count |
|
||||
| `BREATHING_DISTURBANCES` | count |
|
||||
| `ACTIVITY_STEPS` | count |
|
||||
| `FLIGHTS_CLIMBED` | count |
|
||||
| `WALKING_RUNNING_DISTANCE` | metres |
|
||||
| `SIX_MINUTE_WALK_DISTANCE` | metres |
|
||||
| `WALKING_STEP_LENGTH` | metres |
|
||||
| `WALKING_SPEED` | m/s |
|
||||
| `STAIR_ASCENT_SPEED` | m/s |
|
||||
| `STAIR_DESCENT_SPEED` | m/s |
|
||||
| `WALKING_STEADINESS` | percent (0–100) |
|
||||
| `WALKING_ASYMMETRY` | percent (0–100) |
|
||||
| `WALKING_DOUBLE_SUPPORT` | percent (0–100) |
|
||||
| `ACTIVE_ENERGY_BURNED` | kcal |
|
||||
| `ENERGY_EXPENDITURE_KJ` | kJ |
|
||||
| `VO2_MAX` | mL/(kg·min) |
|
||||
| `PULSE_WAVE_VELOCITY` | m/s |
|
||||
| `VASCULAR_AGE` | years |
|
||||
| `FALL_COUNT` | count |
|
||||
| `AUDIO_EXPOSURE_ENV` | dBA |
|
||||
| `AUDIO_EXPOSURE_HEADPHONE` | dBA |
|
||||
| `TIME_IN_DAYLIGHT` | minutes |
|
||||
| `RECOVERY_SCORE` | score (0–100) |
|
||||
| `STRESS_SCORE` | score (0–100) |
|
||||
| `STRAIN_SCORE` | score (0–100) |
|
||||
| `DAY_STRAIN` | score (0–21) |
|
||||
| `WORKOUT_STRAIN` | score (0–21) |
|
||||
| `AUDIO_EXPOSURE_EVENT` | count (event) |
|
||||
| `IRREGULAR_RHYTHM_NOTIFICATION` | event |
|
||||
| `HIGH_HEART_RATE_EVENT` | event |
|
||||
| `LOW_HEART_RATE_EVENT` | event |
|
||||
| `WALKING_STEADINESS_EVENT` | event |
|
||||
| `BREATHING_DISTURBANCE_EVENT` | event |
|
||||
|
||||
The live, machine-readable list of writable types and their units is
|
||||
also served by `GET /api/meta/capabilities` under `ingest.quantityTypes`
|
||||
— the authoritative source if this table ever lags a release.
|
||||
|
||||
### Worked example
|
||||
|
||||
The **Download example** button in **Settings → Export & Import** mints
|
||||
exactly this payload, which is a valid import body:
|
||||
|
||||
```json
|
||||
{
|
||||
"measurements": [
|
||||
{
|
||||
"type": "WEIGHT",
|
||||
"value": 80.5,
|
||||
"unit": "kg",
|
||||
"measuredAt": "2026-05-01T08:00:00.000Z",
|
||||
"source": "manual",
|
||||
"notes": "morning"
|
||||
},
|
||||
{
|
||||
"type": "BLOOD_PRESSURE_SYS",
|
||||
"value": 120,
|
||||
"unit": "mmHg",
|
||||
"measuredAt": "2026-05-01T08:05:00.000Z"
|
||||
},
|
||||
{
|
||||
"type": "BLOOD_PRESSURE_DIA",
|
||||
"value": 80,
|
||||
"unit": "mmHg",
|
||||
"measuredAt": "2026-05-01T08:05:00.000Z"
|
||||
}
|
||||
],
|
||||
"moodEntries": [
|
||||
{
|
||||
"date": "2026-05-01",
|
||||
"mood": "GUT",
|
||||
"score": 4,
|
||||
"tags": "work,exercise"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The response is a count envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": { "measurements": 3, "moodEntries": 1, "skipped": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
## Converting a CSV into the import JSON
|
||||
|
||||
A spreadsheet export is the most common starting point. The shape is
|
||||
small enough to convert by hand for a few rows, or with a one-off script
|
||||
for many. The steps are the same either way:
|
||||
|
||||
1. **One CSV per concept.** Keep measurements and mood in separate
|
||||
sheets — they map to the two arrays. A blood-pressure reading is two
|
||||
measurement rows (`BLOOD_PRESSURE_SYS` and `BLOOD_PRESSURE_DIA`) that
|
||||
share a `measuredAt`.
|
||||
2. **Pick the `type`.** Map each CSV column to a `MeasurementType` from
|
||||
the table above. A "Weight (kg)" column becomes `type: "WEIGHT"`,
|
||||
`unit: "kg"`.
|
||||
3. **Normalise the unit.** Convert each value into the canonical unit
|
||||
before writing it — e.g. pounds → kg, hours of sleep → minutes,
|
||||
`mmol/L` glucose → `mg/dL`. The importer rejects values outside the
|
||||
plausible range for the type, which catches most unit mistakes.
|
||||
4. **Format the timestamp.** Turn the CSV date/time column into an
|
||||
ISO-8601 `measuredAt` (mood uses a `YYYY-MM-DD` `date`). Include the
|
||||
timezone offset (`Z` for UTC) so the reading lands on the right day.
|
||||
5. **Emit the two arrays.** Collect the rows into `measurements` and
|
||||
`moodEntries` as shown above and paste the result into the JSON
|
||||
import control, or upload it as a `.json` file. Keep each array under
|
||||
10 000 entries; split a larger history across runs (5 per hour).
|
||||
|
||||
Because the import is idempotent on the unique constraints, you can
|
||||
re-run a corrected file over a partial one without creating duplicates.
|
||||
@@ -0,0 +1,117 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Password reset (operator)
|
||||
|
||||
A self-hosted operator can reset a single user's password from inside the
|
||||
running container. This is an **operator-only** maintenance path — there is no
|
||||
user-facing or API route for it, and it bypasses the normal account-recovery
|
||||
flow. Use it only for an account you administer (for example, after a forgotten
|
||||
password on a single-user instance).
|
||||
|
||||
> **Operator-only.** The script writes directly to the `users` table and is
|
||||
> intended to be run by the host operator with database access. Treat the new
|
||||
> password like any other secret: pass it on stdin (it is read without echoing)
|
||||
> rather than as a shell argument where it would land in shell history and the
|
||||
> process list.
|
||||
|
||||
## Run it
|
||||
|
||||
The reset CLI ships in the image and runs under plain `node` against the built
|
||||
runtime (the standalone image strips `tsx`, so this is a `.mjs` that uses the
|
||||
in-image `pg` and `@node-rs/argon2` directly and reuses the app's exact Argon2id
|
||||
parameters):
|
||||
|
||||
```sh
|
||||
docker compose exec app node scripts/reset-password.mjs <username-or-email>
|
||||
```
|
||||
|
||||
The script prompts for the new password without echoing it, then prints a
|
||||
confirmation that names only the user — never the password:
|
||||
|
||||
```
|
||||
New password (input hidden):
|
||||
reset-password: password updated for "alice"
|
||||
```
|
||||
|
||||
You can also pass the password as a second argument for non-interactive use,
|
||||
but prefer the stdin prompt so the secret stays out of shell history:
|
||||
|
||||
```sh
|
||||
docker compose exec app node scripts/reset-password.mjs alice 'a-strong-passphrase'
|
||||
```
|
||||
|
||||
## Behaviour
|
||||
|
||||
- The identifier is matched **case-insensitively** against `username` **or**
|
||||
`email`, mirroring the login lookup.
|
||||
- The match must resolve to exactly one user. Zero matches exits non-zero with
|
||||
`no user matches "<id>"`; multiple matches exits non-zero and refuses to guess.
|
||||
- The new password must be at least 12 characters (the app's minimum).
|
||||
- The password is hashed with Argon2id using the same cost parameters as the
|
||||
application (`src/lib/auth/argon2-params.mjs`), so the stored hash is
|
||||
identical to one the app would mint.
|
||||
- `DATABASE_URL` is read from the environment — the same variable the app uses,
|
||||
honouring any `sslmode=` in the connection string.
|
||||
|
||||
## Source checkout
|
||||
|
||||
From a source checkout (not the production image) the same script runs under
|
||||
Node directly:
|
||||
|
||||
```sh
|
||||
DATABASE_URL='postgres://…' node scripts/reset-password.mjs <username-or-email>
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
# Cycle-data threat model
|
||||
|
||||
A compact statement of what self-hosting HealthLog's cycle tracking
|
||||
protects against and what it does not. Pair this with the operator
|
||||
guidance in
|
||||
[`self-hosting/reproductive-health-data.md`](../self-hosting/reproductive-health-data.md).
|
||||
|
||||
The scope here is reproductive-health data specifically (menstrual,
|
||||
fertility, pregnancy, contraception, sexual-activity fields), because
|
||||
that is the category where the "who can reach my data" question carries
|
||||
the highest stakes.
|
||||
|
||||
## What self-hosting + encryption-at-rest protects against
|
||||
|
||||
- **A cloud vendor holding your data.** There is no SaaS account and no
|
||||
vendor key escrow. No third party can be served process for data it
|
||||
does not have.
|
||||
- **A stolen disk or a leaked backup, while the key is held separately.**
|
||||
The intent-revealing fields are AES-256-GCM encrypted at rest; a disk
|
||||
image without the `ENCRYPTION_KEYS` value does not yield them.
|
||||
- **Casual lock-screen disclosure.** Discreet notifications collapse
|
||||
every cycle reminder to a generic "HealthLog reminder" so the event is
|
||||
never named on a lock screen.
|
||||
- **Third-party analytics / ad-tech leakage.** HealthLog ships no
|
||||
telemetry and no third-party trackers; cycle data is never sent to an
|
||||
analytics or advertising endpoint.
|
||||
|
||||
## What it does NOT protect against
|
||||
|
||||
- **Seizure or compulsion of your own host.** If the running machine is
|
||||
seized, imaged, or compelled, the data is on it. Encryption at rest
|
||||
does not help against a live host or a key stored beside the data.
|
||||
- **Your hosting provider.** A VPS provider can snapshot the disk and
|
||||
can be served process. Self-hosting on rented infrastructure narrows
|
||||
the set of parties that hold the data; it does not eliminate them.
|
||||
- **Your own backups.** Any `pg_dump` or off-host export carries the
|
||||
same data and must be protected as part of the same surface.
|
||||
- **A compromised or unlocked client device.** A phone or laptop with a
|
||||
live session or cached client holds data locally. Server-side
|
||||
protection does nothing for an unlocked device.
|
||||
- **Re-identification from "plaintext" fields.** Flow, basal body
|
||||
temperature, ovulation tests, cervical mucus, and cycle dates stay in
|
||||
plaintext so the engines can read them. They are health observations,
|
||||
not the disclosure-sensitive intent fields — but they are still
|
||||
health data on your host.
|
||||
|
||||
## The one-line version
|
||||
|
||||
Self-hosting means **no cloud vendor to subpoena**. It does **not** mean
|
||||
the data is beyond reach: your own infrastructure, your provider, your
|
||||
backups, and your devices are all still part of the surface. Size your
|
||||
operator-side hardening to your own threat model.
|
||||
@@ -38,7 +38,7 @@ HealthLog needs three secrets before it will boot. Generate them with
|
||||
`openssl` and append to `.env`:
|
||||
|
||||
```bash
|
||||
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
|
||||
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
|
||||
```
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Reproductive-health data on your own server
|
||||
|
||||
HealthLog's cycle tracking stores menstrual, fertility, and related
|
||||
reproductive-health data the same way it stores every other metric: in
|
||||
the PostgreSQL database that runs on the host you control, encrypted at
|
||||
rest for the intent-revealing fields. This page explains what that
|
||||
arrangement does and does not change about who can reach that data, in
|
||||
plain terms and without overstating the protection.
|
||||
|
||||
## What self-hosting changes
|
||||
|
||||
A consumer cycle-tracking app runs on the vendor's servers. Your
|
||||
reproductive-health data lives in their database, under their keys, in
|
||||
their jurisdiction. If that vendor receives a subpoena, a civil
|
||||
discovery request, or a law-enforcement demand it judges valid, the
|
||||
vendor — not you — decides how to respond, and it holds the data and the
|
||||
keys needed to comply. Several vendors state in their own privacy
|
||||
policies that they comply with valid legal process; that is a factual
|
||||
description of where the data sits, not an accusation.
|
||||
|
||||
Self-hosting removes that vendor from the picture. There is no cloud
|
||||
account holding your cycle data, no vendor key escrow, and no third
|
||||
party that can be served process to hand over data it does not have. The
|
||||
honest framing is narrow and worth stating precisely:
|
||||
|
||||
> Self-hosting means there is no cloud vendor to subpoena for your
|
||||
> reproductive-health data. It does **not** make the data
|
||||
> unreachable.
|
||||
|
||||
The encryption-at-rest layer is real: the intent-revealing day-log
|
||||
fields (free-text notes always; pregnancy, contraception, and
|
||||
sexual-activity fields by default for this category) are encrypted with
|
||||
AES-256-GCM under a key you supply via `ENCRYPTION_KEYS`, and the
|
||||
application fails closed rather than ever writing those fields in
|
||||
plaintext. Flow, basal body temperature, ovulation-test results,
|
||||
cervical-mucus observations, and the cycle dates stay in plaintext
|
||||
because the prediction and correlation engines read them; they are
|
||||
health observations, not the disclosure-sensitive intent fields.
|
||||
|
||||
## The residual threat (read this part)
|
||||
|
||||
Self-hosting moves the data onto a box you own. That box can still be
|
||||
reached:
|
||||
|
||||
- **Physical or legal seizure of your own host.** If the machine
|
||||
running PostgreSQL — your VPS, your home server, your NAS — is seized,
|
||||
imaged, or compelled, the data is on it. Encryption at rest protects
|
||||
against a stolen disk or a leaked backup only while the key is *not*
|
||||
also available; if the host is running, or the key is stored next to
|
||||
the data, that protection is gone.
|
||||
- **Your hosting provider.** A VPS provider can snapshot your disk and
|
||||
can be served process the same way a SaaS vendor can. Self-hosting on
|
||||
rented infrastructure narrows the set of parties who hold your data;
|
||||
it does not reduce it to zero.
|
||||
- **Your own backups.** Off-host backups (the optional S3 export, or any
|
||||
`pg_dump` you take) carry the same data. Encrypt them, control where
|
||||
they land, and treat them as part of the same threat surface.
|
||||
- **The device that talks to the server.** A phone or laptop with a
|
||||
live session, a cached PWA, or the native client holds data locally
|
||||
too. Protecting the server does nothing for an unlocked device.
|
||||
|
||||
The promise is therefore specific: **no cloud vendor to subpoena**, not
|
||||
**data nobody can ever reach**. If your threat model includes seizure of
|
||||
your own infrastructure, encryption at rest plus a key you keep off the
|
||||
host is the floor, and the threat-model note below covers the rest.
|
||||
|
||||
## Public record
|
||||
|
||||
The reason this distinction matters is documented in public legal
|
||||
record, not speculation:
|
||||
|
||||
- In 2021 the U.S. Federal Trade Commission settled with the maker of a
|
||||
period- and fertility-tracking app over allegations that it shared
|
||||
users' health data with third-party analytics and advertising
|
||||
services despite privacy promises. The settlement is public record.
|
||||
- In 2025 a federal jury found that the same app's maker violated a
|
||||
California privacy statute over the sharing of menstrual-tracking
|
||||
data; the verdict and the underlying class action are public record.
|
||||
|
||||
These are cited as factual public record to explain *why* the
|
||||
"who holds the data" question is the one that matters for this category.
|
||||
HealthLog makes no claim about any other product's current practices.
|
||||
|
||||
## Practical hardening
|
||||
|
||||
If reproductive-health privacy is the reason you self-host, the
|
||||
operator-side steps that matter most:
|
||||
|
||||
- Keep `ENCRYPTION_KEYS` off the host that runs PostgreSQL where your
|
||||
threat model allows it (an injected secret at boot, not a file next to
|
||||
the database). See `docs/ops/encryption-key-rotation.md`.
|
||||
- Turn off any integration you do not need. The fewer outbound paths,
|
||||
the smaller the surface.
|
||||
- Encrypt and control your backups. The off-host backup feature uses
|
||||
AES-GCM; the destination bucket and its access are yours to lock down.
|
||||
- Use the in-app **discreet notifications** toggle so cycle reminders
|
||||
never name the event on a lock screen, and the **client-managed
|
||||
reminders** path if you want the device, not the server, to own those
|
||||
notifications.
|
||||
- Lock the devices that hold a live session.
|
||||
|
||||
See also the [threat-model note](../security/cycle-data-threat-model.md)
|
||||
for a compact "protects against / does not protect against" summary.
|
||||
@@ -97,16 +97,13 @@ In the NPM UI:
|
||||
6. **Advanced tab — Custom Nginx Configuration:**
|
||||
|
||||
```nginx
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 300s;
|
||||
```
|
||||
|
||||
Nginx Proxy Manager configures the standard forwarded headers
|
||||
automatically and adds the WebSocket upgrade directives when
|
||||
Websockets Support is enabled.
|
||||
|
||||
The 300-second read timeout matters for long-running insight
|
||||
generations — anything shorter will cut streamed responses mid-flight.
|
||||
|
||||
|
||||
+64
-6
@@ -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.
|
||||
|
||||
@@ -193,7 +198,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 +321,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 +430,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
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* Pixel-5 mobile smoke for the Insights overview + the cycle vertical
|
||||
* (v1.15.10). Mirrors `mobile-viewport.spec.ts`. Asserts:
|
||||
*
|
||||
* 1. `/insights` has no horizontal page scroll on the Pixel-5 width — the
|
||||
* core guarantee behind the cycle-ring-width fix (the 120 px ring tile
|
||||
* used to overflow narrow grid cells).
|
||||
* 2. `/cycle` has no horizontal page scroll on the Pixel-5 width — covers
|
||||
* the tab-strip-overflow fix (the four long German labels used to force
|
||||
* a horizontal scroll at 375 px) and the responsive cycle ring.
|
||||
* 3. The cycle-calendar day cells clear the WCAG 2.5.5 mobile tap-target
|
||||
* height floor (44 CSS px) — the `min-h-10` → `min-h-11` bump.
|
||||
*
|
||||
* The cycle feature gate is enabled by rewriting the real `/api/auth/me`
|
||||
* response with `cycleTrackingEnabled: true` (every other field stays
|
||||
* authentic), and the `/api/cycle/*` reads are mocked so the wheel, tab strip,
|
||||
* and calendar render deterministically without seeding cycle data.
|
||||
*
|
||||
* Runs only on the `chromium-mobile` project (Pixel 5). The desktop project
|
||||
* skips this whole describe block.
|
||||
*/
|
||||
|
||||
function ymd(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** A 90-day-back → 180-day-forward calendar with a recent labelled cycle run
|
||||
* so the wheel resolves a current phase + day and the calendar paints cells. */
|
||||
function buildCalendarDays(): Array<Record<string, unknown>> {
|
||||
const days: Array<Record<string, unknown>> = [];
|
||||
const start = new Date();
|
||||
start.setDate(start.getDate() - 12);
|
||||
// 5 menstrual, 6 follicular, 1 ovulatory, then luteal through today.
|
||||
const plan: string[] = [
|
||||
...Array<string>(5).fill("MENSTRUAL"),
|
||||
...Array<string>(6).fill("FOLLICULAR"),
|
||||
"OVULATORY",
|
||||
"LUTEAL",
|
||||
"LUTEAL",
|
||||
];
|
||||
for (let i = 0; i < plan.length; i++) {
|
||||
const d = new Date(start);
|
||||
d.setDate(start.getDate() + i);
|
||||
days.push({
|
||||
date: ymd(d),
|
||||
phase: plan[i],
|
||||
isPredictedPeriod: false,
|
||||
isFertileWindow: plan[i] === "OVULATORY",
|
||||
isPredictedOvulation: false,
|
||||
isPeriodLogged: plan[i] === "MENSTRUAL",
|
||||
flow: plan[i] === "MENSTRUAL" ? "MEDIUM" : null,
|
||||
hasSymptoms: false,
|
||||
confidence: 1,
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
});
|
||||
}
|
||||
return days;
|
||||
}
|
||||
|
||||
const CALENDAR_BODY = {
|
||||
data: {
|
||||
profile: {
|
||||
goal: "GENERAL_HEALTH",
|
||||
rawChartMode: false,
|
||||
predictionEnabled: true,
|
||||
cyclesObserved: 1,
|
||||
},
|
||||
prediction: null,
|
||||
days: buildCalendarDays(),
|
||||
meta: { generatedAt: new Date().toISOString() },
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
const PROFILE_BODY = {
|
||||
data: {
|
||||
goal: "GENERAL_HEALTH",
|
||||
cycleTrackingEnabled: true,
|
||||
rawChartMode: false,
|
||||
predictionEnabled: true,
|
||||
discreetNotifications: false,
|
||||
sensitiveCategoryEncryption: false,
|
||||
typicalCycleLength: 28,
|
||||
typicalPeriodLength: 5,
|
||||
lutealPhaseLength: 14,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
const HISTORY_BODY = {
|
||||
data: {
|
||||
cycles: [],
|
||||
stats: {
|
||||
avgLengthDays: null,
|
||||
lengthVariabilityDays: null,
|
||||
avgPeriodLengthDays: null,
|
||||
regularity: "LEARNING",
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
const INSIGHTS_BODY = {
|
||||
data: { rows: [], headline: null, symptomPatterns: [] },
|
||||
error: null,
|
||||
};
|
||||
|
||||
async function assertNoHorizontalScroll(page: import("@playwright/test").Page) {
|
||||
const dims = await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
innerWidth: window.innerWidth,
|
||||
}));
|
||||
expect(
|
||||
dims.scrollWidth,
|
||||
`scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
|
||||
).toBeLessThanOrEqual(dims.innerWidth + 1);
|
||||
}
|
||||
|
||||
test.describe("cycle + insights mobile smoke", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
|
||||
|
||||
// Flip the cycle feature gate ON by rewriting the real /api/auth/me
|
||||
// response — every other field stays authentic so the auth shell behaves
|
||||
// exactly as in production.
|
||||
await page.route("**/api/auth/me", async (route) => {
|
||||
const res = await route.fetch();
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!json?.data) return route.fulfill({ response: res });
|
||||
json.data.cycleTrackingEnabled = true;
|
||||
return route.fulfill({
|
||||
response: res,
|
||||
body: JSON.stringify(json),
|
||||
headers: { ...res.headers(), "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
// Deterministic cycle reads.
|
||||
await page.route(/\/api\/cycle\/calendar(\?|$)/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(CALENDAR_BODY),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/cycle/profile", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(PROFILE_BODY),
|
||||
}),
|
||||
);
|
||||
await page.route(/\/api\/cycle\/cycles(\?|$)/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(HISTORY_BODY),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/cycle/insights", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(INSIGHTS_BODY),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("/insights has no horizontal scroll on Pixel-5", async ({ page }) => {
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle");
|
||||
await assertNoHorizontalScroll(page);
|
||||
});
|
||||
|
||||
test("/cycle has no horizontal scroll and the calendar cells clear the 44 px tap floor", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/cycle", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// The cycle vertical mounted (the page redirects home if the gate is off).
|
||||
const wheel = page.locator('[data-slot="cycle-wheel-tile"]');
|
||||
await expect(wheel).toBeVisible();
|
||||
|
||||
// 1) No horizontal page scroll at the Pixel-5 width.
|
||||
await assertNoHorizontalScroll(page);
|
||||
|
||||
// 2) Calendar day cells clear the 44 px tap-target height floor.
|
||||
const cells = await page.locator('[role="gridcell"]').all();
|
||||
const failures: string[] = [];
|
||||
for (const cell of cells) {
|
||||
if (!(await cell.isVisible().catch(() => false))) continue;
|
||||
const box = await cell.boundingBox();
|
||||
if (!box) continue;
|
||||
if (box.height < 44) {
|
||||
const label = (await cell.getAttribute("aria-label")) ?? "(cell)";
|
||||
failures.push(`${label.slice(0, 30)} → ${box.height.toFixed(1)}px`);
|
||||
}
|
||||
}
|
||||
expect(
|
||||
failures,
|
||||
`Calendar cells below 44 px tall:\n ${failures.join("\n ")}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* Doctor-report PDF flow. The real flow:
|
||||
* 1. User clicks "Configure & generate" on `/settings/export`
|
||||
* (relocated from `/settings/advanced` in v1.4.16 phase B7)
|
||||
* 2. The dialog opens; user picks dates and submits.
|
||||
* 3. Frontend POSTs `/api/doctor-report` to fetch aggregated data
|
||||
* 4. Frontend dynamic-imports `src/lib/doctor-report-pdf.ts` and
|
||||
* generates the PDF client-side via jsPDF; calls `doc.save(...)`
|
||||
* which triggers a download.
|
||||
*
|
||||
* This spec mocks step 3 (so we don't need 90 days of seeded data) and
|
||||
* captures step 4's download via Playwright's download API. We then
|
||||
* confirm the file is non-empty and starts with `%PDF` magic bytes.
|
||||
*/
|
||||
test.describe("doctor report PDF generation", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("button click triggers a non-empty PDF download", async ({ page }) => {
|
||||
// Mock the data endpoint with a minimal-but-shape-correct payload.
|
||||
// The shape below mirrors the `ReportData` interface in
|
||||
// `src/lib/doctor-report-pdf.ts` — empty stats / medications / mood
|
||||
// is enough to exercise the table-rendering path without 90 days
|
||||
// of seeded readings.
|
||||
await page.route("**/api/doctor-report", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
period: {
|
||||
days: 90,
|
||||
since: new Date(Date.now() - 90 * 86_400_000).toISOString(),
|
||||
},
|
||||
patient: {
|
||||
username: "e2e-tester",
|
||||
dateOfBirth: "1985-06-15T00:00:00.000Z",
|
||||
gender: "MALE",
|
||||
heightCm: 180,
|
||||
},
|
||||
stats: {},
|
||||
bmi: null,
|
||||
compliance: {},
|
||||
medications: [],
|
||||
mood: null,
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Capture page errors so a failed dynamic-import surfaces in the
|
||||
// test output instead of timing out silently.
|
||||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (err) => pageErrors.push(`pageerror: ${err.message}`));
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error")
|
||||
pageErrors.push(`console.error: ${msg.text()}`);
|
||||
});
|
||||
|
||||
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// v1.4.37 renamed the trigger testid from `export-action-doctor-report`
|
||||
// to `export-hero-doctor-report-action` when the card was lifted to a
|
||||
// hero block on the export page.
|
||||
const reportBtn = page.getByTestId("export-hero-doctor-report-action");
|
||||
await expect(reportBtn).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Click opens the dialog; the dialog's submit button triggers the PDF
|
||||
// generation. Trigger the download and capture the resulting Download
|
||||
// object once the user confirms.
|
||||
const downloadPromise = page.waitForEvent("download", { timeout: 30_000 });
|
||||
await reportBtn.click();
|
||||
// Submit the dialog with the default dates (last 90 days).
|
||||
const submitBtn = page.getByRole("button", {
|
||||
name: /generate pdf|pdf generieren/i,
|
||||
});
|
||||
await expect(submitBtn).toBeVisible({ timeout: 5_000 });
|
||||
await submitBtn.click();
|
||||
const download = await downloadPromise.catch((err) => {
|
||||
throw new Error(
|
||||
`Download did not fire. Page errors:\n${pageErrors.join("\n")}\n\nOriginal: ${err}`,
|
||||
);
|
||||
});
|
||||
|
||||
const path = await download.path();
|
||||
expect(path).toBeTruthy();
|
||||
|
||||
// Read first 4 bytes — a valid PDF starts with `%PDF`.
|
||||
const fs = await import("node:fs/promises");
|
||||
const buf = await fs.readFile(path!);
|
||||
expect(buf.byteLength).toBeGreaterThan(1024);
|
||||
expect(buf.subarray(0, 4).toString("ascii")).toBe("%PDF");
|
||||
});
|
||||
});
|
||||
@@ -3,15 +3,17 @@ import { expect, test } from "@playwright/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* Settings → Export consolidated UI smoke.
|
||||
* Settings → Export & Import consolidated UI smoke.
|
||||
*
|
||||
* Validates that:
|
||||
* 1. The export surfaces are all rendered with their stable testids:
|
||||
* the health-record export hero, the four CSV/JSON tiles, and the
|
||||
* demoted doctor-report card (v1.12).
|
||||
* 2. The health-record "included data" checklist is a disclosure,
|
||||
* 2. The import surfaces (v1.15.7, issue #281) render: the Apple Health
|
||||
* and generic-JSON cards.
|
||||
* 3. The health-record "included data" checklist is a disclosure,
|
||||
* collapsed by default, and expands on demand.
|
||||
* 3. Clicking the Measurements CSV download button fires a real
|
||||
* 4. Clicking the Measurements CSV download button fires a real
|
||||
* browser download — proving the `/api/export/measurements`
|
||||
* endpoint is reachable from the browser end-to-end.
|
||||
*
|
||||
@@ -19,26 +21,55 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
* download is enough to lock in the wiring without doubling the e2e
|
||||
* runtime.
|
||||
*/
|
||||
test.describe("Settings → Export", () => {
|
||||
test.describe("Settings → Export & Import", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("renders all export surfaces with stable testids", async ({ page }) => {
|
||||
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
|
||||
// v1.12 — the health-record export is the page hero; the
|
||||
// doctor-report card (`export-hero-*` prefix) is demoted to the
|
||||
// bottom; the four CSV/backup tiles keep the `export-card-*` shape.
|
||||
// The health-record export is the page hero; the four CSV/backup
|
||||
// tiles keep the `export-card-*` shape. The doctor report lives
|
||||
// under the health-record export, so it is no longer a separate
|
||||
// surface here.
|
||||
for (const id of [
|
||||
"health-record-export-panel",
|
||||
"export-card-measurements-csv",
|
||||
"export-card-medications-csv",
|
||||
"export-card-mood-csv",
|
||||
"export-card-full-backup",
|
||||
"export-hero-doctor-report",
|
||||
]) {
|
||||
await expect(page.getByTestId(id)).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
});
|
||||
|
||||
test("renders the import surfaces with stable testids", async ({ page }) => {
|
||||
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
|
||||
// v1.15.7 (issue #281) — the Apple Health and generic-JSON import
|
||||
// cards live in the same section, below the export options.
|
||||
for (const id of ["import-card-apple-health", "import-card-json"]) {
|
||||
await expect(page.getByTestId(id)).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
});
|
||||
|
||||
test("JSON import 'Download example' fires a real download", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
|
||||
const exampleBtn = page.getByTestId("import-json-download-example");
|
||||
await expect(exampleBtn).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const downloadPromise = page.waitForEvent("download", { timeout: 30_000 });
|
||||
await exampleBtn.click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
const path = await download.path();
|
||||
expect(path).toBeTruthy();
|
||||
const fs = await import("node:fs/promises");
|
||||
const buf = await fs.readFile(path!);
|
||||
const parsed = JSON.parse(buf.toString("utf8"));
|
||||
expect(Array.isArray(parsed.measurements)).toBe(true);
|
||||
expect(Array.isArray(parsed.moodEntries)).toBe(true);
|
||||
});
|
||||
|
||||
test("included-data checklist is collapsed by default and expands on demand", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -15,11 +15,10 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
* verifies neither path leaks into a horizontal scrollbar on the
|
||||
* <html> element.
|
||||
*
|
||||
* 2. Render exactly one pill per integration card (Withings + Mood
|
||||
* Log when both are configured). Belt-and-braces guard for the
|
||||
* consolidation Marc requested — the Vitest spec covers this on
|
||||
* the SSR markup level, but the Pixel-5 layout is what matters
|
||||
* to him in practice.
|
||||
* 2. Render exactly one pill per integration card. Belt-and-braces
|
||||
* guard for the card consolidation — the Vitest spec covers this
|
||||
* on the SSR markup level, but the Pixel-5 layout is what matters
|
||||
* in practice.
|
||||
*
|
||||
* Mobile-only spec — desktop project skips it.
|
||||
*/
|
||||
@@ -144,18 +143,18 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
|
||||
|
||||
// Wait for the pills to mount (queries resolve after first paint).
|
||||
const pills = page.locator('[data-testid="integration-status-pill"]');
|
||||
// Four integration cards now (Withings, moodLog, WHOOP, Google Health) →
|
||||
// four pills.
|
||||
await expect(pills).toHaveCount(4, { timeout: 10_000 });
|
||||
// Three integration cards now (Withings, WHOOP, Google Health) → three
|
||||
// pills.
|
||||
await expect(pills).toHaveCount(3, { timeout: 10_000 });
|
||||
|
||||
// Withings + moodLog are connected in this fixture; WHOOP is a BYO-keys
|
||||
// card that stays dormant until an operator adds credentials, so assert
|
||||
// the two connected pills order-independently.
|
||||
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
|
||||
// stays dormant until an operator adds credentials, so assert the one
|
||||
// connected pill.
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-testid="integration-status-pill"][data-state="connected"]',
|
||||
),
|
||||
).toHaveCount(2);
|
||||
).toHaveCount(1);
|
||||
|
||||
// The redundant v1.4.18 banner must be gone.
|
||||
await expect(
|
||||
|
||||
@@ -147,6 +147,7 @@ export function buildMockSnapshot(
|
||||
briefing: null,
|
||||
briefingState: "preparing",
|
||||
briefingUpdatedAt: null,
|
||||
briefingStale: false,
|
||||
generatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
+101
-50
@@ -3,36 +3,40 @@ 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.1):
|
||||
*
|
||||
* 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 bubble (`data-slot="coach-nudge-bubble"`) renders
|
||||
* ONLY while an unread Coach-initiated nudge exists
|
||||
* (`GET /api/insights/coach/nudge-status`). By default no bubble
|
||||
* mounts on any viewport; the everyday entry point is the inline
|
||||
* launch the sub-page shell mounts in the header
|
||||
* (`data-slot="coach-launch-icon"`, visible across breakpoints).
|
||||
*
|
||||
* 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 bubble mounts and clicking it
|
||||
* navigates to the full-page chat at `/insights/coach`.
|
||||
*
|
||||
* 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 +107,50 @@ 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 no bubble by default 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();
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
// The header launch icon is the everyday entry point — visible
|
||||
// on mobile too since the layout bubble is nudge-gated.
|
||||
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
|
||||
await expect(icon).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// No unread nudge → no floating bubble in the DOM.
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-nudge-bubble"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test(`/insights/${slug} mounts the header Coach icon on Desktop Chrome`, async ({
|
||||
test(`/insights/${slug} mounts the inline Coach launch and no bubble by default 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 +158,13 @@ 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();
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-nudge-bubble"]'),
|
||||
).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(
|
||||
@@ -154,16 +174,16 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
|
||||
|
||||
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
|
||||
|
||||
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(
|
||||
@@ -182,12 +202,41 @@ 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 mounts the floating bubble and clicking it opens the full-page chat", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The bubble 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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const bubble = page.locator('[data-slot="coach-nudge-bubble"]');
|
||||
await expect(bubble).toBeVisible({ timeout: 10_000 });
|
||||
await bubble.click();
|
||||
|
||||
await page.waitForURL("**/insights/coach", { timeout: 10_000 });
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-page"]'),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("the drawer's Conversations button hands off to the full-page chat on Desktop Chrome", 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 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",
|
||||
@@ -202,22 +251,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 });
|
||||
});
|
||||
});
|
||||
+6
-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.
|
||||
{
|
||||
|
||||
+925
-85
File diff suppressed because it is too large.
Load diff
+909
-69
File diff suppressed because it is too large.
Load diff
+883
-43
File diff suppressed because it is too large.
Load diff
+883
-43
File diff suppressed because it is too large.
Load diff
+883
-43
File diff suppressed because it is too large.
Load diff
+883
-43
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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+9
-7
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.13.3",
|
||||
"version": "1.16.1",
|
||||
"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",
|
||||
@@ -59,8 +59,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 +75,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.77.0",
|
||||
"recharts": "^3.8.1",
|
||||
"rrule": "^2.8.1",
|
||||
"sax": "^1.6.0",
|
||||
@@ -97,8 +98,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 +108,7 @@
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"knip": "^6.14.2",
|
||||
"knip": "^6.16.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.21.0",
|
||||
"prettier": "^3.8.3",
|
||||
|
||||
Generated
+392
-257
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,150 @@
|
||||
-- v1.15.0 — cycle tracking core.
|
||||
--
|
||||
-- Adds the eight cycle enums plus the three capture tables: the 1:1
|
||||
-- CycleProfile settings row, the soft-deleted MenstrualCycle + CycleDayLog
|
||||
-- capture rows (same syncVersion + deletedAt shape as MoodEntry so they
|
||||
-- ride the `/api/sync/changes` delta feed), and their indexes / uniques.
|
||||
-- Purely additive — no existing table is touched.
|
||||
--
|
||||
-- CycleDayLog carries the MoodEntry dual-unique: the canonical-day key
|
||||
-- `(user_id, date)` plus the NULL-distinct external-dedup key
|
||||
-- `(user_id, source, external_id)` for HealthKit re-ingest.
|
||||
--
|
||||
-- Reversibility (down):
|
||||
-- DROP TABLE IF EXISTS "cycle_day_logs";
|
||||
-- DROP TABLE IF EXISTS "menstrual_cycles";
|
||||
-- DROP TABLE IF EXISTS "cycle_profiles";
|
||||
-- DROP TYPE IF EXISTS "prediction_method";
|
||||
-- DROP TYPE IF EXISTS "cycle_phase";
|
||||
-- DROP TYPE IF EXISTS "contraceptive_kind";
|
||||
-- DROP TYPE IF EXISTS "home_test_result";
|
||||
-- DROP TYPE IF EXISTS "cervical_mucus";
|
||||
-- DROP TYPE IF EXISTS "ovulation_test";
|
||||
-- DROP TYPE IF EXISTS "flow_level";
|
||||
-- DROP TYPE IF EXISTS "cycle_tracking_goal";
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "cycle_tracking_goal" AS ENUM ('GENERAL_HEALTH', 'AVOID_PREGNANCY', 'TRYING_TO_CONCEIVE', 'PERIMENOPAUSE', 'OFF');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "flow_level" AS ENUM ('NONE', 'SPOTTING', 'LIGHT', 'MEDIUM', 'HEAVY');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ovulation_test" AS ENUM ('NEGATIVE', 'POSITIVE_LH_SURGE', 'ESTROGEN_SURGE', 'INDETERMINATE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "cervical_mucus" AS ENUM ('DRY', 'STICKY', 'CREAMY', 'WATERY', 'EGG_WHITE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "home_test_result" AS ENUM ('NEGATIVE', 'POSITIVE', 'INDETERMINATE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "contraceptive_kind" AS ENUM ('NONE', 'UNSPECIFIED', 'IMPLANT', 'INJECTION', 'IUD', 'INTRAVAGINAL_RING', 'ORAL', 'PATCH', 'EMERGENCY');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "cycle_phase" AS ENUM ('MENSTRUAL', 'FOLLICULAR', 'OVULATORY', 'LUTEAL');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "prediction_method" AS ENUM ('CALENDAR', 'SYMPTOTHERMAL', 'TEMPERATURE_TREND', 'BLENDED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cycle_profiles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"goal" "cycle_tracking_goal" NOT NULL DEFAULT 'GENERAL_HEALTH',
|
||||
"typical_cycle_length" INTEGER,
|
||||
"typical_period_length" INTEGER,
|
||||
"luteal_phase_length" INTEGER,
|
||||
"prediction_enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"raw_chart_mode" BOOLEAN NOT NULL DEFAULT false,
|
||||
"discreet_notifications" BOOLEAN NOT NULL DEFAULT false,
|
||||
"sensitive_category_encryption" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "cycle_profiles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "menstrual_cycles" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"start_date" TEXT NOT NULL,
|
||||
"end_date" TEXT,
|
||||
"period_end_date" TEXT,
|
||||
"length_days" INTEGER,
|
||||
"ovulation_date" TEXT,
|
||||
"ovulation_confirmed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"is_predicted" BOOLEAN NOT NULL DEFAULT false,
|
||||
"tz" TEXT,
|
||||
"sync_version" INTEGER NOT NULL DEFAULT 0,
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "menstrual_cycles_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cycle_day_logs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"date" TEXT NOT NULL,
|
||||
"cycle_id" TEXT,
|
||||
"flow" "flow_level",
|
||||
"intermenstrual_bleeding" BOOLEAN NOT NULL DEFAULT false,
|
||||
"basal_body_temp_c" DOUBLE PRECISION,
|
||||
"ovulation_test" "ovulation_test",
|
||||
"cervical_mucus" "cervical_mucus",
|
||||
"sexual_activity" BOOLEAN NOT NULL DEFAULT false,
|
||||
"protected_sex" BOOLEAN,
|
||||
"pregnancy_test" "home_test_result",
|
||||
"progesterone_test" "home_test_result",
|
||||
"contraceptive" "contraceptive_kind",
|
||||
"notes_encrypted" TEXT,
|
||||
"source" "measurement_source" NOT NULL DEFAULT 'MANUAL',
|
||||
"external_id" TEXT,
|
||||
"tz" TEXT,
|
||||
"sync_version" INTEGER NOT NULL DEFAULT 0,
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "cycle_day_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cycle_profiles_user_id_key" ON "cycle_profiles"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "menstrual_cycles_user_id_start_date_idx" ON "menstrual_cycles"("user_id", "start_date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "menstrual_cycles_user_id_updated_at_id_idx" ON "menstrual_cycles"("user_id", "updated_at", "id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "menstrual_cycles_user_id_start_date_key" ON "menstrual_cycles"("user_id", "start_date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "cycle_day_logs_user_id_date_idx" ON "cycle_day_logs"("user_id", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "cycle_day_logs_user_id_updated_at_id_idx" ON "cycle_day_logs"("user_id", "updated_at", "id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cycle_day_logs_user_id_date_key" ON "cycle_day_logs"("user_id", "date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cycle_day_logs_user_id_source_external_id_key" ON "cycle_day_logs"("user_id", "source", "external_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cycle_profiles" ADD CONSTRAINT "cycle_profiles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "menstrual_cycles" ADD CONSTRAINT "menstrual_cycles_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cycle_day_logs" ADD CONSTRAINT "cycle_day_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cycle_day_logs" ADD CONSTRAINT "cycle_day_logs_cycle_id_fkey" FOREIGN KEY ("cycle_id") REFERENCES "menstrual_cycles"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,112 @@
|
||||
-- v1.15.0 — cycle symptom taxonomy.
|
||||
--
|
||||
-- Clones the mood-tag trio (0101): a global Category -> Symptom catalog
|
||||
-- plus a per-day-log join. `CycleSymptom` additionally carries
|
||||
-- `user_id` + `label_encrypted` so a future release can mint per-user
|
||||
-- custom symptoms; v1.15.0 ships seeded-catalogue-only.
|
||||
--
|
||||
-- `label_key` columns carry an i18n message key (resolved client-side
|
||||
-- against all six locales) rather than a hard-coded label, so the seeded
|
||||
-- catalog renders localised everywhere. The seed at the bottom uses
|
||||
-- deterministic ids + ON CONFLICT DO NOTHING on `key` so re-running is a
|
||||
-- no-op (idempotent).
|
||||
--
|
||||
-- Reversibility (down):
|
||||
-- DROP TABLE IF EXISTS "cycle_symptom_links";
|
||||
-- DROP TABLE IF EXISTS "cycle_symptoms";
|
||||
-- DROP TABLE IF EXISTS "cycle_symptom_categories";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cycle_symptom_categories" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"label_key" TEXT NOT NULL,
|
||||
"icon" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "cycle_symptom_categories_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cycle_symptoms" (
|
||||
"id" TEXT NOT NULL,
|
||||
"category_id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"label_key" TEXT NOT NULL,
|
||||
"icon" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"user_id" TEXT,
|
||||
"label_encrypted" TEXT,
|
||||
|
||||
CONSTRAINT "cycle_symptoms_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cycle_symptom_links" (
|
||||
"day_log_id" TEXT NOT NULL,
|
||||
"symptom_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "cycle_symptom_links_pkey" PRIMARY KEY ("day_log_id","symptom_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cycle_symptom_categories_key_key" ON "cycle_symptom_categories"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cycle_symptoms_key_key" ON "cycle_symptoms"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "cycle_symptoms_category_id_sort_order_idx" ON "cycle_symptoms"("category_id", "sort_order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "cycle_symptoms_user_id_idx" ON "cycle_symptoms"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "cycle_symptom_links_symptom_id_idx" ON "cycle_symptom_links"("symptom_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cycle_symptoms" ADD CONSTRAINT "cycle_symptoms_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "cycle_symptom_categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cycle_symptoms" ADD CONSTRAINT "cycle_symptoms_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cycle_symptom_links" ADD CONSTRAINT "cycle_symptom_links_day_log_id_fkey" FOREIGN KEY ("day_log_id") REFERENCES "cycle_day_logs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cycle_symptom_links" ADD CONSTRAINT "cycle_symptom_links_symptom_id_fkey" FOREIGN KEY ("symptom_id") REFERENCES "cycle_symptoms"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- ─── Seed the default symptom taxonomy ───────────────────────────────
|
||||
-- Idempotent: deterministic ids + ON CONFLICT ("key") DO NOTHING (the
|
||||
-- mood-tag seed precedent 0101 / 0118 / 0126). Three categories
|
||||
-- (physical, emotional, digestive) with ~15 high-signal symptoms. Keep
|
||||
-- in sync with `src/lib/cycle/symptom-taxonomy.ts` if either changes.
|
||||
|
||||
-- Categories
|
||||
INSERT INTO "cycle_symptom_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active") VALUES
|
||||
('csc_physical', 'physical', 'cycle.symptomCategory.physical', 'Activity', 0, true),
|
||||
('csc_emotional', 'emotional', 'cycle.symptomCategory.emotional', 'Heart', 1, true),
|
||||
('csc_digestive', 'digestive', 'cycle.symptomCategory.digestive', 'Soup', 2, true)
|
||||
ON CONFLICT ("key") DO NOTHING;
|
||||
|
||||
-- Symptoms
|
||||
INSERT INTO "cycle_symptoms" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active") VALUES
|
||||
('cs_cramps', 'csc_physical', 'cramps', 'cycle.symptom.cramps', 'Zap', 0, true),
|
||||
('cs_headache', 'csc_physical', 'headache', 'cycle.symptom.headache', 'Brain', 1, true),
|
||||
('cs_bloating', 'csc_physical', 'bloating', 'cycle.symptom.bloating', 'CircleDot', 2, true),
|
||||
('cs_acne', 'csc_physical', 'acne', 'cycle.symptom.acne', 'Droplet', 3, true),
|
||||
('cs_breast_tenderness', 'csc_physical', 'breast_tenderness', 'cycle.symptom.breastTenderness', 'HeartPulse', 4, true),
|
||||
('cs_fatigue', 'csc_physical', 'fatigue', 'cycle.symptom.fatigue', 'BatteryLow', 5, true),
|
||||
('cs_back_pain', 'csc_physical', 'back_pain', 'cycle.symptom.backPain', 'PersonStanding', 6, true),
|
||||
('cs_insomnia', 'csc_physical', 'insomnia', 'cycle.symptom.insomnia', 'MoonStar', 7, true),
|
||||
('cs_libido_high', 'csc_emotional', 'libido_high', 'cycle.symptom.libidoHigh', 'Flame', 0, true),
|
||||
('cs_libido_low', 'csc_emotional', 'libido_low', 'cycle.symptom.libidoLow', 'Snowflake', 1, true),
|
||||
('cs_mood_swings', 'csc_emotional', 'mood_swings', 'cycle.symptom.moodSwings', 'Drama', 2, true),
|
||||
('cs_food_cravings', 'csc_digestive', 'food_cravings', 'cycle.symptom.foodCravings', 'Cookie', 0, true),
|
||||
('cs_nausea', 'csc_digestive', 'nausea', 'cycle.symptom.nausea', 'Frown', 1, true),
|
||||
('cs_diarrhea', 'csc_digestive', 'diarrhea', 'cycle.symptom.diarrhea', 'Toilet', 2, true),
|
||||
('cs_constipation', 'csc_digestive', 'constipation', 'cycle.symptom.constipation', 'CircleSlash', 3, true)
|
||||
ON CONFLICT ("key") DO NOTHING;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- v1.15.0 — cycle prediction cache.
|
||||
--
|
||||
-- One cached forecast per user, regenerated debounced /
|
||||
-- stale-while-revalidate (the InsightNarrative + v1.8.7 assessment cache
|
||||
-- pattern), NOT per-read. Derived data — not soft-deleted. The ranged
|
||||
-- Low / High band is rendered as a band, never a single date.
|
||||
--
|
||||
-- Reversibility (down):
|
||||
-- DROP TABLE IF EXISTS "cycle_predictions";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "cycle_predictions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"method" "prediction_method" NOT NULL,
|
||||
"next_period_start" TEXT NOT NULL,
|
||||
"next_period_start_low" TEXT NOT NULL,
|
||||
"next_period_start_high" TEXT NOT NULL,
|
||||
"fertile_window_start" TEXT,
|
||||
"fertile_window_end" TEXT,
|
||||
"predicted_ovulation" TEXT,
|
||||
"confidence" DOUBLE PRECISION NOT NULL,
|
||||
"cycles_observed" INTEGER NOT NULL,
|
||||
"generated_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "cycle_predictions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "cycle_predictions_user_id_key" ON "cycle_predictions"("user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "cycle_predictions" ADD CONSTRAINT "cycle_predictions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Additive: at-rest envelope column for the intent-revealing cycle day-log
|
||||
-- fields. Populated INSTEAD of the plaintext columns when
|
||||
-- CycleProfile.sensitive_category_encryption is ON; the plaintext columns
|
||||
-- are then NULL. AES-256-GCM ciphertext, decrypted fail-soft on read.
|
||||
ALTER TABLE "cycle_day_logs" ADD COLUMN "sensitive_encrypted" TEXT;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- v1.15.0 — cycle-tracking feature enable flag.
|
||||
--
|
||||
-- Adds the per-user `cycle_tracking_enabled` toggle to `cycle_profiles`.
|
||||
-- The column is NULLABLE on purpose: NULL means "derive from gender"
|
||||
-- (`FEMALE` → enabled, everything else → disabled), so no row backfill
|
||||
-- is required — existing profiles read NULL and resolve through the
|
||||
-- `isCycleEnabled()` gate helper. An explicit `true` opts a non-FEMALE
|
||||
-- account in (Settings toggle); an explicit `false` opts a FEMALE
|
||||
-- account out. Every `/api/cycle/*` route returns
|
||||
-- `403 { error, meta:{ errorCode:"cycle.disabled" } }` when the resolved
|
||||
-- value is false.
|
||||
--
|
||||
-- Reversibility (down):
|
||||
-- ALTER TABLE "cycle_profiles" DROP COLUMN IF EXISTS "cycle_tracking_enabled";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "cycle_profiles" ADD COLUMN "cycle_tracking_enabled" BOOLEAN;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Optional 1-4 Likert intensity per symptom link. NULL preserves the
|
||||
-- pre-severity presence-link contract for every existing row.
|
||||
ALTER TABLE "cycle_symptom_links" ADD COLUMN "severity" INTEGER;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- v1.15.1 — seed the global `custom` cycle-symptom category.
|
||||
--
|
||||
-- Per-user custom symptoms (the v1.13 custom-mood-tag precedent) hang under a
|
||||
-- single global category row, distinguished from the seeded catalogue by a
|
||||
-- non-null `cycle_symptoms.user_id`. The owner column + `label_encrypted`
|
||||
-- already exist (migration 0129); this only adds the parent category so a
|
||||
-- custom symptom has a stable `category_id` to reference.
|
||||
--
|
||||
-- Idempotent: upserts by `key` (deterministic id `csc_custom`), so re-running
|
||||
-- the migration is a no-op. No table or column changes.
|
||||
INSERT INTO "cycle_symptom_categories"
|
||||
("id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('csc_custom', 'custom', 'cycle.symptomCategory.custom', 'Tag', 100, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon",
|
||||
"sort_order" = EXCLUDED."sort_order", "is_active" = EXCLUDED."is_active";
|
||||
@@ -0,0 +1,13 @@
|
||||
-- v1.15.9 — terminal "auto-missed" state for forgotten medication doses.
|
||||
--
|
||||
-- The hourly auto-miss cron previously flipped a never-acted pending dose to
|
||||
-- `skipped = true`. Because the compliance engine excludes skipped doses from
|
||||
-- the denominator (a deliberate user pause), a forgotten dose then neither
|
||||
-- counted as taken nor as missed — silently inflating adherence.
|
||||
--
|
||||
-- This column lets the engine tell the two apart: a user-initiated skip stays
|
||||
-- `skipped = true` (excluded), while a forgotten dose past its miss cutoff is
|
||||
-- marked `auto_missed = true` and counts as a MISS against the rate. Every
|
||||
-- legacy row defaults to false.
|
||||
ALTER TABLE "medication_intake_events"
|
||||
ADD COLUMN "auto_missed" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -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;
|
||||
+596
-1
@@ -48,7 +48,7 @@ model User {
|
||||
// Profile
|
||||
heightCm Float? @map("height_cm")
|
||||
dateOfBirth DateTime? @map("date_of_birth")
|
||||
gender String? @map("gender") // "MALE" | "FEMALE" | null = keine Angabe
|
||||
gender String? @map("gender") // "MALE" | "FEMALE" | "OTHER" | null = keine Angabe
|
||||
timezone String @default("Europe/Berlin")
|
||||
|
||||
// Codex OAuth (ChatGPT subscription)
|
||||
@@ -132,6 +132,10 @@ model User {
|
||||
// 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")
|
||||
@@ -495,6 +499,30 @@ model User {
|
||||
// unset `insurerName` collapses its name.
|
||||
insurerIkNumber String? @map("insurer_ik_number")
|
||||
|
||||
// v1.15.0 — cycle tracking. The CycleProfile is a 1:1 settings row
|
||||
// (lazily upserted on first cycle-surface access); MenstrualCycle +
|
||||
// CycleDayLog are the soft-deleted, sync-feed-bearing capture rows;
|
||||
// CyclePrediction is a 1:1 cached forecast (debounced regenerate).
|
||||
cycleProfile CycleProfile?
|
||||
menstrualCycles MenstrualCycle[]
|
||||
cycleDayLogs CycleDayLog[]
|
||||
cyclePrediction CyclePrediction?
|
||||
// v1.15.0 — owner of a per-user custom CycleSymptom (NULL = the global
|
||||
// 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")
|
||||
}
|
||||
|
||||
@@ -704,6 +732,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
|
||||
@@ -838,6 +876,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
|
||||
@@ -1372,6 +1414,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[]
|
||||
@@ -1388,6 +1440,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
|
||||
@@ -1431,6 +1496,26 @@ model MedicationIntakeEvent {
|
||||
scheduledFor DateTime @map("scheduled_for")
|
||||
takenAt DateTime? @map("taken_at")
|
||||
skipped Boolean @default(false)
|
||||
/// v1.15.9 — distinguishes a never-acted forgotten dose from a
|
||||
/// deliberate user skip. The hourly auto-miss cron sets this (NOT
|
||||
/// `skipped`) on a pending row past its miss cutoff so the compliance
|
||||
/// engine counts it as a MISS in the denominator, while a user-initiated
|
||||
/// `skipped = true` stays excluded (a deliberate drug holiday). Every
|
||||
/// 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")
|
||||
@@ -1752,6 +1837,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 {
|
||||
@@ -2062,6 +2199,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])
|
||||
@@ -2955,6 +3096,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
|
||||
@@ -3161,3 +3337,422 @@ model InsightNarrative {
|
||||
@@index([userId, updatedAt(sort: Desc)])
|
||||
@@map("insight_narratives")
|
||||
}
|
||||
|
||||
// ─── Cycle Tracking (v1.15.0) ─────────────────────────────────
|
||||
//
|
||||
// Self-hosted reproductive-health tracking: period + flow, symptom
|
||||
// taxonomy, BBT / OPK / cervical-mucus capture, ranged-confidence
|
||||
// prediction. The capture rows (MenstrualCycle, CycleDayLog) carry the
|
||||
// same soft-delete + syncVersion shape as MoodEntry so they ride the
|
||||
// `/api/sync/changes` delta feed. CycleProfile + CyclePrediction are
|
||||
// settings / derived rows and do NOT tombstone.
|
||||
//
|
||||
// Sensitivity: free-text notes are always AES-256-GCM at rest
|
||||
// (`notesEncrypted`, the `*Encrypted` convention). The default-ON
|
||||
// `sensitiveCategoryEncryption` flag drives whether the intent-revealing
|
||||
// fields (pregnancy / contraception / sexual activity) also encrypt at
|
||||
// the application layer — those stay queryable-plaintext-physiology
|
||||
// (flow, BBT, OPK, mucus, dates, phase) untouched so the rollup +
|
||||
// correlation tier keeps working.
|
||||
|
||||
/// Per-user goal that gates every cycle copy + the fertile-window
|
||||
/// surfacing. `OFF` disables the category entirely; `GENERAL_HEALTH` /
|
||||
/// `PERIMENOPAUSE` hide conception framing; only `TRYING_TO_CONCEIVE`
|
||||
/// (and, with a stronger caveat, `AVOID_PREGNANCY`) surface fertile-window
|
||||
/// language.
|
||||
enum CycleTrackingGoal {
|
||||
GENERAL_HEALTH
|
||||
AVOID_PREGNANCY
|
||||
TRYING_TO_CONCEIVE
|
||||
PERIMENOPAUSE
|
||||
OFF
|
||||
|
||||
@@map("cycle_tracking_goal")
|
||||
}
|
||||
|
||||
/// Menstrual-flow intensity for a logged day. Mirrors the HealthKit
|
||||
/// `HKCategoryValueMenstrualFlow` ladder (unspecified collapses to the
|
||||
/// presence of a row; `NONE` is an explicit no-flow marker).
|
||||
enum FlowLevel {
|
||||
NONE
|
||||
SPOTTING
|
||||
LIGHT
|
||||
MEDIUM
|
||||
HEAVY
|
||||
|
||||
@@map("flow_level")
|
||||
}
|
||||
|
||||
/// Ovulation predictor-kit (OPK) reading. `POSITIVE_LH_SURGE` is the
|
||||
/// LH-surge positive; `ESTROGEN_SURGE` is the estrogen-rise pre-peak
|
||||
/// some dual-hormone kits report. Mirrors HealthKit
|
||||
/// `HKCategoryValueOvulationTestResult`.
|
||||
enum OvulationTest {
|
||||
NEGATIVE
|
||||
POSITIVE_LH_SURGE
|
||||
ESTROGEN_SURGE
|
||||
INDETERMINATE
|
||||
|
||||
@@map("ovulation_test")
|
||||
}
|
||||
|
||||
/// Cervical-mucus quality, fertility-awareness ladder (dry → egg-white
|
||||
/// peak). Mirrors HealthKit `HKCategoryValueCervicalMucusQuality`.
|
||||
enum CervicalMucus {
|
||||
DRY
|
||||
STICKY
|
||||
CREAMY
|
||||
WATERY
|
||||
EGG_WHITE
|
||||
|
||||
@@map("cervical_mucus")
|
||||
}
|
||||
|
||||
/// Generic home-test result for the pregnancy / progesterone test
|
||||
/// fields. Mirrors HealthKit `HKCategoryValuePregnancyTestResult` /
|
||||
/// `HKCategoryValueProgesteroneTestResult`.
|
||||
enum HomeTestResult {
|
||||
NEGATIVE
|
||||
POSITIVE
|
||||
INDETERMINATE
|
||||
|
||||
@@map("home_test_result")
|
||||
}
|
||||
|
||||
/// Contraceptive method logged for a day. `UNSPECIFIED` mirrors the
|
||||
/// HealthKit `unspecified` member distinct from `NONE` (an explicit
|
||||
/// no-method marker). Mirrors HealthKit `HKCategoryValueContraceptive`.
|
||||
enum ContraceptiveKind {
|
||||
NONE
|
||||
UNSPECIFIED
|
||||
IMPLANT
|
||||
INJECTION
|
||||
IUD
|
||||
INTRAVAGINAL_RING
|
||||
ORAL
|
||||
PATCH
|
||||
EMERGENCY
|
||||
|
||||
@@map("contraceptive_kind")
|
||||
}
|
||||
|
||||
/// Derived cycle phase used by the prediction engine + the CYCLE_PHASE
|
||||
/// correlation channel. Computed from day-of-cycle + the back-calculated
|
||||
/// luteal length, never stored on the capture rows.
|
||||
enum CyclePhase {
|
||||
MENSTRUAL
|
||||
FOLLICULAR
|
||||
OVULATORY
|
||||
LUTEAL
|
||||
|
||||
@@map("cycle_phase")
|
||||
}
|
||||
|
||||
/// Which method produced a cached CyclePrediction. `CALENDAR` is the
|
||||
/// median+MAD variability predictor; `SYMPTOTHERMAL` folds the
|
||||
/// sensiplan BBT + mucus confirmation; `TEMPERATURE_TREND` is the
|
||||
/// retrospective wrist/skin-temperature ovulation; `BLENDED` combines
|
||||
/// them.
|
||||
enum PredictionMethod {
|
||||
CALENDAR
|
||||
SYMPTOTHERMAL
|
||||
TEMPERATURE_TREND
|
||||
BLENDED
|
||||
|
||||
@@map("prediction_method")
|
||||
}
|
||||
|
||||
/// One settings row per user, lazily upserted on first cycle-surface
|
||||
/// access (goal defaults to GENERAL_HEALTH). Not soft-deleted — it is a
|
||||
/// profile row, like the other per-user settings columns on User.
|
||||
model CycleProfile {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
|
||||
/// Drives all cycle copy + fertile-window gating (see CycleTrackingGoal).
|
||||
goal CycleTrackingGoal @default(GENERAL_HEALTH)
|
||||
|
||||
/// Per-user feature enable. NULL = derive from gender (`FEMALE` → on,
|
||||
/// everything else → off) so the column never needs a backfill; an
|
||||
/// explicit `true` opts a non-FEMALE account in, an explicit `false`
|
||||
/// opts a FEMALE account out. Resolved through `isCycleEnabled()` —
|
||||
/// every `/api/cycle/*` route 403s with `errorCode:"cycle.disabled"`
|
||||
/// when the resolved value is false.
|
||||
cycleTrackingEnabled Boolean? @map("cycle_tracking_enabled")
|
||||
|
||||
/// Optional user-supplied priors. The engine applies a luteal default
|
||||
/// of 14 days in code (NOT a DB default) when `lutealPhaseLength` is
|
||||
/// NULL, so a future constant change never strands historical rows.
|
||||
typicalCycleLength Int? @map("typical_cycle_length")
|
||||
typicalPeriodLength Int? @map("typical_period_length")
|
||||
lutealPhaseLength Int? @map("luteal_phase_length")
|
||||
|
||||
/// When false, the app computes / stores NO predictions — pure logging
|
||||
/// (the Read-Your-Body posture for users who distrust algorithmic
|
||||
/// interpretation).
|
||||
predictionEnabled Boolean @default(true) @map("prediction_enabled")
|
||||
/// Raw-chart mode — surfaces logged values without the interpreted
|
||||
/// prediction overlays.
|
||||
rawChartMode Boolean @default(false) @map("raw_chart_mode")
|
||||
|
||||
/// When true, every cycle push body is generic ("HealthLog reminder")
|
||||
/// so no cycle-event naming reaches the lock screen.
|
||||
discreetNotifications Boolean @default(false) @map("discreet_notifications")
|
||||
/// Default-ON for this category (post-Dobbs threat model): drives
|
||||
/// whether the intent-revealing day-log fields encrypt at the
|
||||
/// application layer in addition to the always-encrypted notes.
|
||||
sensitiveCategoryEncryption Boolean @default(true) @map("sensitive_category_encryption")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId])
|
||||
@@map("cycle_profiles")
|
||||
}
|
||||
|
||||
/// One row per menstrual cycle (a period-start anchor + derived span).
|
||||
/// `isPredicted` rows are forward-minted forecasts; observed rows are
|
||||
/// confirmed from logged flow. Soft-deleted + syncVersion-bumped so the
|
||||
/// row rides the `/api/sync/changes` feed as a tombstone.
|
||||
model MenstrualCycle {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
|
||||
/// YYYY-MM-DD — period start, anchored to `tz` when non-null, else the
|
||||
/// legacy Europe/Berlin assumption (the MoodEntry.date convention).
|
||||
startDate String @map("start_date")
|
||||
/// YYYY-MM-DD — cycle end (= day before the next cycle's start).
|
||||
endDate String? @map("end_date")
|
||||
/// YYYY-MM-DD — last bleeding day of this cycle's period.
|
||||
periodEndDate String? @map("period_end_date")
|
||||
|
||||
/// Observed cycle length in days (start→next start). NULL until the
|
||||
/// next cycle anchors it.
|
||||
lengthDays Int? @map("length_days")
|
||||
/// YYYY-MM-DD — confirmed or estimated ovulation day.
|
||||
ovulationDate String? @map("ovulation_date")
|
||||
/// True once ovulation is confirmed (symptothermal / temperature-trend),
|
||||
/// as opposed to a calendar estimate.
|
||||
ovulationConfirmed Boolean @default(false) @map("ovulation_confirmed")
|
||||
/// Forward-minted forecast row vs an observed cycle.
|
||||
isPredicted Boolean @default(false) @map("is_predicted")
|
||||
|
||||
/// Per-row IANA timezone the date strings are anchored to (NULL =
|
||||
/// legacy Europe/Berlin), mirroring MoodEntry.tz.
|
||||
tz String?
|
||||
syncVersion Int @default(0) @map("sync_version")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
dayLogs CycleDayLog[]
|
||||
|
||||
@@unique([userId, startDate])
|
||||
@@index([userId, startDate])
|
||||
// Keyset support for the `/api/sync/changes` cycle feed
|
||||
// (`where userId` ordered by `(updatedAt asc, id asc)`).
|
||||
@@index([userId, updatedAt, id])
|
||||
@@map("menstrual_cycles")
|
||||
}
|
||||
|
||||
/// One canonical row per (user, day): the HealthKit sync unit + the
|
||||
/// manual log-day surface. Dual-unique exactly like MoodEntry — the
|
||||
/// canonical-day key plus the NULL-distinct external-dedup key for
|
||||
/// HealthKit re-ingest. Soft-deleted + syncVersion-bumped.
|
||||
model CycleDayLog {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// YYYY-MM-DD — anchored to `tz` when non-null, else legacy
|
||||
/// Europe/Berlin (the MoodEntry.date convention).
|
||||
date String
|
||||
/// Optional link to the owning MenstrualCycle (set once the day is
|
||||
/// attributed to a cycle span).
|
||||
cycleId String? @map("cycle_id")
|
||||
|
||||
/// Menstrual-flow intensity (NULL = not logged this day).
|
||||
flow FlowLevel?
|
||||
/// Spotting / bleeding outside the period window.
|
||||
intermenstrualBleeding Boolean @default(false) @map("intermenstrual_bleeding")
|
||||
|
||||
/// Basal body temperature in Celsius. Queryable-plaintext — feeds the
|
||||
/// symptothermal confirmation. BBT also lands in Measurement
|
||||
/// (BODY_TEMPERATURE) for charting; this column is the cycle-local copy.
|
||||
basalBodyTempC Float? @map("basal_body_temp_c")
|
||||
/// Ovulation predictor-kit (OPK) reading.
|
||||
ovulationTest OvulationTest? @map("ovulation_test")
|
||||
/// Cervical-mucus quality.
|
||||
cervicalMucus CervicalMucus? @map("cervical_mucus")
|
||||
|
||||
/// Intent-revealing fields. When CycleProfile.sensitiveCategoryEncryption
|
||||
/// is ON these are encrypted at the application layer (the dispatch
|
||||
/// envelope), default-ON for this category.
|
||||
sexualActivity Boolean @default(false) @map("sexual_activity")
|
||||
protectedSex Boolean? @map("protected_sex")
|
||||
pregnancyTest HomeTestResult? @map("pregnancy_test")
|
||||
progesteroneTest HomeTestResult? @map("progesterone_test")
|
||||
contraceptive ContraceptiveKind? @map("contraceptive")
|
||||
|
||||
/// AES-256-GCM ciphertext envelope of the intent-revealing fields
|
||||
/// (sexualActivity / protectedSex / pregnancyTest / progesteroneTest /
|
||||
/// contraceptive). Populated INSTEAD of the plaintext columns above when
|
||||
/// CycleProfile.sensitiveCategoryEncryption is ON: the plaintext columns
|
||||
/// are NULLed and this carries the JSON envelope, so the most sensitive
|
||||
/// reproductive data is encrypted at rest. Those five fields then drop
|
||||
/// out of the rollup / correlation tier (queryable plaintext is the
|
||||
/// trade for at-rest secrecy). Decrypted fail-soft on read.
|
||||
sensitiveEncrypted String? @map("sensitive_encrypted")
|
||||
|
||||
/// AES-256-GCM ciphertext envelope of the day's free-text note (always
|
||||
/// encrypted — the `*Encrypted` String? convention, matching
|
||||
/// MoodTag.labelEncrypted + InsightNarrative).
|
||||
notesEncrypted String? @map("notes_encrypted")
|
||||
|
||||
/// Provenance. MANUAL (web / single POST) or APPLE_HEALTH (the batch
|
||||
/// ingest); mirrors MoodEntry.source. The wire spelling is APPLE_HEALTH.
|
||||
source MeasurementSource @default(MANUAL)
|
||||
/// Stable per-source dedup id. NULL for native / MANUAL entries (those
|
||||
/// dedup on the canonical `(userId, date)` key); a HealthKit re-ingest
|
||||
/// upserts on `(userId, source, externalId)`. Postgres treats NULLs as
|
||||
/// distinct so MANUAL rows never collide on it.
|
||||
externalId String? @map("external_id")
|
||||
|
||||
tz String?
|
||||
syncVersion Int @default(0) @map("sync_version")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
cycle MenstrualCycle? @relation(fields: [cycleId], references: [id], onDelete: SetNull)
|
||||
symptomLinks CycleSymptomLink[]
|
||||
|
||||
@@unique([userId, date])
|
||||
// NULL-distinct external dedup key for HealthKit re-ingest (the
|
||||
// MoodEntry dual-unique precedent).
|
||||
@@unique([userId, source, externalId])
|
||||
@@index([userId, date])
|
||||
// Keyset support for the `/api/sync/changes` cycle-day feed.
|
||||
@@index([userId, updatedAt, id])
|
||||
@@map("cycle_day_logs")
|
||||
}
|
||||
|
||||
// ─── Cycle Symptom Taxonomy (v1.15.0) ─────────────────────────
|
||||
//
|
||||
// Clones the MoodTagCategory → MoodTag → MoodEntryTagLink trio: a global,
|
||||
// icon-bearing, labelKey-i18n, isActive/sortOrder catalog plus a per-day
|
||||
// join. `CycleSymptom` additionally carries `userId?` + `labelEncrypted?`
|
||||
// so a future release can mint per-user custom symptoms (the MoodTag
|
||||
// custom-tag precedent); v1.15.0 ships seeded-catalogue-only.
|
||||
|
||||
model CycleSymptomCategory {
|
||||
id String @id @default(cuid())
|
||||
/// Stable machine key (e.g. `physical`). Unique so the seed migration
|
||||
/// can upsert idempotently.
|
||||
key String @unique
|
||||
/// i18n message key for the category label (`cycle.symptomCategory.<key>`).
|
||||
labelKey String @map("label_key")
|
||||
/// Lucide icon name for the category header.
|
||||
icon String?
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
/// Soft-hide a category without deleting its historical links.
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
symptoms CycleSymptom[]
|
||||
|
||||
@@map("cycle_symptom_categories")
|
||||
}
|
||||
|
||||
model CycleSymptom {
|
||||
id String @id @default(cuid())
|
||||
categoryId String @map("category_id")
|
||||
/// Stable machine key (e.g. `cramps`). Unique across the catalog.
|
||||
key String @unique
|
||||
/// i18n message key for the symptom label (`cycle.symptom.<key>`).
|
||||
labelKey String @map("label_key")
|
||||
/// Lucide icon name for the symptom chip.
|
||||
icon String?
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
/// Owner of a per-user custom symptom. NULL = the global seeded
|
||||
/// catalogue (resolves `labelKey` against the locale). Set = a user's
|
||||
/// own custom symptom carrying a `custom:<cuid>` key and a free-text
|
||||
/// label held in `labelEncrypted` (the MoodTag custom-tag precedent).
|
||||
userId String? @map("user_id")
|
||||
/// AES-256-GCM ciphertext of a custom symptom's free-text label. NULL
|
||||
/// for catalogue rows. Decrypted server-side for the effective read.
|
||||
labelEncrypted String? @map("label_encrypted")
|
||||
|
||||
category CycleSymptomCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
user User? @relation("UserCycleSymptoms", fields: [userId], references: [id], onDelete: Cascade)
|
||||
links CycleSymptomLink[]
|
||||
|
||||
@@index([categoryId, sortOrder])
|
||||
@@index([userId])
|
||||
@@map("cycle_symptoms")
|
||||
}
|
||||
|
||||
/// Join: which symptoms a cycle day-log carries. Composite PK keeps the
|
||||
/// pairing unique; cascade on both sides so deleting a day-log (or a
|
||||
/// catalog symptom) cleans up its links.
|
||||
model CycleSymptomLink {
|
||||
dayLogId String @map("day_log_id")
|
||||
symptomId String @map("symptom_id")
|
||||
/// Optional 1-4 Likert intensity for this symptom on this day. NULL = a
|
||||
/// plain presence link (the pre-severity contract). Surfaced in
|
||||
/// CycleDayLogDTO so the log-day sheet can pre-fill the severity chips.
|
||||
severity Int? @map("severity")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
dayLog CycleDayLog @relation(fields: [dayLogId], references: [id], onDelete: Cascade)
|
||||
symptom CycleSymptom @relation(fields: [symptomId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([dayLogId, symptomId])
|
||||
@@index([symptomId])
|
||||
@@map("cycle_symptom_links")
|
||||
}
|
||||
|
||||
/// One cached forecast per user, regenerated debounced /
|
||||
/// stale-while-revalidate (the InsightNarrative + v1.8.7 assessment
|
||||
/// cache pattern), NOT per-read. Derived — not soft-deleted.
|
||||
model CyclePrediction {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
|
||||
/// Which method produced this forecast.
|
||||
method PredictionMethod
|
||||
|
||||
/// YYYY-MM-DD — predicted next period start (point estimate) plus the
|
||||
/// ranged Low / High band (rendered as a band, never a single date).
|
||||
nextPeriodStart String @map("next_period_start")
|
||||
nextPeriodStartLow String @map("next_period_start_low")
|
||||
nextPeriodStartHigh String @map("next_period_start_high")
|
||||
|
||||
/// YYYY-MM-DD — fertile-window bounds (goal-gated at the read surface).
|
||||
fertileWindowStart String? @map("fertile_window_start")
|
||||
fertileWindowEnd String? @map("fertile_window_end")
|
||||
/// YYYY-MM-DD — predicted ovulation day.
|
||||
predictedOvulation String? @map("predicted_ovulation")
|
||||
|
||||
/// 0..1 confidence, shrinking with cycle-length variance + sparse
|
||||
/// logging + < MIN_CYCLES observed.
|
||||
confidence Float
|
||||
/// How many observed cycles the forecast was computed from.
|
||||
cyclesObserved Int @map("cycles_observed")
|
||||
/// When the forecast was generated (drives the stale-while-revalidate
|
||||
/// debounce).
|
||||
generatedAt DateTime @map("generated_at")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId])
|
||||
@@map("cycle_predictions")
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
+10
-2
@@ -13,12 +13,20 @@
|
||||
{
|
||||
"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-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
+49
-21
@@ -50,16 +50,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 +94,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,11 +119,21 @@ async function cacheFirst(request, cacheName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Network-first: try network, fall back to cache.
|
||||
* 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(request, cacheName) {
|
||||
async function networkFirst(event, cacheName) {
|
||||
const { request } = event;
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
const response =
|
||||
(event.preloadResponse ? await event.preloadResponse : null) ||
|
||||
(await fetch(request));
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(cacheName);
|
||||
cache.put(request, response.clone());
|
||||
@@ -122,8 +141,15 @@ async function networkFirst(request, cacheName) {
|
||||
}
|
||||
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 +169,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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{
|
||||
"name": "POSTGRES_PASSWORD",
|
||||
"purpose": "Database password for the bundled `db` service in docker-compose.yml.",
|
||||
"example": "<openssl rand -base64 24>"
|
||||
"example": "<openssl rand -hex 32>"
|
||||
},
|
||||
{
|
||||
"name": "NEXT_PUBLIC_APP_URL",
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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,859 @@
|
||||
/**
|
||||
* scripts/repair-intake-anomalies.ts — operator repair for the known
|
||||
* historic medication-ledger defects:
|
||||
*
|
||||
* 1. SLOT DUPLICATES — more than one live `medication_intake_events` row
|
||||
* on the same exact `(user_id, medication_id, scheduled_for)` tuple
|
||||
* (cross-source duplicates the pre-v1.15.19 write path could mint).
|
||||
* Repair: keep one winner per slot (H1 precedence, see `pickWinner`
|
||||
* below) and soft-delete the losers — `deleted_at = now()`,
|
||||
* `sync_version` incremented, `updated_at` bumped — exactly the
|
||||
* tombstone shape the per-event DELETE route and the
|
||||
* `intake-slot-dedup` worker write, so iOS delta-sync drops them.
|
||||
*
|
||||
* 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.
|
||||
* User intention is unknowable here, so these are REPORTED only
|
||||
* (correct or delete them in the medication history tab). With
|
||||
* `--fix --tombstone-implausible` the operator can explicitly opt
|
||||
* into soft-deleting them.
|
||||
*
|
||||
* 3. WINDOW / TIMES DRIFT (v1.16.1) — `medication_schedules` rows whose
|
||||
* legacy `window_start` / `window_end` no longer contains the
|
||||
* canonical `times_of_day` (e.g. a degenerate 07:00 / 07:00 window
|
||||
* left behind while the dose times moved to 09:00 / 21:00). The
|
||||
* band model never reads the window when `times_of_day` exists, but
|
||||
* the stale pair keeps confusing every legacy read. Repair: clamp
|
||||
* `window_start` / `window_end` to `min/max(times_of_day)`. Report
|
||||
* always; mutate with `--fix`. Overnight windows
|
||||
* (`window_end < window_start`) are skipped — they encode a
|
||||
* deliberate wrap and cannot drift in this shape.
|
||||
*
|
||||
* 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 (user 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
|
||||
* anchors — they linger as phantom open doses. Repair: tombstone
|
||||
* with `--fix` (same soft-delete shape as defect 1).
|
||||
*
|
||||
* After any mutation the affected `(user, medication, day)` compliance
|
||||
* rollups are recomputed with the SAME slot-level DISTINCT aggregation the
|
||||
* shared helper runs — see `recomputeComplianceDay` below, a verbatim
|
||||
* SQL twin of `recomputeMedicationComplianceForDay` in
|
||||
* `src/lib/rollups/medication-compliance-rollups.ts`. The rollup
|
||||
* aggregates DISTINCT slots since v1.15.19, so a recompute alone already
|
||||
* heals the counts; the tombstones additionally clear the phantom rows
|
||||
* from the history view.
|
||||
*
|
||||
* Default is a DRY-RUN that only reports. Idempotent: a second `--fix`
|
||||
* run finds zero groups and changes nothing.
|
||||
*
|
||||
* SELF-CONTAINED by design (v1.16.0)
|
||||
* ----------------------------------
|
||||
* The production standalone image ships neither the project
|
||||
* `node_modules` tree nor the `@/` path alias, so the previous
|
||||
* `dotenv/config` + `@/lib/db` import chain died with "Cannot find
|
||||
* module" inside the container. This script therefore depends on
|
||||
* exactly ONE package — `pg` — and reads `DATABASE_URL` straight from
|
||||
* the environment (no dotenv). Inside the production image `pg` resolves
|
||||
* via `NODE_PATH=/opt/pg-boss/node_modules` (Dockerfile installs
|
||||
* `pg@8.x` there); in a repo checkout it resolves from the project
|
||||
* `node_modules`. The `pnpm dlx` invocation below supplies `tsx` (the
|
||||
* standalone image strips it) and pins `pg` for any environment where
|
||||
* neither resolution path exists.
|
||||
*
|
||||
* Usage
|
||||
* -----
|
||||
* # dry-run (DATABASE_URL must point at the target database)
|
||||
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
|
||||
*
|
||||
* # scoped dry-run
|
||||
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user <id>
|
||||
*
|
||||
* # apply
|
||||
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
|
||||
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
|
||||
*/
|
||||
import { Client, types } from "pg";
|
||||
|
||||
const TAG = "[repair-intake-anomalies]";
|
||||
|
||||
// Prisma stores `DateTime` columns as `timestamp(3)` WITHOUT time zone,
|
||||
// carrying UTC wall-clock values. node-postgres would parse those as
|
||||
// LOCAL time by default; this parser pins them back to UTC so every
|
||||
// Date this script handles is the true instant. (OID 1114 = timestamp
|
||||
// without time zone.)
|
||||
types.setTypeParser(1114, (value: string) => new Date(`${value.replace(" ", "T")}Z`));
|
||||
|
||||
/**
|
||||
* Serialise a Date for a `timestamp without time zone` comparison /
|
||||
* equality parameter. The ISO string's `Z` suffix is ignored by the
|
||||
* `::timestamp` cast, so the bound value is the UTC wall-clock — the
|
||||
* exact representation the column stores. Millisecond-precise, so
|
||||
* slot-equality (`scheduled_for = $n::timestamp`) round-trips exactly.
|
||||
*/
|
||||
function utcParam(date: Date): string {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
interface CliOptions {
|
||||
fix: boolean;
|
||||
tombstoneImplausible: boolean;
|
||||
userId: string | null;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliOptions {
|
||||
const opts: CliOptions = {
|
||||
fix: false,
|
||||
tombstoneImplausible: false,
|
||||
userId: null,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--fix") {
|
||||
opts.fix = true;
|
||||
} else if (arg === "--tombstone-implausible") {
|
||||
opts.tombstoneImplausible = true;
|
||||
} else if (arg === "--user" && argv[i + 1]) {
|
||||
opts.userId = argv[i + 1];
|
||||
i += 1;
|
||||
} else {
|
||||
console.error(`${TAG} unknown argument: ${arg}`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
interface IntakeRow {
|
||||
id: string;
|
||||
takenAt: Date | null;
|
||||
skipped: boolean;
|
||||
source: string;
|
||||
createdAt: Date;
|
||||
/**
|
||||
* v1.16.0 — slot-binding provenance; USER_PIN = the user fixed the
|
||||
* attribution by hand (pin onto a slot OR released as deliberately
|
||||
* ad-hoc with `scheduledFor === takenAt`).
|
||||
*/
|
||||
attributionSource: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank a row for the slot-winner pick: pinned take (3) > taken (2) >
|
||||
* skipped (1) > pending (0). `takenAt` set and `skipped` together still
|
||||
* counts as taken — the recorded dose dominates. The USER_PIN rung
|
||||
* (v1.16.0) mirrors the dedup worker's rank
|
||||
* (`src/lib/medications/intake-slot-dedup.ts`). The worker additionally
|
||||
* excludes USER_PIN rows from its SNAP clusters (the snap could move a
|
||||
* user-fixed anchor); this script needs no such exclusion — its groups
|
||||
* are exact-instant, so a USER_PIN row in a group already sits ON its
|
||||
* fixed anchor and the rank keeps it as the dose of record there.
|
||||
*/
|
||||
function rowRank(row: IntakeRow): number {
|
||||
if (row.takenAt !== null) {
|
||||
return row.attributionSource === "USER_PIN" ? 3 : 2;
|
||||
}
|
||||
if (row.skipped) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the winner among the live rows sharing one exact slot.
|
||||
*
|
||||
* H1 precedence — neither in-tree helper is exported, so the rule is
|
||||
* rebuilt here; see `pickSlotRow` in
|
||||
* `src/lib/medications/scheduling/slot-upsert.ts` (actioned > pending,
|
||||
* deterministic `createdAt asc, id asc` tie-break) and `pickWinner` in
|
||||
* `src/lib/medications/intake-slot-dedup.ts` (taken > skipped > pending):
|
||||
*
|
||||
* 1. actioned beats pending (never resurrect a phantom pending row;
|
||||
* deleting a recorded dose would under-report adherence — the
|
||||
* dangerous direction);
|
||||
* 2. between two taken rows the EARLIER `takenAt` wins — the first
|
||||
* recorded intake is the dose of record;
|
||||
* 3. final tie-break `createdAt asc`, then `id asc` — fully
|
||||
* deterministic across runs and DB orderings.
|
||||
*/
|
||||
function pickWinner(rows: IntakeRow[]): IntakeRow {
|
||||
return [...rows].sort((a, b) => {
|
||||
const rank = rowRank(b) - rowRank(a);
|
||||
if (rank !== 0) return rank;
|
||||
if (a.takenAt !== null && b.takenAt !== null) {
|
||||
const dt = a.takenAt.getTime() - b.takenAt.getTime();
|
||||
if (dt !== 0) return dt;
|
||||
}
|
||||
const created = a.createdAt.getTime() - b.createdAt.getTime();
|
||||
if (created !== 0) return created;
|
||||
return a.id < b.id ? -1 : 1;
|
||||
})[0];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Timezone / day-key helpers — self-contained twins of the app helpers
|
||||
// the previous revision imported. Reference implementations:
|
||||
// - `userDayKey` / `DEFAULT_TIMEZONE`: `src/lib/tz/format.ts`
|
||||
// - `tzOffsetMinutes` / `startOfDayUtcInTz` / the DST-safe day-after
|
||||
// boundary: `src/lib/rollups/medication-compliance-rollups.ts`
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
function isValidTimezone(tz: string): boolean {
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function safeTimezone(tz: string | null | undefined): string {
|
||||
if (tz && isValidTimezone(tz)) return tz;
|
||||
return DEFAULT_TIMEZONE;
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` of the instant in the user's tz (en-CA = ISO order). */
|
||||
function dayKeyForScheduledFor(
|
||||
scheduledFor: Date,
|
||||
tz: string | null | undefined,
|
||||
): string {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: safeTimezone(tz),
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(scheduledFor);
|
||||
}
|
||||
|
||||
/** UTC offset (minutes) of `tz` at the given instant; honours DST. */
|
||||
function tzOffsetMinutes(date: Date, tz: string): number {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
||||
parts.find((p) => p.type === type)?.value ?? "0";
|
||||
let hour = Number(get("hour"));
|
||||
if (hour === 24) hour = 0;
|
||||
const asIfUtc = Date.UTC(
|
||||
Number(get("year")),
|
||||
Number(get("month")) - 1,
|
||||
Number(get("day")),
|
||||
hour,
|
||||
Number(get("minute")),
|
||||
Number(get("second")),
|
||||
);
|
||||
return Math.round((asIfUtc - date.getTime()) / 60000);
|
||||
}
|
||||
|
||||
/** UTC instant of local-midnight on `dayKey` in `tz` (2-pass for DST). */
|
||||
function startOfDayUtcInTz(dayKey: string, tz: string): Date {
|
||||
const [yearStr, monthStr, dayStr] = dayKey.split("-");
|
||||
const year = Number(yearStr);
|
||||
const month = Number(monthStr);
|
||||
const day = Number(dayStr);
|
||||
let guess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0));
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const offsetMin = tzOffsetMinutes(guess, tz);
|
||||
guess = new Date(
|
||||
Date.UTC(year, month - 1, day, 0, 0, 0, 0) - offsetMin * 60_000,
|
||||
);
|
||||
}
|
||||
return guess;
|
||||
}
|
||||
|
||||
/**
|
||||
* `[start, windowEnd)` UTC window of the local calendar day — the same
|
||||
* DST-safe boundary derivation `recomputeMedicationComplianceForDay`
|
||||
* runs (23 / 25-hour days fold correctly because the end anchors on
|
||||
* the NEXT day's local midnight, not `start + 24 h`).
|
||||
*/
|
||||
function dayWindowUtc(dayKey: string, tz: string): { start: Date; end: Date } {
|
||||
const start = startOfDayUtcInTz(dayKey, tz);
|
||||
const fallbackEnd = new Date(start.getTime() + 86_400_000);
|
||||
const [y, m, d] = dayKey.split("-").map(Number);
|
||||
const probeNext = new Date(Date.UTC(y, m - 1, d, 12, 0, 0) + 86_400_000);
|
||||
const nextDayKey = dayKeyForScheduledFor(probeNext, tz);
|
||||
const dstSafeEnd = startOfDayUtcInTz(nextDayKey, tz);
|
||||
return {
|
||||
start,
|
||||
end: dstSafeEnd.getTime() > start.getTime() ? dstSafeEnd : fallbackEnd,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute one `(userId, medicationId, dayKey)` compliance-rollup row.
|
||||
*
|
||||
* VERBATIM SQL TWIN of `recomputeMedicationComplianceForDay` in
|
||||
* `src/lib/rollups/medication-compliance-rollups.ts` — keep the two in
|
||||
* sync when the rollup contract changes. v1.15.19 semantics: the inner
|
||||
* CTE folds rows sharing one `scheduled_for` instant into one DISTINCT
|
||||
* slot with taken-beats-skipped priority; the outer aggregate counts
|
||||
* slots, not rows, so a duplicated slot can never inflate `scheduled`.
|
||||
* The upsert re-aggregates inside the statement (atomic against
|
||||
* concurrent app writers); the trailing DELETE clears the row when the
|
||||
* day no longer holds any live event.
|
||||
*/
|
||||
async function recomputeComplianceDay(
|
||||
client: Client,
|
||||
userId: string,
|
||||
medicationId: string,
|
||||
dayKey: string,
|
||||
tz: string | null | undefined,
|
||||
): Promise<void> {
|
||||
const { start, end } = dayWindowUtc(dayKey, safeTimezone(tz));
|
||||
await client.query(
|
||||
`
|
||||
WITH slot AS (
|
||||
SELECT
|
||||
BOOL_OR("taken_at" IS NOT NULL AND NOT "skipped") AS slot_taken,
|
||||
BOOL_OR("skipped") AS slot_skipped
|
||||
FROM "medication_intake_events"
|
||||
WHERE "user_id" = $1
|
||||
AND "medication_id" = $2
|
||||
AND "deleted_at" IS NULL
|
||||
AND "scheduled_for" >= $4::timestamp
|
||||
AND "scheduled_for" < $5::timestamp
|
||||
GROUP BY "scheduled_for"
|
||||
),
|
||||
aggregate AS (
|
||||
SELECT
|
||||
COUNT(*)::int AS scheduled,
|
||||
COALESCE(SUM(CASE WHEN "slot_taken" THEN 1 ELSE 0 END), 0)::int AS taken,
|
||||
COALESCE(SUM(CASE WHEN NOT "slot_taken" AND "slot_skipped" THEN 1 ELSE 0 END), 0)::int AS skipped
|
||||
FROM slot
|
||||
)
|
||||
INSERT INTO "medication_compliance_rollups"
|
||||
("user_id", "medication_id", "day", "scheduled", "taken", "skipped", "computed_at")
|
||||
SELECT $1, $2, $3, aggregate.scheduled, aggregate.taken, aggregate.skipped, NOW()
|
||||
FROM aggregate
|
||||
WHERE aggregate.scheduled > 0
|
||||
ON CONFLICT ("user_id", "medication_id", "day") DO UPDATE
|
||||
SET "scheduled" = EXCLUDED."scheduled",
|
||||
"taken" = EXCLUDED."taken",
|
||||
"skipped" = EXCLUDED."skipped",
|
||||
"computed_at" = EXCLUDED."computed_at"
|
||||
`,
|
||||
[userId, medicationId, dayKey, utcParam(start), utcParam(end)],
|
||||
);
|
||||
await client.query(
|
||||
`
|
||||
DELETE FROM "medication_compliance_rollups"
|
||||
WHERE "user_id" = $1
|
||||
AND "medication_id" = $2
|
||||
AND "day" = $3
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "medication_intake_events"
|
||||
WHERE "user_id" = $1
|
||||
AND "medication_id" = $2
|
||||
AND "deleted_at" IS NULL
|
||||
AND "scheduled_for" >= $4::timestamp
|
||||
AND "scheduled_for" < $5::timestamp
|
||||
)
|
||||
`,
|
||||
[userId, medicationId, dayKey, utcParam(start), utcParam(end)],
|
||||
);
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
duplicateGroups: number;
|
||||
rowsTombstoned: number;
|
||||
implausibleRows: number;
|
||||
implausibleTombstoned: number;
|
||||
windowDriftSchedules: number;
|
||||
windowsReconciled: number;
|
||||
staleAnchorRows: number;
|
||||
staleAnchorTombstoned: number;
|
||||
rollupsRecomputed: number;
|
||||
}
|
||||
|
||||
const HHMM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
/** Wall-clock `HH:mm` of `instant` in the user's timezone. */
|
||||
function hhmmInTz(instant: Date, tz: string | null | undefined): string {
|
||||
const parts = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: safeTimezone(tz),
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).formatToParts(instant);
|
||||
const get = (type: Intl.DateTimeFormatPartTypes): string =>
|
||||
parts.find((p) => p.type === type)?.value ?? "00";
|
||||
// Some engines render midnight as 24:xx; normalise to 00.
|
||||
const hour = String(Number(get("hour")) % 24).padStart(2, "0");
|
||||
return `${hour}:${get("minute")}`;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error(
|
||||
`${TAG} DATABASE_URL must be set (no dotenv — export it or prefix ` +
|
||||
`the command: DATABASE_URL='postgresql://…' pnpm dlx …)`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
if (opts.tombstoneImplausible && !opts.fix) {
|
||||
console.error(
|
||||
`${TAG} --tombstone-implausible requires --fix (dry-run never mutates)`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${TAG} mode=${opts.fix ? "FIX" : "DRY-RUN"}` +
|
||||
(opts.tombstoneImplausible ? " +tombstone-implausible" : "") +
|
||||
(opts.userId ? ` user=${opts.userId}` : " (all users)"),
|
||||
);
|
||||
|
||||
const client = new Client({ connectionString: process.env.DATABASE_URL });
|
||||
await client.connect();
|
||||
|
||||
const summary: Summary = {
|
||||
duplicateGroups: 0,
|
||||
rowsTombstoned: 0,
|
||||
implausibleRows: 0,
|
||||
implausibleTombstoned: 0,
|
||||
windowDriftSchedules: 0,
|
||||
windowsReconciled: 0,
|
||||
staleAnchorRows: 0,
|
||||
staleAnchorTombstoned: 0,
|
||||
rollupsRecomputed: 0,
|
||||
};
|
||||
|
||||
// Optional per-user scope. The fragment is a fixed string with a
|
||||
// positional placeholder — never user-input spliced.
|
||||
const userFilter = opts.userId ? `AND e."user_id" = $1` : "";
|
||||
const userParams = opts.userId ? [opts.userId] : [];
|
||||
|
||||
/** `userId -> timezone` cache for the rollup day-key derivation. */
|
||||
const tzCache = new Map<string, string | null>();
|
||||
async function userTimezone(userId: string): Promise<string | null> {
|
||||
if (!tzCache.has(userId)) {
|
||||
const res = await client.query<{ timezone: string | null }>(
|
||||
`SELECT "timezone" FROM "users" WHERE "id" = $1`,
|
||||
[userId],
|
||||
);
|
||||
tzCache.set(userId, res.rows[0]?.timezone ?? null);
|
||||
}
|
||||
return tzCache.get(userId) ?? null;
|
||||
}
|
||||
|
||||
// `(userId, medicationId, dayKey)` tuples whose compliance rollup needs
|
||||
// a recompute after the tombstones. Deduped so a busy day folds once.
|
||||
const daysToRecompute = new Set<string>();
|
||||
|
||||
try {
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 1. Slot duplicates — exact (user, medication, scheduled_for)
|
||||
// tuples carrying more than one live row.
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
const groups = await client.query<{
|
||||
user_id: string;
|
||||
medication_id: string;
|
||||
scheduled_for: Date;
|
||||
row_count: number;
|
||||
}>(
|
||||
`
|
||||
SELECT e."user_id", e."medication_id", e."scheduled_for",
|
||||
COUNT(*)::int AS row_count
|
||||
FROM "medication_intake_events" e
|
||||
WHERE e."deleted_at" IS NULL
|
||||
${userFilter}
|
||||
GROUP BY e."user_id", e."medication_id", e."scheduled_for"
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY e."user_id", e."medication_id", e."scheduled_for"
|
||||
`,
|
||||
userParams,
|
||||
);
|
||||
|
||||
summary.duplicateGroups = groups.rows.length;
|
||||
console.log(
|
||||
`\n${TAG} 1) duplicate slot groups found: ${groups.rows.length}`,
|
||||
);
|
||||
|
||||
for (const group of groups.rows) {
|
||||
const rowsRes = await client.query<{
|
||||
id: string;
|
||||
taken_at: Date | null;
|
||||
skipped: boolean;
|
||||
source: string;
|
||||
created_at: Date;
|
||||
attribution_source: string;
|
||||
}>(
|
||||
`
|
||||
SELECT "id", "taken_at", "skipped", "source"::text AS source, "created_at",
|
||||
"attribution_source"::text AS attribution_source
|
||||
FROM "medication_intake_events"
|
||||
WHERE "user_id" = $1
|
||||
AND "medication_id" = $2
|
||||
AND "scheduled_for" = $3::timestamp
|
||||
AND "deleted_at" IS NULL
|
||||
ORDER BY "created_at" ASC, "id" ASC
|
||||
`,
|
||||
[group.user_id, group.medication_id, utcParam(group.scheduled_for)],
|
||||
);
|
||||
const rows: IntakeRow[] = rowsRes.rows.map((r) => ({
|
||||
id: r.id,
|
||||
takenAt: r.taken_at,
|
||||
skipped: r.skipped,
|
||||
source: r.source,
|
||||
createdAt: r.created_at,
|
||||
attributionSource: r.attribution_source,
|
||||
}));
|
||||
if (rows.length < 2) continue; // collapsed since the scan — nothing to do.
|
||||
|
||||
const winner = pickWinner(rows);
|
||||
const losers = rows.filter((r) => r.id !== winner.id);
|
||||
|
||||
console.log(
|
||||
` - user=${group.user_id} medication=${group.medication_id} ` +
|
||||
`slot=${group.scheduled_for.toISOString()} rows=${rows.length} ` +
|
||||
`keep=${winner.id} (${winner.source}, ` +
|
||||
`${winner.takenAt ? `taken ${winner.takenAt.toISOString()}` : winner.skipped ? "skipped" : "pending"}) ` +
|
||||
`tombstone=[${losers.map((r) => `${r.id} (${r.source})`).join(", ")}]`,
|
||||
);
|
||||
|
||||
if (opts.fix) {
|
||||
// Same tombstone the per-event DELETE route and the dedup worker
|
||||
// write: soft-delete + syncVersion bump (the sync feed echoes a
|
||||
// monotonic value so iOS drops the row). Prisma's `@updatedAt`
|
||||
// is client-side, so raw SQL bumps `updated_at` explicitly —
|
||||
// the offline-sync keyset feed orders on it. The
|
||||
// `deleted_at IS NULL` guard keeps the write idempotent against
|
||||
// a concurrent collapse.
|
||||
const res = await client.query(
|
||||
`
|
||||
UPDATE "medication_intake_events"
|
||||
SET "deleted_at" = NOW(),
|
||||
"sync_version" = "sync_version" + 1,
|
||||
"updated_at" = NOW()
|
||||
WHERE "id" = ANY($1) AND "deleted_at" IS NULL
|
||||
`,
|
||||
[losers.map((r) => r.id)],
|
||||
);
|
||||
summary.rowsTombstoned += res.rowCount ?? 0;
|
||||
|
||||
const tz = await userTimezone(group.user_id);
|
||||
const dayKey = dayKeyForScheduledFor(group.scheduled_for, tz);
|
||||
daysToRecompute.add(
|
||||
`${group.user_id}|${group.medication_id}|${dayKey}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 2. Implausible taken_at — report only; tombstone needs explicit
|
||||
// operator opt-in.
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
const implausibleRes = await client.query<{
|
||||
id: string;
|
||||
user_id: string;
|
||||
medication_id: string;
|
||||
medication_name: string;
|
||||
scheduled_for: Date;
|
||||
taken_at: Date;
|
||||
source: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}>(
|
||||
`
|
||||
SELECT e."id", e."user_id", e."medication_id",
|
||||
m."name" AS medication_name,
|
||||
e."scheduled_for", e."taken_at", e."source"::text AS source,
|
||||
e."created_at", e."updated_at"
|
||||
FROM "medication_intake_events" e
|
||||
JOIN "medications" m ON m."id" = e."medication_id"
|
||||
WHERE e."deleted_at" IS NULL
|
||||
AND e."taken_at" IS NOT NULL
|
||||
AND (e."taken_at" < e."scheduled_for" - interval '7 days'
|
||||
OR e."taken_at" > now() + interval '1 day')
|
||||
${userFilter}
|
||||
ORDER BY e."user_id", e."taken_at"
|
||||
`,
|
||||
userParams,
|
||||
);
|
||||
const implausible = implausibleRes.rows;
|
||||
|
||||
summary.implausibleRows = implausible.length;
|
||||
console.log(
|
||||
`\n${TAG} 2) implausible taken_at rows found: ${implausible.length}`,
|
||||
);
|
||||
if (implausible.length > 0) {
|
||||
console.table(
|
||||
implausible.map((r) => ({
|
||||
id: r.id,
|
||||
medication: r.medication_name,
|
||||
scheduledFor: r.scheduled_for.toISOString(),
|
||||
takenAt: r.taken_at.toISOString(),
|
||||
source: r.source,
|
||||
createdAt: r.created_at.toISOString(),
|
||||
updatedAt: r.updated_at.toISOString(),
|
||||
})),
|
||||
);
|
||||
if (!(opts.fix && opts.tombstoneImplausible)) {
|
||||
console.log(
|
||||
`${TAG} these rows are NOT changed automatically — the recorded ` +
|
||||
`intent is unknowable. Correct or delete them in the medication ` +
|
||||
`history tab, or re-run with --fix --tombstone-implausible to ` +
|
||||
`soft-delete them.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.fix && opts.tombstoneImplausible && implausible.length > 0) {
|
||||
const res = await client.query(
|
||||
`
|
||||
UPDATE "medication_intake_events"
|
||||
SET "deleted_at" = NOW(),
|
||||
"sync_version" = "sync_version" + 1,
|
||||
"updated_at" = NOW()
|
||||
WHERE "id" = ANY($1) AND "deleted_at" IS NULL
|
||||
`,
|
||||
[implausible.map((r) => r.id)],
|
||||
);
|
||||
summary.implausibleTombstoned = res.rowCount ?? 0;
|
||||
console.log(
|
||||
`${TAG} tombstoned ${res.rowCount ?? 0} implausible row(s) (operator opt-in)`,
|
||||
);
|
||||
|
||||
for (const row of implausible) {
|
||||
const tz = await userTimezone(row.user_id);
|
||||
const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz);
|
||||
daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 3. Window / times drift — schedules whose legacy window no longer
|
||||
// contains the canonical times_of_day. Reconcile the window to
|
||||
// min/max(times_of_day) with --fix. Overnight windows
|
||||
// (window_end < window_start) are skipped by the predicate.
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
const schedUserFilter = opts.userId ? `AND m."user_id" = $1` : "";
|
||||
const driftRes = await client.query<{
|
||||
id: string;
|
||||
medication_id: string;
|
||||
user_id: string;
|
||||
medication_name: string;
|
||||
window_start: string;
|
||||
window_end: string;
|
||||
times_min: string;
|
||||
times_max: string;
|
||||
}>(
|
||||
`
|
||||
SELECT s."id", s."medication_id", m."user_id",
|
||||
m."name" AS medication_name,
|
||||
s."window_start", s."window_end",
|
||||
(SELECT MIN(t) FROM unnest(s."times_of_day") AS t) AS times_min,
|
||||
(SELECT MAX(t) FROM unnest(s."times_of_day") AS t) AS times_max
|
||||
FROM "medication_schedules" s
|
||||
JOIN "medications" m ON m."id" = s."medication_id"
|
||||
WHERE COALESCE(array_length(s."times_of_day", 1), 0) > 0
|
||||
AND s."window_start" <= s."window_end"
|
||||
AND (
|
||||
(SELECT MIN(t) FROM unnest(s."times_of_day") AS t) < s."window_start"
|
||||
OR (SELECT MAX(t) FROM unnest(s."times_of_day") AS t) > s."window_end"
|
||||
)
|
||||
${schedUserFilter}
|
||||
ORDER BY m."user_id", s."medication_id", s."id"
|
||||
`,
|
||||
userParams,
|
||||
);
|
||||
|
||||
summary.windowDriftSchedules = driftRes.rows.length;
|
||||
console.log(
|
||||
`\n${TAG} 3) schedules whose window does not contain times_of_day: ${driftRes.rows.length}`,
|
||||
);
|
||||
for (const row of driftRes.rows) {
|
||||
console.log(
|
||||
` - schedule=${row.id} medication=${row.medication_id} (${row.medication_name}) ` +
|
||||
`window=${row.window_start}..${row.window_end} → ` +
|
||||
`${row.times_min}..${row.times_max}`,
|
||||
);
|
||||
if (!HHMM_RE.test(row.times_min) || !HHMM_RE.test(row.times_max)) {
|
||||
console.log(
|
||||
`${TAG} SKIP: malformed times_of_day bounds on schedule ${row.id}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (opts.fix) {
|
||||
const res = await client.query(
|
||||
`
|
||||
UPDATE "medication_schedules"
|
||||
SET "window_start" = $2,
|
||||
"window_end" = $3
|
||||
WHERE "id" = $1
|
||||
`,
|
||||
[row.id, row.times_min, row.times_max],
|
||||
);
|
||||
summary.windowsReconciled += res.rowCount ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 4. Stale-anchor pending rows — live pending rows whose
|
||||
// scheduled_for HH:mm (user tz) matches no current dose anchor of
|
||||
// the medication. Tombstone with --fix.
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
const pendingRes = await client.query<{
|
||||
id: string;
|
||||
user_id: string;
|
||||
medication_id: string;
|
||||
medication_name: string;
|
||||
scheduled_for: Date;
|
||||
source: string;
|
||||
}>(
|
||||
`
|
||||
SELECT e."id", e."user_id", e."medication_id",
|
||||
m."name" AS medication_name,
|
||||
e."scheduled_for", e."source"::text AS source
|
||||
FROM "medication_intake_events" e
|
||||
JOIN "medications" m ON m."id" = e."medication_id"
|
||||
WHERE e."deleted_at" IS NULL
|
||||
AND e."taken_at" IS NULL
|
||||
AND e."skipped" = false
|
||||
AND e."auto_missed" = false
|
||||
${userFilter}
|
||||
ORDER BY e."user_id", e."scheduled_for"
|
||||
`,
|
||||
userParams,
|
||||
);
|
||||
|
||||
/** `medicationId -> Set<HH:mm>` of current dose anchors. */
|
||||
const anchorCache = new Map<string, Set<string>>();
|
||||
async function medicationAnchors(
|
||||
medicationId: string,
|
||||
): Promise<Set<string>> {
|
||||
const cached = anchorCache.get(medicationId);
|
||||
if (cached) return cached;
|
||||
const res = await client.query<{
|
||||
window_start: string;
|
||||
times_of_day: string[];
|
||||
}>(
|
||||
`SELECT "window_start", "times_of_day"
|
||||
FROM "medication_schedules" WHERE "medication_id" = $1`,
|
||||
[medicationId],
|
||||
);
|
||||
const anchors = new Set<string>();
|
||||
for (const s of res.rows) {
|
||||
const times = (s.times_of_day ?? []).filter((t) => HHMM_RE.test(t));
|
||||
if (times.length > 0) {
|
||||
for (const t of times) anchors.add(t);
|
||||
} else if (HHMM_RE.test(s.window_start)) {
|
||||
// Legacy row without times_of_day — its anchor is window_start.
|
||||
anchors.add(s.window_start);
|
||||
}
|
||||
}
|
||||
anchorCache.set(medicationId, anchors);
|
||||
return anchors;
|
||||
}
|
||||
|
||||
const staleRows: typeof pendingRes.rows = [];
|
||||
for (const row of pendingRes.rows) {
|
||||
const anchors = await medicationAnchors(row.medication_id);
|
||||
// No derivable anchors (PRN-only / unscheduled) — leave the row.
|
||||
if (anchors.size === 0) continue;
|
||||
const tz = await userTimezone(row.user_id);
|
||||
if (!anchors.has(hhmmInTz(row.scheduled_for, tz))) {
|
||||
staleRows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
summary.staleAnchorRows = staleRows.length;
|
||||
console.log(
|
||||
`\n${TAG} 4) live pending rows on stale schedule anchors: ${staleRows.length}`,
|
||||
);
|
||||
if (staleRows.length > 0) {
|
||||
console.table(
|
||||
staleRows.map((r) => ({
|
||||
id: r.id,
|
||||
medication: r.medication_name,
|
||||
scheduledFor: r.scheduled_for.toISOString(),
|
||||
source: r.source,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.fix && staleRows.length > 0) {
|
||||
const res = await client.query(
|
||||
`
|
||||
UPDATE "medication_intake_events"
|
||||
SET "deleted_at" = NOW(),
|
||||
"sync_version" = "sync_version" + 1,
|
||||
"updated_at" = NOW()
|
||||
WHERE "id" = ANY($1) AND "deleted_at" IS NULL
|
||||
`,
|
||||
[staleRows.map((r) => r.id)],
|
||||
);
|
||||
summary.staleAnchorTombstoned = res.rowCount ?? 0;
|
||||
|
||||
for (const row of staleRows) {
|
||||
const tz = await userTimezone(row.user_id);
|
||||
const dayKey = dayKeyForScheduledFor(row.scheduled_for, tz);
|
||||
daysToRecompute.add(`${row.user_id}|${row.medication_id}|${dayKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 5. Recompute the affected compliance rollups so scheduled/taken
|
||||
// counts self-correct. Same DISTINCT-slot SQL the shared helper
|
||||
// runs (see `recomputeComplianceDay` above).
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
if (opts.fix && daysToRecompute.size > 0) {
|
||||
console.log(
|
||||
`\n${TAG} 5) recomputing ${daysToRecompute.size} compliance rollup day(s)`,
|
||||
);
|
||||
for (const key of daysToRecompute) {
|
||||
const [userId, medicationId, dayKey] = key.split("|");
|
||||
try {
|
||||
await recomputeComplianceDay(
|
||||
client,
|
||||
userId,
|
||||
medicationId,
|
||||
dayKey,
|
||||
await userTimezone(userId),
|
||||
);
|
||||
summary.rollupsRecomputed += 1;
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`${TAG} rollup recompute failed for ${key}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
// Summary
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
console.log(`\n${TAG} ${opts.fix ? "DONE" : "DRY-RUN — nothing changed"}`);
|
||||
console.log(` duplicate slot groups: ${summary.duplicateGroups}`);
|
||||
console.log(` duplicate rows tombstoned: ${summary.rowsTombstoned}`);
|
||||
console.log(` implausible taken_at rows: ${summary.implausibleRows}`);
|
||||
console.log(` implausible rows tombstoned: ${summary.implausibleTombstoned}`);
|
||||
console.log(` window/times drift schedules: ${summary.windowDriftSchedules}`);
|
||||
console.log(` windows reconciled: ${summary.windowsReconciled}`);
|
||||
console.log(` stale-anchor pending rows: ${summary.staleAnchorRows}`);
|
||||
console.log(` stale-anchor rows tombstoned: ${summary.staleAnchorTombstoned}`);
|
||||
console.log(` rollup days recomputed: ${summary.rollupsRecomputed}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`${TAG} fatal error:`, err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* scripts/reset-password.mjs — operator-only CLI to reset a single user's
|
||||
* password from inside the running container.
|
||||
*
|
||||
* WHY plain `.mjs` and not the `.ts` scripts pattern: the production standalone
|
||||
* image strips `tsx` and ships the Prisma client only as bundled TypeScript
|
||||
* inside `server.js` (not importable from a standalone process). This script
|
||||
* therefore talks to Postgres through `pg` (present in the image) and hashes
|
||||
* with `@node-rs/argon2` (also present), reusing the ONE shared Argon2id param
|
||||
* object that the app's `hashPassword()` uses, so the hash it writes is
|
||||
* byte-compatible with a hash the app would mint.
|
||||
*
|
||||
* Usage (inside the container — operator only):
|
||||
* docker compose exec app node scripts/reset-password.mjs <username-or-email> [new-password]
|
||||
*
|
||||
* If the password is omitted it is read from stdin without echoing. The
|
||||
* password is never printed back. Exits non-zero with a clear message when the
|
||||
* identifier matches zero or more than one user.
|
||||
*
|
||||
* Reads `DATABASE_URL` from the environment (the same var the app uses). The
|
||||
* connection honours any `sslmode=` in the URL exactly like the app pool.
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { hash } from "@node-rs/argon2";
|
||||
import pg from "pg";
|
||||
import { ARGON2_HASH_OPTIONS } from "../src/lib/auth/argon2-params.mjs";
|
||||
|
||||
function fail(message) {
|
||||
console.error(`reset-password: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Read a line from stdin without echoing it to the terminal. */
|
||||
function promptHidden(label) {
|
||||
return new Promise((resolve) => {
|
||||
process.stdout.write(label);
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
// Override the line-writer so typed characters are never echoed. The
|
||||
// prompt label above is already written; the closing newline is emitted
|
||||
// when the answer resolves.
|
||||
rl._writeToOutput = () => {};
|
||||
rl.question("", (answer) => {
|
||||
rl.close();
|
||||
process.stdout.write("\n");
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const identifier = process.argv[2];
|
||||
if (!identifier) {
|
||||
fail(
|
||||
"usage: node scripts/reset-password.mjs <username-or-email> [new-password]",
|
||||
);
|
||||
}
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
fail("DATABASE_URL must be set");
|
||||
}
|
||||
|
||||
let newPassword = process.argv[3];
|
||||
if (!newPassword) {
|
||||
newPassword = await promptHidden("New password (input hidden): ");
|
||||
}
|
||||
if (!newPassword || newPassword.length < 12) {
|
||||
fail("new password must be at least 12 characters");
|
||||
}
|
||||
|
||||
const client = new pg.Client({ connectionString: databaseUrl });
|
||||
await client.connect();
|
||||
try {
|
||||
// Case-insensitive match on username OR email, mirroring the login lookup.
|
||||
// Scope to a single user: zero or multiple matches is a hard error so an
|
||||
// operator never silently resets the wrong account.
|
||||
const found = await client.query(
|
||||
`SELECT id, username FROM users
|
||||
WHERE lower(username) = lower($1) OR lower(email) = lower($1)`,
|
||||
[identifier],
|
||||
);
|
||||
|
||||
if (found.rowCount === 0) {
|
||||
fail(`no user matches "${identifier}"`);
|
||||
}
|
||||
if (found.rowCount > 1) {
|
||||
fail(
|
||||
`"${identifier}" matches ${found.rowCount} users — refusing to guess; reset by exact username`,
|
||||
);
|
||||
}
|
||||
|
||||
const user = found.rows[0];
|
||||
const passwordHash = await hash(newPassword, ARGON2_HASH_OPTIONS);
|
||||
|
||||
await client.query(
|
||||
`UPDATE users SET password_hash = $1, updated_at = now() WHERE id = $2`,
|
||||
[passwordHash, user.id],
|
||||
);
|
||||
|
||||
// Never echo the password; confirm by username only.
|
||||
console.log(`reset-password: password updated for "${user.username}"`);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
fail(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
-- HealthLog demo seed for apps01 — v1.15 (cycle-inclusive, FEMALE demo).
|
||||
--
|
||||
-- Creates / refreshes the `demo` account as a WOMAN with cycle tracking on,
|
||||
-- NOW()-anchored + idempotent (re-runnable: wipes the demo user's rows first).
|
||||
-- Showcases vitals + mood + the v1.15 cycle vertical (5 cycles, daily BBT with
|
||||
-- a biphasic shift, mucus/OPK progression, period flow, phase-clustered
|
||||
-- symptoms), plus phase-dependent RHR/HRV/sleep so the phase×vitals insights
|
||||
-- board surfaces real findings. role=USER (login is open on prod; an
|
||||
-- admin demo would be a risk). AI insights are generated separately afterwards.
|
||||
--
|
||||
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-apps01.sql
|
||||
|
||||
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_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()
|
||||
WHERE NOT EXISTS (SELECT 1 FROM users WHERE username = 'demo');
|
||||
|
||||
UPDATE users SET
|
||||
gender = 'FEMALE', role = 'USER', height_cm = 168, date_of_birth = TIMESTAMP '1992-03-15',
|
||||
password_hash = '$argon2id$v=19$m=19456,t=2,p=1$3KvrEIuI5UnwG53GPbS71w$mL8ZQotIXn9iLFIw3yHXbh6SitX1w7TXKI5ahWcKkF8',
|
||||
onboarding_completed_at = NOW(), timezone = 'Europe/Berlin', locale = 'en', updated_at = NOW()
|
||||
WHERE username = 'demo';
|
||||
|
||||
CREATE TEMP TABLE _demo AS SELECT id AS user_id FROM users WHERE username = 'demo' LIMIT 1;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM _demo) THEN
|
||||
RAISE EXCEPTION 'seed-demo-apps01: demo user not resolved';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── Wipe prior demo rows (idempotent) ─────────────────────────────
|
||||
DELETE FROM cycle_symptom_links WHERE day_log_id IN (SELECT id FROM cycle_day_logs WHERE user_id IN (SELECT user_id FROM _demo));
|
||||
DELETE FROM cycle_day_logs WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM cycle_predictions WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM menstrual_cycles WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM mood_entries WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM measurements WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM measurement_rollups WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
DELETE FROM mood_entry_rollups WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
|
||||
-- ── Cycle profile (tracking ON) ───────────────────────────────────
|
||||
INSERT INTO cycle_profiles (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)
|
||||
SELECT 'cp_' || replace(gen_random_uuid()::text, '-', ''), user_id, 'GENERAL_HEALTH', true, 28, 5, 14, true, false, false, true, NOW(), NOW()
|
||||
FROM _demo
|
||||
WHERE NOT EXISTS (SELECT 1 FROM cycle_profiles cp JOIN _demo d ON cp.user_id = d.user_id);
|
||||
|
||||
UPDATE cycle_profiles SET
|
||||
cycle_tracking_enabled = true, goal = 'GENERAL_HEALTH', prediction_enabled = true,
|
||||
typical_cycle_length = 28, typical_period_length = 5, luteal_phase_length = 14, updated_at = NOW()
|
||||
WHERE user_id IN (SELECT user_id FROM _demo);
|
||||
|
||||
-- ── Measurements (90-day sliding window, phase-aware) ─────────────
|
||||
-- cycleDay(d) = ((128 - d) % 28) + 1 ; luteal = cycleDay > 13.
|
||||
WITH demo AS (SELECT user_id FROM _demo), days AS (SELECT generate_series(0, 89) AS d)
|
||||
INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, measured_at, created_at, updated_at)
|
||||
SELECT 'm_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo), v.type::measurement_type, v.value, v.unit, 'MANUAL'::measurement_source,
|
||||
v.gc::glucose_context,
|
||||
NOW() - (d || ' days')::interval - (random() * 5 || ' hours')::interval,
|
||||
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
|
||||
FROM days,
|
||||
LATERAL (
|
||||
SELECT ((128 - d) % 28) + 1 AS cd
|
||||
) c,
|
||||
LATERAL (VALUES
|
||||
('WEIGHT', round((66.0 - (90 - d) * 0.011 + (random() - 0.5) * 0.5)::numeric, 1)::float8, 'kg', NULL),
|
||||
('BLOOD_PRESSURE_SYS', round((116 + (random() - 0.5) * 8)::numeric, 0)::float8, 'mmHg', NULL),
|
||||
('BLOOD_PRESSURE_DIA', round((74 + (random() - 0.5) * 6)::numeric, 0)::float8, 'mmHg', NULL),
|
||||
('PULSE', round((68 + (random() - 0.5) * 8)::numeric, 0)::float8, 'bpm', NULL),
|
||||
('RESTING_HEART_RATE', round((56 + (CASE WHEN c.cd > 13 THEN 3 ELSE 0 END) + (random() - 0.5) * 3)::numeric, 0)::float8, 'bpm', NULL),
|
||||
('HEART_RATE_VARIABILITY', round((68 - (CASE WHEN c.cd > 13 THEN 7 ELSE 0 END) + (random() - 0.5) * 6)::numeric, 0)::float8, 'ms', NULL),
|
||||
('SLEEP_DURATION', round((445 - (CASE WHEN c.cd > 13 THEN 18 ELSE 0 END) + (random() - 0.5) * 40)::numeric, 0)::float8, 'minutes', NULL),
|
||||
('ACTIVITY_STEPS', round((8200 + (random() - 0.5) * 3500)::numeric, 0)::float8, 'steps', NULL),
|
||||
('BODY_FAT', round((27.5 - (90 - d) * 0.006 + (random() - 0.5) * 0.4)::numeric, 1)::float8, '%', NULL),
|
||||
('BODY_TEMPERATURE', round(((CASE WHEN c.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8, 'celsius', NULL),
|
||||
('BLOOD_GLUCOSE', round((92 + (random() - 0.5) * 18)::numeric, 0)::float8, 'mg/dL', 'FASTING')
|
||||
) AS v(type, value, unit, gc);
|
||||
|
||||
-- ── Mood (90 days, luteal dip) ────────────────────────────────────
|
||||
WITH demo AS (SELECT user_id FROM _demo), days AS (SELECT generate_series(0, 89) AS d)
|
||||
INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, tz, sync_version, created_at, updated_at)
|
||||
SELECT 'mo_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo),
|
||||
to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD'),
|
||||
CASE s.score WHEN 1 THEN 'LAUSIG' WHEN 2 THEN 'SCHLECHT' WHEN 3 THEN 'OKAY' WHEN 4 THEN 'GUT' ELSE 'SUPER_GUT' END,
|
||||
s.score, 'MANUAL',
|
||||
NOW() - (d || ' days')::interval - (random() * 6 || ' hours')::interval,
|
||||
NOW() - (d || ' days')::interval, 'Europe/Berlin', 0,
|
||||
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
|
||||
FROM days,
|
||||
LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c,
|
||||
LATERAL (
|
||||
SELECT GREATEST(1, LEAST(5,
|
||||
round((CASE WHEN c.cd > 23 THEN 2.6 WHEN c.cd > 13 THEN 3.3 ELSE 3.9 END) + (random() - 0.5) * 1.6)::int
|
||||
)) AS score
|
||||
) s;
|
||||
|
||||
-- ── Menstrual cycles (5, NOW-anchored; last open) ─────────────────
|
||||
WITH offs AS (SELECT unnest(ARRAY[128, 100, 72, 44, 16]) AS off)
|
||||
INSERT INTO menstrual_cycles (id, user_id, start_date, end_date, period_end_date, length_days, ovulation_date, ovulation_confirmed, is_predicted, tz, created_at, updated_at)
|
||||
SELECT 'mc_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM _demo),
|
||||
to_char(NOW() - (off || ' days')::interval, 'YYYY-MM-DD'),
|
||||
CASE WHEN off = 16 THEN NULL ELSE to_char(NOW() - ((off - 27) || ' days')::interval, 'YYYY-MM-DD') END,
|
||||
to_char(NOW() - ((off - 4) || ' days')::interval, 'YYYY-MM-DD'),
|
||||
CASE WHEN off = 16 THEN NULL ELSE 28 END,
|
||||
to_char(NOW() - ((off - 13) || ' days')::interval, 'YYYY-MM-DD'),
|
||||
true, false, 'Europe/Berlin', NOW(), NOW()
|
||||
FROM offs;
|
||||
|
||||
-- ── Cycle day-logs (128 days) + phase-clustered symptom links ─────
|
||||
WITH demo AS (SELECT user_id FROM _demo),
|
||||
gen AS (
|
||||
SELECT d, ((128 - d) % 28) + 1 AS cd, to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD') AS dstr
|
||||
FROM generate_series(0, 127) AS d
|
||||
),
|
||||
ins AS (
|
||||
INSERT INTO cycle_day_logs (id, user_id, date, flow, basal_body_temp_c, cervical_mucus, ovulation_test, source, tz, sync_version, created_at, updated_at)
|
||||
SELECT 'cdl_' || replace(gen_random_uuid()::text, '-', ''), (SELECT user_id FROM demo), g.dstr,
|
||||
(CASE g.cd WHEN 1 THEN 'MEDIUM' WHEN 2 THEN 'HEAVY' WHEN 3 THEN 'MEDIUM' WHEN 4 THEN 'LIGHT' WHEN 5 THEN 'SPOTTING' ELSE NULL END)::flow_level,
|
||||
round(((CASE WHEN g.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8,
|
||||
(CASE WHEN g.cd BETWEEN 1 AND 5 THEN NULL WHEN g.cd BETWEEN 6 AND 9 THEN 'STICKY' WHEN g.cd BETWEEN 10 AND 12 THEN 'CREAMY' WHEN g.cd BETWEEN 13 AND 14 THEN 'EGG_WHITE' WHEN g.cd BETWEEN 15 AND 16 THEN 'WATERY' ELSE 'DRY' END)::cervical_mucus,
|
||||
(CASE WHEN g.cd = 13 THEN 'POSITIVE_LH_SURGE' WHEN g.cd IN (11, 12, 14) THEN 'NEGATIVE' ELSE NULL END)::ovulation_test,
|
||||
'MANUAL'::measurement_source, 'Europe/Berlin', 0,
|
||||
NOW() - (g.d || ' days')::interval, NOW() - (g.d || ' days')::interval
|
||||
FROM gen g
|
||||
RETURNING id, date
|
||||
),
|
||||
link_menstrual AS (
|
||||
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
|
||||
SELECT ins.id, s.id, (2 + floor(random() * 3))::int, NOW()
|
||||
FROM ins JOIN gen g ON g.dstr = ins.date
|
||||
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('cramps', 'headache', 'fatigue')) s
|
||||
WHERE g.cd BETWEEN 1 AND 3
|
||||
)
|
||||
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
|
||||
SELECT ins.id, s.id, (1 + floor(random() * 3))::int, NOW()
|
||||
FROM ins JOIN gen g ON g.dstr = ins.date
|
||||
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('mood_swings', 'bloating', 'breast_tenderness')) s
|
||||
WHERE g.cd BETWEEN 24 AND 28;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ── Summary ───────────────────────────────────────────────────────
|
||||
SELECT 'demo user' AS what, id FROM users WHERE username = 'demo';
|
||||
SELECT 'measurements' AS what, count(*) FROM measurements WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
|
||||
SELECT 'mood' AS what, count(*) FROM mood_entries WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
|
||||
SELECT 'cycles' AS what, count(*) FROM menstrual_cycles WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
|
||||
SELECT 'day_logs' AS what, count(*) FROM cycle_day_logs WHERE user_id = (SELECT id FROM users WHERE username = 'demo');
|
||||
SELECT 'symptom_links' AS what, count(*) FROM cycle_symptom_links l JOIN cycle_day_logs dl ON l.day_log_id = dl.id WHERE dl.user_id = (SELECT id FROM users WHERE username = 'demo');
|
||||
@@ -0,0 +1,216 @@
|
||||
-- HealthLog demo 1-year history extender for apps01.
|
||||
--
|
||||
-- WHAT THIS DOES
|
||||
-- Extends the demo account's data BACKWARD from the existing ~90-day seed
|
||||
-- (scripts/seed-demo-apps01.sql) to ~365 days of coverage, and adds ~365 days
|
||||
-- of realistic medication-intake history for the two pre-existing demo meds.
|
||||
-- It mirrors that seed EXACTLY: same column lists, enum values, NOW()-anchored
|
||||
-- offset math (cycleDay(d) = ((128 - d) % 28) + 1), cuid/uuid-id style, and the
|
||||
-- same phase-aware vitals / mood / cycle channels.
|
||||
--
|
||||
-- The seed covers offsets 0..89 (measurements + mood), 0..127 (cycle day-logs),
|
||||
-- and 5 cycles at NOW-offsets {128,100,72,44,16}. This file ADDS ONLY the older
|
||||
-- tail so nothing is duplicated:
|
||||
-- - measurements / mood : offsets 90..364 (thinned to every 2nd day past ~180d)
|
||||
-- - cycle day-logs : offsets 128..364
|
||||
-- - menstrual cycles : 7 more 28-day cycles at offsets 156..352
|
||||
-- - medication intakes : ~365d of due slots for both demo meds
|
||||
--
|
||||
-- SCOPE + SAFETY
|
||||
-- - Demo-user-scoped: EVERY statement filters on the hardcoded demo user id.
|
||||
-- - Idempotent: every INSERT guards on the live unique index via ON CONFLICT
|
||||
-- DO NOTHING (or a date-offset window that begins past the seed's coverage),
|
||||
-- so re-running adds nothing new.
|
||||
-- - NO DDL (no CREATE/ALTER/DROP/TRUNCATE), no changes to any other user,
|
||||
-- no table-wide DELETE/UPDATE.
|
||||
--
|
||||
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-extend-1y.sql
|
||||
|
||||
\set uid 'usr_demo_cf31025295714ece8d91f5af13afd76d'
|
||||
\set medA 'cmq2lzjqh005x01nrys2crzg9' -- Mounjaro: weekly @ 08:00 (rolling 7d)
|
||||
\set medB 'cmq2m0mzk006101nrdpijbij0' -- Ramipril: twice daily @ 08:00 + 18:00
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Refuse to run if the demo user does not exist.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM users WHERE id = 'usr_demo_cf31025295714ece8d91f5af13afd76d') THEN
|
||||
RAISE EXCEPTION 'seed-demo-extend-1y: demo user not found';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── Measurements: offsets 90..364, phase-aware (mirrors the seed channels) ─────
|
||||
-- Thin the deep tail: keep every day for 90..179, every 2nd day for 180..364, to
|
||||
-- keep row volume sane while still covering the full year. Unique index
|
||||
-- (user_id, type, measured_at, source, sleep_stage) NULLS NOT DISTINCT — but
|
||||
-- measured_at carries a random sub-day jitter, so collisions are effectively
|
||||
-- impossible on a fresh tail; ON CONFLICT DO NOTHING makes re-runs safe anyway.
|
||||
WITH days AS (
|
||||
SELECT d FROM generate_series(90, 364) AS d
|
||||
WHERE d < 180 OR (d % 2) = 0
|
||||
)
|
||||
INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, measured_at, created_at, updated_at)
|
||||
SELECT 'm_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
v.type::measurement_type, v.value, v.unit, 'MANUAL'::measurement_source,
|
||||
v.gc::glucose_context,
|
||||
NOW() - (d || ' days')::interval - (random() * 5 || ' hours')::interval,
|
||||
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
|
||||
FROM days,
|
||||
LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c,
|
||||
LATERAL (VALUES
|
||||
('WEIGHT', round((66.0 - (90 - d) * 0.011 + (random() - 0.5) * 0.5)::numeric, 1)::float8, 'kg', NULL),
|
||||
('BLOOD_PRESSURE_SYS', round((116 + (random() - 0.5) * 8)::numeric, 0)::float8, 'mmHg', NULL),
|
||||
('BLOOD_PRESSURE_DIA', round((74 + (random() - 0.5) * 6)::numeric, 0)::float8, 'mmHg', NULL),
|
||||
('PULSE', round((68 + (random() - 0.5) * 8)::numeric, 0)::float8, 'bpm', NULL),
|
||||
('RESTING_HEART_RATE', round((56 + (CASE WHEN c.cd > 13 THEN 3 ELSE 0 END) + (random() - 0.5) * 3)::numeric, 0)::float8, 'bpm', NULL),
|
||||
('HEART_RATE_VARIABILITY', round((68 - (CASE WHEN c.cd > 13 THEN 7 ELSE 0 END) + (random() - 0.5) * 6)::numeric, 0)::float8, 'ms', NULL),
|
||||
('SLEEP_DURATION', round((445 - (CASE WHEN c.cd > 13 THEN 18 ELSE 0 END) + (random() - 0.5) * 40)::numeric, 0)::float8, 'minutes', NULL),
|
||||
('ACTIVITY_STEPS', round((8200 + (random() - 0.5) * 3500)::numeric, 0)::float8, 'steps', NULL),
|
||||
('BODY_FAT', round((27.5 - (90 - d) * 0.006 + (random() - 0.5) * 0.4)::numeric, 1)::float8, '%', NULL),
|
||||
('BODY_TEMPERATURE', round(((CASE WHEN c.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8, 'celsius', NULL),
|
||||
('BLOOD_GLUCOSE', round((92 + (random() - 0.5) * 18)::numeric, 0)::float8, 'mg/dL', 'FASTING')
|
||||
) AS v(type, value, unit, gc)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Mood: offsets 90..364, luteal dip (mirrors the seed) ───────────────────────
|
||||
-- Unique index (user_id, date, mood_logged_at). One entry per day, fresh tail.
|
||||
WITH days AS (SELECT generate_series(90, 364) AS d)
|
||||
INSERT INTO mood_entries (id, user_id, date, mood, score, source, mood_logged_at, synced_at, tz, sync_version, created_at, updated_at)
|
||||
SELECT 'mo_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD'),
|
||||
CASE s.score WHEN 1 THEN 'LAUSIG' WHEN 2 THEN 'SCHLECHT' WHEN 3 THEN 'OKAY' WHEN 4 THEN 'GUT' ELSE 'SUPER_GUT' END,
|
||||
s.score, 'MANUAL',
|
||||
NOW() - (d || ' days')::interval - (random() * 6 || ' hours')::interval,
|
||||
NOW() - (d || ' days')::interval, 'Europe/Berlin', 0,
|
||||
NOW() - (d || ' days')::interval, NOW() - (d || ' days')::interval
|
||||
FROM days,
|
||||
LATERAL (SELECT ((128 - d) % 28) + 1 AS cd) c,
|
||||
LATERAL (
|
||||
SELECT GREATEST(1, LEAST(5,
|
||||
round((CASE WHEN c.cd > 23 THEN 2.6 WHEN c.cd > 13 THEN 3.3 ELSE 3.9 END) + (random() - 0.5) * 1.6)::int
|
||||
)) AS score
|
||||
) s
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Menstrual cycles: 7 more 28-day cycles, offsets 156..352 ───────────────────
|
||||
-- Continues the seed's cadence backward (seed had {128,100,72,44,16}; next is 156).
|
||||
-- Unique index (user_id, start_date) → ON CONFLICT DO NOTHING. All closed cycles.
|
||||
WITH offs AS (SELECT unnest(ARRAY[156, 184, 212, 240, 268, 296, 324, 352]) AS off)
|
||||
INSERT INTO menstrual_cycles (id, user_id, start_date, end_date, period_end_date, length_days, ovulation_date, ovulation_confirmed, is_predicted, tz, created_at, updated_at)
|
||||
SELECT 'mc_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
to_char(NOW() - (off || ' days')::interval, 'YYYY-MM-DD'),
|
||||
to_char(NOW() - ((off - 27) || ' days')::interval, 'YYYY-MM-DD'),
|
||||
to_char(NOW() - ((off - 4) || ' days')::interval, 'YYYY-MM-DD'),
|
||||
28,
|
||||
to_char(NOW() - ((off - 13) || ' days')::interval, 'YYYY-MM-DD'),
|
||||
true, false, 'Europe/Berlin', NOW(), NOW()
|
||||
FROM offs
|
||||
ON CONFLICT (user_id, start_date) DO NOTHING;
|
||||
|
||||
-- ── Cycle day-logs: offsets 128..364 + phase-clustered symptom links ───────────
|
||||
-- Mirrors the seed exactly (flow/BBT/mucus/OPK by cycleDay). Unique index
|
||||
-- (user_id, date) → ON CONFLICT DO NOTHING. Symptom links only attach to rows
|
||||
-- this run actually inserts (RETURNING), so re-runs add no duplicate links.
|
||||
WITH gen AS (
|
||||
SELECT d, ((128 - d) % 28) + 1 AS cd, to_char(NOW() - (d || ' days')::interval, 'YYYY-MM-DD') AS dstr
|
||||
FROM generate_series(128, 364) AS d
|
||||
),
|
||||
ins AS (
|
||||
INSERT INTO cycle_day_logs (id, user_id, date, flow, basal_body_temp_c, cervical_mucus, ovulation_test, source, tz, sync_version, created_at, updated_at)
|
||||
SELECT 'cdl_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d', g.dstr,
|
||||
(CASE g.cd WHEN 1 THEN 'MEDIUM' WHEN 2 THEN 'HEAVY' WHEN 3 THEN 'MEDIUM' WHEN 4 THEN 'LIGHT' WHEN 5 THEN 'SPOTTING' ELSE NULL END)::flow_level,
|
||||
round(((CASE WHEN g.cd <= 13 THEN 36.36 ELSE 36.73 END) + (random() - 0.5) * 0.08)::numeric, 2)::float8,
|
||||
(CASE WHEN g.cd BETWEEN 1 AND 5 THEN NULL WHEN g.cd BETWEEN 6 AND 9 THEN 'STICKY' WHEN g.cd BETWEEN 10 AND 12 THEN 'CREAMY' WHEN g.cd BETWEEN 13 AND 14 THEN 'EGG_WHITE' WHEN g.cd BETWEEN 15 AND 16 THEN 'WATERY' ELSE 'DRY' END)::cervical_mucus,
|
||||
(CASE WHEN g.cd = 13 THEN 'POSITIVE_LH_SURGE' WHEN g.cd IN (11, 12, 14) THEN 'NEGATIVE' ELSE NULL END)::ovulation_test,
|
||||
'MANUAL'::measurement_source, 'Europe/Berlin', 0,
|
||||
NOW() - (g.d || ' days')::interval, NOW() - (g.d || ' days')::interval
|
||||
FROM gen g
|
||||
ON CONFLICT (user_id, date) DO NOTHING
|
||||
RETURNING id, date
|
||||
),
|
||||
link_menstrual AS (
|
||||
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
|
||||
SELECT ins.id, s.id, (2 + floor(random() * 3))::int, NOW()
|
||||
FROM ins JOIN gen g ON g.dstr = ins.date
|
||||
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('cramps', 'headache', 'fatigue')) s
|
||||
WHERE g.cd BETWEEN 1 AND 3
|
||||
)
|
||||
INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at)
|
||||
SELECT ins.id, s.id, (1 + floor(random() * 3))::int, NOW()
|
||||
FROM ins JOIN gen g ON g.dstr = ins.date
|
||||
CROSS JOIN LATERAL (SELECT id FROM cycle_symptoms WHERE user_id IS NULL AND key IN ('mood_swings', 'bloating', 'breast_tenderness')) s
|
||||
WHERE g.cd BETWEEN 24 AND 28;
|
||||
|
||||
-- ── Medication intakes: Med A (weekly @ 08:00 Berlin), ~365d ───────────────────
|
||||
-- The existing demo rows store scheduled_for in UTC at 06:00 (= 08:00 Berlin DST).
|
||||
-- We mint slots at the Berlin local 08:00 and convert to the stored UTC wall-time
|
||||
-- with AT TIME ZONE so winter (07:00 UTC) vs summer (06:00 UTC) is honoured.
|
||||
-- Live partial unique index: (user_id, medication_id, scheduled_for, source)
|
||||
-- WHERE deleted_at IS NULL → ON CONFLICT on that predicate makes re-runs safe.
|
||||
-- Compliance ~85-90%: deterministic per-slot pseudo-random skip ~12% of weeks.
|
||||
-- Never future-dated: WHERE the slot timestamp <= NOW().
|
||||
WITH slots AS (
|
||||
SELECT
|
||||
( (date_trunc('day', NOW() AT TIME ZONE 'Europe/Berlin') - (w * 7 || ' days')::interval + interval '8 hours')
|
||||
AT TIME ZONE 'Europe/Berlin' ) AT TIME ZONE 'UTC' AS sched_utc,
|
||||
-- deterministic skip decision per week offset (no random(): re-run stable)
|
||||
( (hashtextextended('medA-week-' || w, 0) & 2147483647)::numeric / 2147483647.0 ) AS r
|
||||
FROM generate_series(0, 52) AS w -- 53 weekly slots = w*7 days back ≈ 371 days
|
||||
)
|
||||
INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at, sync_version)
|
||||
SELECT 'cmq_demo_a_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
'cmq2lzjqh005x01nrys2crzg9',
|
||||
s.sched_utc,
|
||||
CASE WHEN s.r < 0.88 THEN s.sched_utc + (interval '5 minutes' + (s.r * interval '90 minutes')) ELSE NULL END,
|
||||
CASE WHEN s.r < 0.88 THEN false ELSE true END,
|
||||
'WEB'::intake_source,
|
||||
s.sched_utc, s.sched_utc, 0
|
||||
FROM slots s
|
||||
WHERE s.sched_utc <= NOW()
|
||||
-- skip slots already covered by the seeded rows (06-06 06:00 + 06-13 06:00)
|
||||
AND s.sched_utc < date_trunc('day', NOW()) - interval '1 day'
|
||||
ON CONFLICT (user_id, medication_id, scheduled_for, source) WHERE deleted_at IS NULL DO NOTHING;
|
||||
|
||||
-- ── Medication intakes: Med B (twice daily @ 08:00 + 18:00 Berlin), ~365d ──────
|
||||
-- One row per due slot per day. Compliance ~85-90%: deterministic per-slot skip
|
||||
-- ~12% of individual slots. Same TZ + ON CONFLICT + no-future-date guards.
|
||||
WITH slots AS (
|
||||
SELECT
|
||||
( (date_trunc('day', NOW() AT TIME ZONE 'Europe/Berlin') - (dd || ' days')::interval + (hh || ' hours')::interval)
|
||||
AT TIME ZONE 'Europe/Berlin' ) AT TIME ZONE 'UTC' AS sched_utc,
|
||||
( (hashtextextended('medB-' || dd || '-' || hh, 0) & 2147483647)::numeric / 2147483647.0 ) AS r
|
||||
FROM generate_series(0, 364) AS dd
|
||||
CROSS JOIN (VALUES (8), (18)) AS h(hh)
|
||||
)
|
||||
INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at, sync_version)
|
||||
SELECT 'cmq_demo_b_' || replace(gen_random_uuid()::text, '-', ''),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
'cmq2m0mzk006101nrdpijbij0',
|
||||
s.sched_utc,
|
||||
CASE WHEN s.r < 0.88 THEN s.sched_utc + (interval '5 minutes' + (s.r * interval '120 minutes')) ELSE NULL END,
|
||||
CASE WHEN s.r < 0.88 THEN false ELSE true END,
|
||||
'WEB'::intake_source,
|
||||
s.sched_utc, s.sched_utc, 0
|
||||
FROM slots s
|
||||
WHERE s.sched_utc <= NOW()
|
||||
-- skip slots already covered by the seeded rows (today's 06:00 + 16:00 UTC)
|
||||
AND s.sched_utc < date_trunc('day', NOW())
|
||||
ON CONFLICT (user_id, medication_id, scheduled_for, source) WHERE deleted_at IS NULL DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ── Verification (demo-user-scoped totals after apply) ─────────────────────────
|
||||
\echo === post-apply totals (demo user) ===
|
||||
SELECT 'measurements' AS what, count(*), min(measured_at)::date AS oldest, max(measured_at)::date AS newest FROM measurements WHERE user_id = :'uid';
|
||||
SELECT 'mood' AS what, count(*), min(date) AS oldest, max(date) AS newest FROM mood_entries WHERE user_id = :'uid';
|
||||
SELECT 'cycles' AS what, count(*), min(start_date) AS oldest, max(start_date) AS newest FROM menstrual_cycles WHERE user_id = :'uid';
|
||||
SELECT 'day_logs' AS what, count(*), min(date) AS oldest, max(date) AS newest FROM cycle_day_logs WHERE user_id = :'uid';
|
||||
SELECT 'symptom_links' AS what, count(*) FROM cycle_symptom_links l JOIN cycle_day_logs dl ON l.day_log_id = dl.id WHERE dl.user_id = :'uid';
|
||||
SELECT 'intake_medA' AS what, count(*), count(*) FILTER (WHERE taken_at IS NOT NULL) AS taken, count(*) FILTER (WHERE skipped) AS skipped FROM medication_intake_events WHERE user_id = 'usr_demo_cf31025295714ece8d91f5af13afd76d' AND medication_id = 'cmq2lzjqh005x01nrys2crzg9';
|
||||
SELECT 'intake_medB' AS what, count(*), count(*) FILTER (WHERE taken_at IS NOT NULL) AS taken, count(*) FILTER (WHERE skipped) AS skipped FROM medication_intake_events WHERE user_id = 'usr_demo_cf31025295714ece8d91f5af13afd76d' AND medication_id = 'cmq2m0mzk006101nrdpijbij0';
|
||||
@@ -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;
|
||||
@@ -0,0 +1,153 @@
|
||||
-- HealthLog demo computed-wellness-score seeder for apps01 (1 year).
|
||||
--
|
||||
-- WHAT THIS DOES
|
||||
-- Seeds the three PERSISTED nightly wellness scores for the demo account so
|
||||
-- all of the dashboard's "Deine Gesundheitswerte" rings render with a full
|
||||
-- year of history. These three are normally written by the nightly score
|
||||
-- crons (src/lib/jobs/{recovery,stress,strain}-score.ts) as COMPUTED
|
||||
-- Measurement rows; the demo never had them computed, so only the two
|
||||
-- compute-on-read composites (READINESS + SLEEP_SCORE — derived live from the
|
||||
-- demo's RHR/HRV/sleep/mood, NOT stored) were showing.
|
||||
--
|
||||
-- channel type external_id unit value
|
||||
-- Recovery RECOVERY_SCORE recovery:YYYY-MM-DD score 0..100 (55-90)
|
||||
-- Strain STRAIN_SCORE strain:YYYY-MM-DD score 0..100 (20-75)
|
||||
-- Stress STRESS_SCORE stress:YYYY-MM-DD score 0..100 (25-65)
|
||||
--
|
||||
-- READINESS and SLEEP_SCORE are NOT seeded here: neither exists in the
|
||||
-- measurement_type enum (verified live) — both are recomputed on read from
|
||||
-- the underlying signals the demo already carries, so they already render and
|
||||
-- a stored row would be ignored. Seeding them would be dead data.
|
||||
--
|
||||
-- Row shape mirrors src/lib/insights/score-row.ts::upsertScoreRow exactly:
|
||||
-- source = COMPUTED, unit = 'score', measured_at = noon UTC on the scored day,
|
||||
-- external_id = '<prefix>:YYYY-MM-DD', sleep_stage / device_type /
|
||||
-- external_source_version / deleted_at all NULL.
|
||||
--
|
||||
-- Coverage: one row per (channel, UTC day) for the last 365 days (offsets
|
||||
-- 1..365 — offset 0 is "today", which the nightly cron scores as the PREVIOUS
|
||||
-- day, so the freshest persisted day is yesterday; this matches the job and
|
||||
-- keeps every row strictly measured_at <= NOW()). 365 days x 3 channels =
|
||||
-- 1,095 rows on a clean apply.
|
||||
--
|
||||
-- DETERMINISM
|
||||
-- Values + ids are hash-derived from the day stamp (hashtextextended /
|
||||
-- md5 — NO random()), so a re-run is byte-stable. Idempotent re-runs are
|
||||
-- additionally guarded by ON CONFLICT DO NOTHING on the live unique index
|
||||
-- (user_id, type, source, external_id) — the exact key the cron upserts on.
|
||||
--
|
||||
-- SCOPE + SAFETY
|
||||
-- - Demo-user-scoped: EVERY statement hardcodes the demo user id.
|
||||
-- - Idempotent: ON CONFLICT DO NOTHING; re-running adds nothing.
|
||||
-- - No DDL (no CREATE/ALTER/DROP/TRUNCATE), no other-user writes, no
|
||||
-- table-wide DELETE/UPDATE, no future-dated rows (measured_at <= NOW()).
|
||||
--
|
||||
-- Run: ssh apps-01 'docker exec -i <db-container> psql -U healthlog -d healthlog' < scripts/seed-demo-scores-1y.sql
|
||||
|
||||
\set uid 'usr_demo_cf31025295714ece8d91f5af13afd76d'
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Refuse to run if the demo user does not exist.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM users WHERE id = 'usr_demo_cf31025295714ece8d91f5af13afd76d') THEN
|
||||
RAISE EXCEPTION 'seed-demo-scores-1y: demo user not found';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ── The three persisted score channels, one row per (channel, day) ─────────────
|
||||
-- offsets 1..365 (the scored day is the day that just ended; offset 0 = today is
|
||||
-- never persisted by the cron). day_key is the UTC calendar day; measured_at is
|
||||
-- noon UTC on it, mirroring scoreMeasuredAt(). Deterministic values:
|
||||
-- - week phase : a gentle 7-day sinusoid keyed to the day-of-year (weekly
|
||||
-- rhythm — lighter mid-week, heavier toward the weekend).
|
||||
-- - noise : per-(channel,day) hash in [0,1), no random().
|
||||
-- - recovery : 55..90, dips after a "hard" (high-strain) day.
|
||||
-- - strain : 20..75, weekend / mid-week training spikes (~25% of days).
|
||||
-- - stress : 25..65, inversely coupled to recovery (recovery up → stress
|
||||
-- down) so the two rings tell a coherent story.
|
||||
WITH days AS (
|
||||
SELECT
|
||||
d,
|
||||
(NOW() AT TIME ZONE 'UTC')::date - d AS day_key
|
||||
FROM generate_series(1, 365) AS d
|
||||
),
|
||||
calc AS (
|
||||
SELECT
|
||||
d,
|
||||
day_key,
|
||||
(day_key + 12)::timestamp AS measured_at, -- noon UTC on the scored day
|
||||
-- weekly rhythm in [-1, 1] from ISO day-of-week (Mon=1..Sun=7)
|
||||
sin(2 * pi() * (extract(isodow FROM day_key)::numeric - 1) / 7.0) AS week_phase,
|
||||
-- deterministic per-day "is this a training day" flag (~25%)
|
||||
( (hashtextextended('score-train-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS train_r,
|
||||
-- independent deterministic noise streams per channel, all in [0,1)
|
||||
( (hashtextextended('score-rec-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS rec_r,
|
||||
( (hashtextextended('score-str-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS str_r,
|
||||
( (hashtextextended('score-stress-' || day_key::text, 0) & 2147483647)::numeric / 2147483647.0 ) AS stress_r
|
||||
FROM days
|
||||
),
|
||||
vals AS (
|
||||
SELECT
|
||||
d, day_key, measured_at, train_r,
|
||||
-- STRAIN 20..75: base + weekly rhythm + a spike on training days.
|
||||
LEAST(75, GREATEST(20, round(
|
||||
40 + week_phase * 8
|
||||
+ (CASE WHEN train_r < 0.25 THEN 22 ELSE 0 END)
|
||||
+ (str_r - 0.5) * 14
|
||||
)))::int AS strain,
|
||||
-- RECOVERY 55..90: base + weekly rhythm, suppressed the day after a hard day.
|
||||
LEAST(90, GREATEST(55, round(
|
||||
74 + week_phase * 6
|
||||
- (CASE WHEN train_r < 0.25 THEN 7 ELSE 0 END)
|
||||
+ (rec_r - 0.5) * 16
|
||||
)))::int AS recovery
|
||||
FROM calc
|
||||
),
|
||||
final AS (
|
||||
SELECT
|
||||
v.d, v.day_key, v.measured_at, v.strain, v.recovery,
|
||||
-- STRESS 25..65: inverse to recovery (high recovery → low stress) + noise.
|
||||
LEAST(65, GREATEST(25, round(
|
||||
90 - v.recovery * 0.5
|
||||
+ (c.stress_r - 0.5) * 14
|
||||
)))::int AS stress
|
||||
FROM vals v JOIN calc c USING (d)
|
||||
),
|
||||
rows AS (
|
||||
SELECT day_key, measured_at, type, ext_prefix, value FROM final,
|
||||
LATERAL (VALUES
|
||||
('RECOVERY_SCORE', 'recovery:', recovery),
|
||||
('STRAIN_SCORE', 'strain:', strain),
|
||||
('STRESS_SCORE', 'stress:', stress)
|
||||
) AS ch(type, ext_prefix, value)
|
||||
)
|
||||
INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, external_id, created_at, updated_at)
|
||||
SELECT
|
||||
-- deterministic id from the per-row external_id → byte-stable re-runs.
|
||||
'm_score_' || md5(ext_prefix || to_char(day_key, 'YYYY-MM-DD')),
|
||||
'usr_demo_cf31025295714ece8d91f5af13afd76d',
|
||||
type::measurement_type,
|
||||
value::float8,
|
||||
'score',
|
||||
'COMPUTED'::measurement_source,
|
||||
measured_at,
|
||||
ext_prefix || to_char(day_key, 'YYYY-MM-DD'),
|
||||
measured_at,
|
||||
measured_at
|
||||
FROM rows
|
||||
WHERE measured_at <= NOW()
|
||||
ON CONFLICT (user_id, type, source, external_id) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- ── Verification (demo-user-scoped, all five score channels) ───────────────────
|
||||
\echo === post-apply score coverage (demo user) ===
|
||||
SELECT type, count(*) AS rows, min(measured_at)::date AS oldest, max(measured_at)::date AS newest
|
||||
FROM measurements
|
||||
WHERE user_id = :'uid'
|
||||
AND type IN ('RECOVERY_SCORE', 'STRAIN_SCORE', 'STRESS_SCORE')
|
||||
GROUP BY type
|
||||
ORDER BY type;
|
||||
\echo (READINESS + SLEEP_SCORE are compute-on-read composites — not stored, render from underlying signals.)
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* v1.15.20 — service-worker cache behaviour, evaluated in a `vm` sandbox.
|
||||
*
|
||||
* `public/sw.js` is plain script (no module system), so the test boots it
|
||||
* inside a vm context with a fake CacheStorage and dispatches the captured
|
||||
* event listeners. Three behaviours are pinned:
|
||||
*
|
||||
* 1. the offline HTML fallback only serves a shell cached under the
|
||||
* CURRENT `CACHE_VERSION` cache names (a stale pre-update shell would
|
||||
* reference a chunk graph that no longer exists);
|
||||
* 2. `activate` enables navigation preload and `networkFirst` consumes
|
||||
* `event.preloadResponse` when the browser supplies it;
|
||||
* 3. `trimCache` reads the key list once and deletes the excess prefix
|
||||
* in a single pass (the previous loop re-fetched all keys per delete).
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import vm from "node:vm";
|
||||
|
||||
const SW_SOURCE = readFileSync(
|
||||
resolve(__dirname, "../../public/sw.js"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const ORIGIN = "https://app.example";
|
||||
// `importScripts` throws in the sandbox, so the literal fallback version
|
||||
// is active and the cache names are deterministic.
|
||||
const CURRENT_PAGE_CACHE = "healthlog-pages-v1.4.43";
|
||||
const CURRENT_STATIC_CACHE = "healthlog-static-v1.4.43";
|
||||
|
||||
class FakeCache {
|
||||
map = new Map<string, Response>();
|
||||
keysCalls = 0;
|
||||
|
||||
private keyOf(request: RequestInfo | URL): string {
|
||||
if (typeof request === "string") return new URL(request, ORIGIN).href;
|
||||
if (request instanceof URL) return request.href;
|
||||
return request.url;
|
||||
}
|
||||
|
||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||
return this.map.get(this.keyOf(request));
|
||||
}
|
||||
|
||||
async put(request: RequestInfo | URL, response: Response): Promise<void> {
|
||||
this.map.set(this.keyOf(request), response);
|
||||
}
|
||||
|
||||
async addAll(urls: string[]): Promise<void> {
|
||||
for (const url of urls) {
|
||||
this.map.set(this.keyOf(url), new Response(`precached:${url}`));
|
||||
}
|
||||
}
|
||||
|
||||
async keys(): Promise<Request[]> {
|
||||
this.keysCalls += 1;
|
||||
return [...this.map.keys()].map((url) => new Request(url));
|
||||
}
|
||||
|
||||
async delete(request: RequestInfo | URL): Promise<boolean> {
|
||||
return this.map.delete(this.keyOf(request));
|
||||
}
|
||||
}
|
||||
|
||||
class FakeCacheStorage {
|
||||
stores = new Map<string, FakeCache>();
|
||||
|
||||
async open(name: string): Promise<FakeCache> {
|
||||
let store = this.stores.get(name);
|
||||
if (!store) {
|
||||
store = new FakeCache();
|
||||
this.stores.set(name, store);
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
async keys(): Promise<string[]> {
|
||||
return [...this.stores.keys()];
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<boolean> {
|
||||
return this.stores.delete(name);
|
||||
}
|
||||
|
||||
async match(request: RequestInfo | URL): Promise<Response | undefined> {
|
||||
for (const store of this.stores.values()) {
|
||||
const hit = await store.match(request);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
interface SwHarness {
|
||||
listeners: Map<string, (event: unknown) => void>;
|
||||
cacheStorage: FakeCacheStorage;
|
||||
navPreload: { enabled: boolean; enable: () => Promise<void> };
|
||||
context: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function bootServiceWorker(): SwHarness {
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const cacheStorage = new FakeCacheStorage();
|
||||
const navPreload = {
|
||||
enabled: false,
|
||||
enable: async () => {
|
||||
navPreload.enabled = true;
|
||||
},
|
||||
};
|
||||
const selfObj = {
|
||||
addEventListener: (type: string, fn: (event: unknown) => void) => {
|
||||
listeners.set(type, fn);
|
||||
},
|
||||
skipWaiting: async () => {},
|
||||
clients: { claim: async () => {} },
|
||||
registration: { navigationPreload: navPreload },
|
||||
location: { origin: ORIGIN },
|
||||
};
|
||||
const context: Record<string, unknown> = {
|
||||
self: selfObj,
|
||||
caches: cacheStorage,
|
||||
importScripts: () => {
|
||||
throw new Error("no generated version file in the sandbox");
|
||||
},
|
||||
// Default: network down. Individual tests override `context.fetch`.
|
||||
fetch: async () => {
|
||||
throw new TypeError("network down");
|
||||
},
|
||||
Response,
|
||||
Request,
|
||||
URL,
|
||||
Promise,
|
||||
console,
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(SW_SOURCE, context);
|
||||
return { listeners, cacheStorage, navPreload, context };
|
||||
}
|
||||
|
||||
async function dispatchActivate(harness: SwHarness): Promise<void> {
|
||||
let settled: Promise<unknown> = Promise.resolve();
|
||||
harness.listeners.get("activate")!({
|
||||
waitUntil: (p: Promise<unknown>) => {
|
||||
settled = p;
|
||||
},
|
||||
});
|
||||
await settled;
|
||||
}
|
||||
|
||||
function dispatchNavigationFetch(
|
||||
harness: SwHarness,
|
||||
path: string,
|
||||
preloadResponse?: Promise<Response | undefined>,
|
||||
): Promise<Response> {
|
||||
let captured: Promise<Response> | null = null;
|
||||
harness.listeners.get("fetch")!({
|
||||
request: new Request(`${ORIGIN}${path}`, {
|
||||
headers: { accept: "text/html" },
|
||||
}),
|
||||
respondWith: (p: Promise<Response>) => {
|
||||
captured = p;
|
||||
},
|
||||
preloadResponse,
|
||||
});
|
||||
if (!captured) throw new Error("fetch handler did not respond");
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe("sw.js — activate", () => {
|
||||
it("drops stale-version caches and enables navigation preload", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
const stale = await harness.cacheStorage.open("healthlog-pages-v0.0.1");
|
||||
await stale.put(`${ORIGIN}/`, new Response("stale shell"));
|
||||
await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
|
||||
|
||||
await dispatchActivate(harness);
|
||||
|
||||
expect(harness.cacheStorage.stores.has("healthlog-pages-v0.0.1")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(harness.cacheStorage.stores.has(CURRENT_PAGE_CACHE)).toBe(true);
|
||||
expect(harness.navPreload.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sw.js — networkFirst offline fallback", () => {
|
||||
it("never serves a shell cached under a previous CACHE_VERSION", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
// A stale pre-update cache that survived into the activation gap.
|
||||
const stale = await harness.cacheStorage.open("healthlog-pages-v0.0.1");
|
||||
await stale.put(`${ORIGIN}/`, new Response("stale shell"));
|
||||
|
||||
const res = await dispatchNavigationFetch(harness, "/");
|
||||
|
||||
// The stale shell is ignored; the language-neutral offline page wins.
|
||||
expect(res.status).toBe(503);
|
||||
expect(await res.text()).toContain("Offline");
|
||||
});
|
||||
|
||||
it("serves the current-version page cache when offline", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
|
||||
await pages.put(`${ORIGIN}/`, new Response("current shell"));
|
||||
|
||||
const res = await dispatchNavigationFetch(harness, "/");
|
||||
expect(await res.text()).toBe("current shell");
|
||||
});
|
||||
|
||||
it("falls back to the current-version precached shell when the page cache misses", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
const statics = await harness.cacheStorage.open(CURRENT_STATIC_CACHE);
|
||||
await statics.addAll(["/"]);
|
||||
|
||||
const res = await dispatchNavigationFetch(harness, "/");
|
||||
expect(await res.text()).toBe("precached:/");
|
||||
});
|
||||
|
||||
it("consumes the navigation-preload response instead of re-fetching", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
let fetchCalls = 0;
|
||||
harness.context.fetch = async () => {
|
||||
fetchCalls += 1;
|
||||
return new Response("network shell");
|
||||
};
|
||||
|
||||
const res = await dispatchNavigationFetch(
|
||||
harness,
|
||||
"/",
|
||||
Promise.resolve(new Response("preloaded shell")),
|
||||
);
|
||||
|
||||
expect(await res.clone().text()).toBe("preloaded shell");
|
||||
expect(fetchCalls).toBe(0);
|
||||
// The preloaded response was cached under the current page cache.
|
||||
const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
|
||||
expect(await pages.match(`${ORIGIN}/`)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sw.js — trimCache", () => {
|
||||
it("reads the key list once and deletes only the oldest excess entries", async () => {
|
||||
const harness = bootServiceWorker();
|
||||
const store = await harness.cacheStorage.open(CURRENT_PAGE_CACHE);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await store.put(`${ORIGIN}/page-${i}`, new Response(`p${i}`));
|
||||
}
|
||||
store.keysCalls = 0;
|
||||
|
||||
const trimCache = (
|
||||
harness.context as { trimCache?: (name: string, max: number) => Promise<void> }
|
||||
).trimCache;
|
||||
expect(typeof trimCache).toBe("function");
|
||||
await trimCache!(CURRENT_PAGE_CACHE, 5);
|
||||
|
||||
// Single key read (the previous implementation re-read per deletion).
|
||||
expect(store.keysCalls).toBe(1);
|
||||
// Oldest three gone, newest five kept.
|
||||
expect(store.map.has(`${ORIGIN}/page-0`)).toBe(false);
|
||||
expect(store.map.has(`${ORIGIN}/page-2`)).toBe(false);
|
||||
expect(store.map.has(`${ORIGIN}/page-3`)).toBe(true);
|
||||
expect(store.map.has(`${ORIGIN}/page-7`)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -21,8 +21,8 @@ import { NextResponse } from "next/server";
|
||||
* it to devices, so the origin only needs to respond reliably; Apple's
|
||||
* one-hour CDN TTL pairs with the `Cache-Control` directive below.
|
||||
*
|
||||
* Both `healthlog.bombeck.io` (maintainer prod) and `demo.healthlog.dev`
|
||||
* (demo) share this handler — the response is host-independent.
|
||||
* Every host serving this app (an operator's instance, the public demo)
|
||||
* shares this handler — the response is host-independent.
|
||||
*/
|
||||
const AASA_APP_ID = "S8WDX4W5KX.dev.healthlog.app";
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* v1.16.0 — dashboard first-load choreography.
|
||||
*
|
||||
* Two complaints from the maintainer's first-load walk-through:
|
||||
*
|
||||
* 1. The tile strip painted EMPTY dark cards while the primary query
|
||||
* was in flight — no inner structure previewing the final tile.
|
||||
* 2. The chart row popped in one chart at a time: every chart owns
|
||||
* its own query, `/api/mood/analytics` is a single cheap read, so
|
||||
* the mood chart reliably painted first and the measurement
|
||||
* charts trickled in afterwards.
|
||||
*
|
||||
* The fix: structured `<TrendCardSkeleton>` silhouettes for every tile
|
||||
* slot, plus a shared reveal gate (`useDashboardChartReveal` /
|
||||
* `<DashboardChartCell>`) that holds every gated chart on its
|
||||
* layout-stable skeleton until every chart's data settled — or the 2 s
|
||||
* fallback fires so one slow widget cannot block the row.
|
||||
*
|
||||
* Project convention is SSR-only tests (vitest `node` environment, no
|
||||
* DOM test runner): the gate logic is pinned through the pure
|
||||
* `resolveChartRevealState` resolver, the markup through
|
||||
* `renderToStaticMarkup`, and the page wiring through textual pins.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import {
|
||||
CHART_REVEAL_TIMEOUT_MS,
|
||||
DashboardChartCell,
|
||||
resolveChartRevealState,
|
||||
} from "@/components/dashboard/chart-reveal";
|
||||
import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
const PAGE_PATH = join(process.cwd(), "src/app/page.tsx");
|
||||
|
||||
describe("resolveChartRevealState", () => {
|
||||
it("stays hidden while the gate is not armed (no visible charts yet)", () => {
|
||||
expect(
|
||||
resolveChartRevealState({
|
||||
expectedIds: [],
|
||||
readyIds: new Set(),
|
||||
timedOut: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
// Even a stale timeout cannot reveal an empty row.
|
||||
expect(
|
||||
resolveChartRevealState({
|
||||
expectedIds: [],
|
||||
readyIds: new Set(),
|
||||
timedOut: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("holds the skeletons while any gated chart is still loading", () => {
|
||||
expect(
|
||||
resolveChartRevealState({
|
||||
expectedIds: ["weight-chart", "mood-chart"],
|
||||
readyIds: new Set(["mood-chart"]),
|
||||
timedOut: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reveals once every gated chart reported ready (the Promise.all moment)", () => {
|
||||
expect(
|
||||
resolveChartRevealState({
|
||||
expectedIds: ["weight-chart", "mood-chart"],
|
||||
readyIds: new Set(["weight-chart", "mood-chart"]),
|
||||
timedOut: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reveals on timeout so a slow widget cannot block the row", () => {
|
||||
expect(
|
||||
resolveChartRevealState({
|
||||
expectedIds: ["weight-chart", "mood-chart"],
|
||||
readyIds: new Set(),
|
||||
timedOut: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("pins the 1.2 s fallback budget", () => {
|
||||
// v1.16.1 — lowered from 2 s: the timeout is only the worst-case cap
|
||||
// on how long fast charts wait for the slowest sibling (there is no
|
||||
// minimum delay — the row reveals the moment every gated query
|
||||
// settles). 1.2 s keeps the synchronized reveal without making one
|
||||
// cold widget hold the whole row on skeletons for 2 s.
|
||||
expect(CHART_REVEAL_TIMEOUT_MS).toBe(1_200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DashboardChartCell", () => {
|
||||
const render = (revealed: boolean) =>
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DashboardChartCell revealed={revealed}>
|
||||
<div data-slot="probe-chart">chart body</div>
|
||||
</DashboardChartCell>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
it("hidden state: keeps the chart mounted but invisible under a skeleton overlay", () => {
|
||||
const html = render(false);
|
||||
expect(html).toContain('data-revealed="false"');
|
||||
// Chart stays mounted (its query must fire) but is layout-only.
|
||||
expect(html).toContain('data-slot="probe-chart"');
|
||||
expect(html).toContain("invisible");
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
// Layout-stable skeleton overlays the same box.
|
||||
expect(html).toContain('data-slot="chart-skeleton"');
|
||||
expect(html).toContain("absolute inset-0");
|
||||
});
|
||||
|
||||
it("revealed state: drops the skeleton and fades the content in (motion-safe)", () => {
|
||||
const html = render(true);
|
||||
expect(html).toContain('data-revealed="true"');
|
||||
expect(html).not.toContain('data-slot="chart-skeleton"');
|
||||
expect(html).not.toContain("invisible");
|
||||
expect(html).toContain("motion-safe:animate-in");
|
||||
expect(html).toContain("motion-safe:fade-in");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TrendCardSkeleton", () => {
|
||||
it("renders a structured silhouette, not an empty card", () => {
|
||||
const html = renderToStaticMarkup(<TrendCardSkeleton />);
|
||||
expect(html).toContain('data-slot="trend-card-skeleton"');
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
// Structured: heading row + headline value + sub-row blocks.
|
||||
const skeletonBlocks = html.match(/data-slot="skeleton"/g) ?? [];
|
||||
expect(skeletonBlocks.length).toBeGreaterThanOrEqual(5);
|
||||
// Same outer chrome + min-height floor as the real TrendCard slot.
|
||||
expect(html).toContain("rounded-xl");
|
||||
expect(html).toContain("min-h-[8rem]");
|
||||
// Reduced motion honoured via the Skeleton primitive.
|
||||
expect(html).toContain("motion-reduce:animate-none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dashboard chart-reveal wiring (page.tsx)", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
|
||||
it("derives the gated id list from the chart visibility gates", () => {
|
||||
expect(src).toMatch(
|
||||
/const\s+revealChartIds:\s*string\[\]\s*=\s*\[\];/,
|
||||
);
|
||||
expect(src).toMatch(
|
||||
/useDashboardChartReveal\(revealChartIds\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("every data-backed chart reports through onDataReady", () => {
|
||||
for (const id of [
|
||||
"weight-chart",
|
||||
"bmi-chart",
|
||||
"bp-chart",
|
||||
"pulse-chart",
|
||||
"bodyFat-chart",
|
||||
"mood-chart",
|
||||
"sleep-chart",
|
||||
"steps-chart",
|
||||
"medications",
|
||||
]) {
|
||||
expect(src).toContain(`onDataReady={() => markChartReady("${id}")}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("gated entries render inside DashboardChartCell", () => {
|
||||
expect(src).toMatch(
|
||||
/entry\.revealGated\s*\?[\s\S]*?<DashboardChartCell revealed=\{chartsRevealed\}>/,
|
||||
);
|
||||
});
|
||||
|
||||
it("self-skeletoning cards (achievements / workouts) stay outside the gate", () => {
|
||||
// Their entries never set `revealGated`, so the ungated branch
|
||||
// renders them directly.
|
||||
const achievements = src.match(
|
||||
/charts\.push\(\{\s*id:\s*"achievements"[\s\S]*?\}\);/,
|
||||
);
|
||||
const workouts = src.match(
|
||||
/charts\.push\(\{\s*id:\s*"recentWorkouts"[\s\S]*?\}\);/,
|
||||
);
|
||||
expect(achievements?.[0]).not.toContain("revealGated");
|
||||
expect(workouts?.[0]).not.toContain("revealGated");
|
||||
});
|
||||
});
|
||||
@@ -50,13 +50,16 @@ describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
|
||||
it("wraps each tile-strip cell in a `<Suspense>` boundary with a layout-stable placeholder", () => {
|
||||
const src = load(PAGE_PATH);
|
||||
// v1.4.41 W-FRONTEND-FACTORY (UX M1) swapped the prior `null`
|
||||
// fallback for an `aria-hidden` placeholder div that mirrors the
|
||||
// trend-card chrome. The tile body is synchronous today so the
|
||||
// fallback rarely paints, but a future RSC hoist of any tile slot
|
||||
// would otherwise leave the grid track empty and trigger CLS as
|
||||
// the cell paints in. The structural pin guards the contract.
|
||||
// fallback for an `aria-hidden` placeholder div; v1.16.0 upgraded
|
||||
// it to the structured `<TrendCardSkeleton>` silhouette (the
|
||||
// aria-hidden + trend-card chrome now live inside the component,
|
||||
// see `trend-card-skeleton.tsx`). The tile body is synchronous
|
||||
// today so the fallback rarely paints, but a future RSC hoist of
|
||||
// any tile slot would otherwise leave the grid track empty and
|
||||
// trigger CLS as the cell paints in. The structural pin guards the
|
||||
// contract.
|
||||
expect(src).toMatch(
|
||||
/<Suspense\s+fallback=\{[\s\S]*?aria-hidden="true"[\s\S]*?\}\s*>\s*\{entry\.node\}\s*<\/Suspense>/,
|
||||
/<Suspense\s+fallback=\{<TrendCardSkeleton\s*\/>\}>\s*\{entry\.node\}\s*<\/Suspense>/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -130,7 +130,12 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("marks the skeleton aria-hidden + carries motion-reduce", () => {
|
||||
it("marks the skeleton aria-hidden + renders structured silhouettes", () => {
|
||||
// v1.16.0 — the strip silhouettes are structured `<TrendCardSkeleton>`
|
||||
// cards (label + headline value + sub-row), not empty pulsing divs.
|
||||
// Reduced motion moved into the component (the `<Skeleton>` primitive
|
||||
// carries `motion-reduce:animate-none`); the per-component test in
|
||||
// `dashboard-chart-reveal.test.tsx` pins that.
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
const skeletonBlock = src.match(
|
||||
/showTileStripSkeleton\s*&&[\s\S]*?<\/div>\s*\)\}/,
|
||||
@@ -138,7 +143,14 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
|
||||
expect(skeletonBlock).not.toBeNull();
|
||||
const block = skeletonBlock?.[0] ?? "";
|
||||
expect(block).toContain('aria-hidden="true"');
|
||||
expect(block).toContain("motion-reduce:animate-none");
|
||||
expect(block).toContain("<TrendCardSkeleton");
|
||||
});
|
||||
|
||||
it("uses the structured silhouette as the per-tile Suspense fallback", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
expect(src).toMatch(
|
||||
/<Suspense fallback=\{<TrendCardSkeleton \/>\}>/,
|
||||
);
|
||||
});
|
||||
|
||||
it("gates the EmptyState on the resolved `showEmptyState` flag", () => {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import RootLoading from "../loading";
|
||||
import {
|
||||
DASHBOARD_WIDGET_IDS,
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
} from "@/lib/dashboard-layout";
|
||||
|
||||
/**
|
||||
* v1.16.1 — root loading fallback renders the structured dashboard
|
||||
* silhouette, not bare cards.
|
||||
*
|
||||
* The first frame a user sees on a cold dashboard load is this fallback
|
||||
* (it streams before the page mounts and before any query resolves).
|
||||
* Pre-fix it painted six EMPTY `bg-card` rectangles in a generic
|
||||
* 3-column grid; the page then swapped to its structured
|
||||
* `<TrendCardSkeleton>` strip + `<ChartSkeleton>` band — a visible
|
||||
* "empty dark cards → skeletons" two-step with a layout jump in
|
||||
* between. These tests pin the fallback to the SAME silhouette
|
||||
* components and the SAME grid track the page's own loading phase
|
||||
* uses, so every phase of the first paint shows one consistent
|
||||
* structure.
|
||||
*/
|
||||
|
||||
const GRID_TRACK =
|
||||
"[grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))]";
|
||||
|
||||
// Same expression the fallback (and, per-user, the page) uses to size
|
||||
// the silhouette strip: web-known widget ids, tileVisible falling back
|
||||
// to the legacy visible flag.
|
||||
const webWidgetIds = new Set<string>(DASHBOARD_WIDGET_IDS);
|
||||
const expectedTileCount = DEFAULT_DASHBOARD_LAYOUT.widgets.filter(
|
||||
(w) => webWidgetIds.has(w.id) && (w.tileVisible ?? w.visible),
|
||||
).length;
|
||||
|
||||
function renderMarkup(): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<RootLoading />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("RootLoading — structured dashboard silhouette (SSR markup)", () => {
|
||||
it("renders one structured trend-card silhouette per default-visible web tile", () => {
|
||||
const markup = renderMarkup();
|
||||
const tiles = markup.match(/data-slot="trend-card-skeleton"/g) ?? [];
|
||||
expect(expectedTileCount).toBeGreaterThan(0);
|
||||
expect(tiles).toHaveLength(expectedTileCount);
|
||||
});
|
||||
|
||||
it("renders the chart-band silhouette", () => {
|
||||
const markup = renderMarkup();
|
||||
expect(markup).toContain('data-slot="chart-skeleton"');
|
||||
});
|
||||
|
||||
it("uses the same auto-fit grid track as the in-page tile strip", () => {
|
||||
const markup = renderMarkup();
|
||||
expect(markup).toContain(
|
||||
"grid-template-columns:repeat(auto-fit,minmax(min(100%,11rem),1fr))",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks the silhouette strip with the shared tile-strip-skeleton slot", () => {
|
||||
const markup = renderMarkup();
|
||||
expect(markup).toContain('data-slot="dashboard-tile-strip-skeleton"');
|
||||
});
|
||||
|
||||
it("contains no bare empty-card silhouette (every card carries inner skeleton rows)", () => {
|
||||
const markup = renderMarkup();
|
||||
// The legacy fallback's signature was a card with a single empty
|
||||
// h-24 block and no structured rows. The structured silhouettes
|
||||
// always carry the headline-value row (`h-[30px]`) inside each
|
||||
// card; assert it is present and the legacy block is gone.
|
||||
expect(markup).not.toContain("h-24");
|
||||
expect(markup).toContain("h-[30px]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("RootLoading — single skeleton source (wiring pins)", () => {
|
||||
const loadingSrc = readFileSync(
|
||||
join(process.cwd(), "src/app/loading.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const pageSrc = readFileSync(join(process.cwd(), "src/app/page.tsx"), "utf8");
|
||||
|
||||
it("imports the same silhouette components the page's loading phase uses", () => {
|
||||
expect(loadingSrc).toContain(
|
||||
'import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton"',
|
||||
);
|
||||
expect(loadingSrc).toContain(
|
||||
'import { ChartSkeleton } from "@/components/charts/chart-skeleton"',
|
||||
);
|
||||
expect(pageSrc).toContain(
|
||||
'import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton"',
|
||||
);
|
||||
});
|
||||
|
||||
it("shares the grid-track literal with the page so the footprint matches at swap time", () => {
|
||||
expect(loadingSrc).toContain(GRID_TRACK);
|
||||
expect(pageSrc).toContain(GRID_TRACK);
|
||||
});
|
||||
});
|
||||
@@ -170,14 +170,14 @@ export default function AboutPage() {
|
||||
data-slot="about-footer"
|
||||
>
|
||||
<p>
|
||||
HealthLog — open source under{" "}
|
||||
HealthLog — source-available under the{" "}
|
||||
<a
|
||||
href="https://github.com/MBombeck/HealthLog/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground hover:underline"
|
||||
>
|
||||
AGPL-3.0
|
||||
PolyForm Noncommercial License 1.0.0
|
||||
</a>
|
||||
. See{" "}
|
||||
<Link
|
||||
|
||||
@@ -31,6 +31,7 @@ import { LoginOverviewSection } from "@/components/admin/login-overview-section"
|
||||
import { RemindersSection } from "@/components/admin/reminders-section";
|
||||
import { ServicesSection } from "@/components/admin/services-section";
|
||||
import { SystemStatusSection } from "@/components/admin/system-status-section";
|
||||
import { InviteTokensSection } from "@/components/admin/invite-tokens-section";
|
||||
import { UserManagementSection } from "@/components/admin/user-management-section";
|
||||
import type { AdminSectionSlug } from "@/components/admin/section-slugs";
|
||||
|
||||
@@ -141,6 +142,18 @@ export function AdminSectionRenderer({
|
||||
<UserManagementSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "invites":
|
||||
// v1.16.0 — invites moved out of Users into their own section:
|
||||
// the full table (status, redemptions, revocation) is a workflow
|
||||
// of its own and was drowning under the account list.
|
||||
return (
|
||||
<SectionFrame
|
||||
title={t("admin.section.invites.title")}
|
||||
subtitle={t("admin.section.invites.subtitle")}
|
||||
>
|
||||
<InviteTokensSection />
|
||||
</SectionFrame>
|
||||
);
|
||||
case "api-tokens":
|
||||
return (
|
||||
<SectionFrame
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { AdminShell } from "@/components/admin/admin-shell";
|
||||
import { SystemStatusSummary } from "@/components/admin/system-status-summary";
|
||||
import { VersionTileSection } from "@/components/admin/version-tile-section";
|
||||
import { RecentAuditPreview } from "@/components/admin/recent-audit-preview";
|
||||
import { useAdminSettings } from "@/components/admin/_shared";
|
||||
|
||||
@@ -61,6 +62,8 @@ export default function AdminOverviewPage() {
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<VersionTileSection />
|
||||
|
||||
<SystemStatusSummary />
|
||||
|
||||
<RecentAuditPreview />
|
||||
|
||||
@@ -38,7 +38,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const rl = await checkRateLimit("admin-ai-settings", 10, 60_000);
|
||||
if (!rl.allowed) throw new HttpError(429, "Too many requests");
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const { apiKey, model, baseUrl } = body as {
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
MEDICATION_COMPLIANCE_BACKFILL_DAYS,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
|
||||
import { restoreCycleData } from "@/lib/cycle/backup";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -63,6 +64,10 @@ interface RestoreResponse {
|
||||
notificationChannels: number;
|
||||
pushSubscriptions: number;
|
||||
telegramScheduledDeletions: number;
|
||||
// v1.15.0 — cycle tables wiped before recreate.
|
||||
cycles: number;
|
||||
cycleDayLogs: number;
|
||||
cycleProfile: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -115,7 +120,11 @@ const handler = apiHandler(
|
||||
|
||||
let body: { confirm?: string } = {};
|
||||
try {
|
||||
body = (await request.json()) as { confirm?: string };
|
||||
const raw = await request.text();
|
||||
if (raw.length > 64 * 1024) {
|
||||
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
|
||||
}
|
||||
body = JSON.parse(raw) as { confirm?: string };
|
||||
} catch {
|
||||
return apiError("Invalid JSON body", 400);
|
||||
}
|
||||
@@ -397,6 +406,11 @@ const handler = apiHandler(
|
||||
});
|
||||
}
|
||||
|
||||
// v1.15.0 — cycle tables (profile + observed spans + day-logs +
|
||||
// symptom links). Delete-then-recreate, mirroring the contract
|
||||
// above. `notesEncrypted` is restored as ciphertext verbatim.
|
||||
const cycleCleared = await restoreCycleData(tx, ownerId, payload);
|
||||
|
||||
return {
|
||||
measurements: measurements.count,
|
||||
medications: meds.count,
|
||||
@@ -405,6 +419,9 @@ const handler = apiHandler(
|
||||
notificationChannels: channels.count,
|
||||
pushSubscriptions: subs.count,
|
||||
telegramScheduledDeletions: tgDel.count,
|
||||
cycles: cycleCleared.cycles,
|
||||
cycleDayLogs: cycleCleared.cycleDayLogs,
|
||||
cycleProfile: cycleCleared.cycleProfile,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -513,6 +530,8 @@ const handler = apiHandler(
|
||||
medications: summary.medications,
|
||||
intakeEvents: summary.intakeEvents,
|
||||
moodEntries: summary.moodEntries,
|
||||
cycles: summary.cycles,
|
||||
cycleDayLogs: summary.cycleDayLogs,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -216,6 +216,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
medications: summary.medications,
|
||||
intakeEvents: summary.intakeEvents,
|
||||
moodEntries: summary.moodEntries,
|
||||
cycles: summary.cycles,
|
||||
cycleDayLogs: summary.cycleDayLogs,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -27,7 +27,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
let confirm = "";
|
||||
try {
|
||||
const body = await request.json();
|
||||
const raw = await request.text();
|
||||
if (raw.length > 64 * 1024) {
|
||||
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
|
||||
}
|
||||
const body = JSON.parse(raw);
|
||||
confirm = typeof body?.confirm === "string" ? body.confirm : "";
|
||||
} catch {
|
||||
return apiError("Invalid request", 422);
|
||||
|
||||
@@ -44,10 +44,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.drain.cumulative" } });
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson<DrainBody>(request);
|
||||
const { data: rawBody, error: jsonError } = await safeJson<DrainBody>(
|
||||
request,
|
||||
{ maxBytes: 64 * 1024 },
|
||||
);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const body: DrainBody = (rawBody && typeof rawBody === "object") ? rawBody : {};
|
||||
const body: DrainBody = rawBody && typeof rawBody === "object" ? rawBody : {};
|
||||
const userId =
|
||||
typeof body.userId === "string" && body.userId.length > 0
|
||||
? body.userId
|
||||
@@ -88,7 +91,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (summary.totals.bucketsCollapsed === 0 && summary.totals.usersScanned === 0 && userId) {
|
||||
if (
|
||||
summary.totals.bucketsCollapsed === 0 &&
|
||||
summary.totals.usersScanned === 0 &&
|
||||
userId
|
||||
) {
|
||||
return apiError("User not found", 404);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ export const PATCH = apiHandler(
|
||||
const { user } = await requireAdmin();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = updateFeedbackSchema.safeParse(body);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* DELETE /api/admin/invites/{id} — revoke a registration invite.
|
||||
*
|
||||
* v1.16.0 — soft revocation (`revokedAt`), replacing the v1.15.20 hard
|
||||
* delete: the admin table now renders redemption history per invite,
|
||||
* and a deleted row would erase who was admitted through it. The
|
||||
* consume path refuses a revoked invite like an expired one.
|
||||
*
|
||||
* Idempotent — revoking an unknown or already-revoked id returns
|
||||
* `{ revoked: false }` rather than 404 so a double-tap in the admin UI
|
||||
* never surfaces an error.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { user } = await requireAdmin();
|
||||
const { id } = await params;
|
||||
|
||||
const { count } = await prisma.inviteToken.updateMany({
|
||||
where: { id, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
const revoked = count > 0;
|
||||
|
||||
if (revoked) {
|
||||
await auditLog("admin.invite.revoked", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { inviteId: id },
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.invite.revoked" },
|
||||
meta: { revoked },
|
||||
});
|
||||
|
||||
return apiSuccess({ revoked });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* GET /api/admin/invites — list all registration invites.
|
||||
* POST /api/admin/invites — mint a new invite token.
|
||||
*
|
||||
* v1.15.20 — closed-registration invite flow. The raw `hlv_<hex>` token
|
||||
* appears EXACTLY ONCE: in the POST response, alongside the composed
|
||||
* registration URL. Only the HMAC-SHA256 hash is persisted (the
|
||||
* `ApiToken` scheme), so neither the GET list nor a database leak can
|
||||
* reproduce a usable invite.
|
||||
*
|
||||
* Both verbs are `requireAdmin()` — cookie-only by construction; a
|
||||
* Bearer token can never mint or enumerate invites.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import {
|
||||
buildInviteUrl,
|
||||
generateInviteToken,
|
||||
} from "@/lib/auth/invite-token";
|
||||
import { inviteCreateSchema } from "@/lib/validations/invite";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.invites.list" } });
|
||||
|
||||
const invites = await prisma.inviteToken.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
usedAt: true,
|
||||
revokedAt: true,
|
||||
uses: true,
|
||||
maxUses: true,
|
||||
creator: { select: { id: true, username: true } },
|
||||
consumer: { select: { id: true, username: true } },
|
||||
// v1.16.0 — full per-signup history. `consumer` only carries the
|
||||
// LAST account; multi-use invites list every admitted signup here.
|
||||
redemptions: {
|
||||
select: {
|
||||
id: true,
|
||||
redeemedAt: true,
|
||||
user: { select: { id: true, username: true, email: true } },
|
||||
},
|
||||
orderBy: { redeemedAt: "desc" },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return apiSuccess(invites);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = inviteCreateSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { expiresInDays, maxUses } = parsed.data;
|
||||
const rawToken = generateInviteToken();
|
||||
const expiresAt = new Date(
|
||||
Date.now() + expiresInDays * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const invite = await prisma.inviteToken.create({
|
||||
data: {
|
||||
tokenHash: hashToken(rawToken),
|
||||
createdBy: user.id,
|
||||
expiresAt,
|
||||
maxUses,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
uses: true,
|
||||
maxUses: true,
|
||||
},
|
||||
});
|
||||
|
||||
// The audit row carries metadata only — never the raw token.
|
||||
await auditLog("admin.invite.created", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { inviteId: invite.id, expiresInDays, maxUses },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.invite.created" },
|
||||
meta: { expiresInDays, maxUses },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
...invite,
|
||||
// Shown exactly once. Neither value is persisted in plaintext.
|
||||
token: rawToken,
|
||||
url: buildInviteUrl(rawToken, req.url),
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
@@ -48,8 +48,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.rollups.recompute" } });
|
||||
|
||||
const { data: rawBody, error: jsonError } =
|
||||
await safeJson<RecomputeBody>(request);
|
||||
const { data: rawBody, error: jsonError } = await safeJson<RecomputeBody>(
|
||||
request,
|
||||
{ maxBytes: 64 * 1024 },
|
||||
);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const body: RecomputeBody =
|
||||
|
||||
@@ -120,7 +120,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
annotate({ action: { name: "admin.settings.assistant-flags.update" } });
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = assistantFlagsSchema.safeParse(body);
|
||||
|
||||
Loaded 100 of 779 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user