mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
* fix(measurements): close issue #109 + sync body composition across UI/PDF/thresholds User-visible: TOTAL_BODY_WATER, BONE_MASS, and BLOOD_GLUCOSE now appear in the measurements filter, badge, mobile icon, and edit dialog (issue #109). Server-rendered doctor report now includes body composition rows (audit C-9). Body water + bone mass have proper effective-range thresholds so severity logic doesn't silently return `nominal` (audit C-15). Structural: extracted the per-type display maps from measurement-list.tsx into measurement-list-meta.ts as a deliberate first step toward the single-source manifest planned for phase P5. Coverage tests now fail-fast if a future enum addition skips any of: list label/icon/color, doctor-PDF label/unit/vital-types, chart label, threshold metric order/label/bounds. Audit-2026-05-07 / phase P0: - closes #109 (filter UI gap, root cause for the German raw-enum display) - closes audit C-9 (server PDF used a stale type map) - closes audit C-15 (no warn/critical bands for body composition) Tests: 282 → 296 (14 new). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: align OpenAPI spec + migration comment with body-composition reality - OpenAPI MeasurementType enum: add TOTAL_BODY_WATER + BONE_MASS (was at v1.3.0 baseline; spec drifted past the v1.3 server release) - Bump openapi version 1.3.0 → 1.3.2 to match package.json - Migration 0022 comment: TOTAL_BODY_WATER stores kg (water content), not percent of body weight. Validators (VALUE_RANGES) and the Withings client (type 77 → kg) treat it as kg already; the comment was the lone outlier and would mislead the next reviewer. Audit-2026-05-07 / phase P1a — closes audit M-B1 (partial; OpenAPI enum) and M-B2 (migration unit comment). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): close 3 audit CRITICALs (Withings header / idempotency Bearer / GlitchTip URL strip) C-3 (Withings webhook secret in URL): Now reads X-Withings-Webhook-Secret header in preference; URL ?secret=… fallback retained for legacy Withings configurations and emits a Wide Event warning so operators can spot still-using-the-old-flow integrators. The query-string secret was leaking into Coolify access logs and any GlitchTip event whose request.url got captured. C-4 (idempotency dead for Bearer-token clients): The default userIdResolver only consulted getSession(). Bearer-authed clients (the iOS / n8n / headless-API fleet that PR #117 was built for) fell through, so every retry created duplicate measurements. The default now tries cookie session first, then Bearer token via hashToken+apiToken.findUnique with revoked/expiry checks. All three existing call sites (measurements, mood-entries, medications/intake) get the fix automatically. H-B7 (request.url with secrets shipped to GlitchTip): reportToGlitchtip now strips the query string before forwarding the URL, so Withings legacy ?secret=… and any OAuth ?code=… that errors during the callback don't end up in someone else's incident UI. Audit-2026-05-07 / phase P2. Tests: 296 → 301 (5 new in idempotency.test for the Bearer fallback + revoked/expired/null cases). Deferred to a follow-up (needs DB migration / lock primitive): C-2 moodLog webhook → AES-GCM at rest + HMAC-over-body C-5 idempotency TOCTOU advisory_lock or in-flight sentinel row Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(measurements): add OXYGEN_SATURATION (SpO2) as first-class measurement type — v1.3.3 Closes the OxygenSaturation feature request from #109. - Migration 0024 extends MeasurementType enum (additive, IF NOT EXISTS) - Zod validators: plausibility 50–100% (below 50 is incompatible with sustained life; above 100 is physically impossible) - effective-range thresholds: green 95–100, orange 92–94, red <92. Sources: BTS Guideline 2017 (target 94–98%), ATS clinical practice (≥95% at rest is healthy), AHA/clinical literature (<92% = call provider, <88% = ER). Lower-only concern — upper orange wing collapses to greenMax since saturation cannot exceed 100%. - Personalisation: existing thresholds-override UI lets COPD / chronic-respiratory users with a doctor-set 88–92 baseline tune the bands. METRIC_ORDER + METRIC_LABEL_KEYS extended in thresholds-section.tsx; the exhaustive `Record<ThresholdMetric>` forces TS to catch any missed surface. - Withings ScanWatch type 54 mapping (best-effort — only fires for pulse-ox-capable devices). - Wired through measurement-form, measurement-list-meta (icon: Wind), health-chart, both doctor-report-pdf renderers (core + browser-side near-duplicate), OpenAPI spec (version 1.3.0 → 1.3.3), and i18n in 4 sections × 2 locales. - iOS DTO already declared `OXYGEN_SATURATION` — server enum addition closes the long-standing drift flagged in audit V2. - 4 new tests: numeric green band, upper-wing collapse, user-override (COPD baseline), METRIC_BOUNDS shape. Tests: 301 → 305. Typecheck: clean. Audit-2026-05-07 / v1.3.3 / closes #109 SpO2 feature request. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(analytics): truthfulness pass on medical citations + clamp override orangeMax Doctor-agent + hallucination-hunter audit V3 found six medical citations in effective-range.ts that the code did not actually implement. Plus a latent bug in the user-override path that produced physically-impossible orangeMax values (>100% saturation, <0% body-fat). Citation rewrites (no numeric changes): - ACTIVITY_STEPS: "WHO ≥8000 steps/day" → Saint-Maurice JAMA 2020. WHO publishes activity in min/week, not steps. Step-count was a misattribution that also leaked into AI prompts. - TOTAL_BODY_WATER: "ESPEN Body Composition Guidance 2017" → Watson formula / ICRP Reference Man. ESPEN 2017 is BIA practice guidance, it does not publish kg cut-offs. - BONE_MASS: "DEXA-equivalent bone mineral content" → "Bioimpedance- estimated; not DEXA-comparable". Withings BIA values typically run 5–7% below DEXA — calling them DEXA-equivalent in a doctor-report PDF was a real legal-exposure misattribution. - OXYGEN_SATURATION: "BTS Guideline 2017 (target 94–98%) + ATS clinical practice (≥95% at rest is healthy)" → "consumer pulse- oximeter consensus (≥95% normal at rest); slightly tighter than BTS 2017 (94–98%); NICE NG115 ≤92% escalation, BMJ panel / FDA pulse-oximeter labelling ≤88% ER threshold". Code uses 95-100/92-100 band — that's an ATS/consumer-consensus number, not BTS's literal 94-98 target. Soften the citation to match the code, not vice versa. Bug fix (1 latent, 1 real-today): - effective-range override path now clamps orangeMin/orangeMax to METRIC_BOUNDS.{min,max}. Without the clamp, a COPD user setting SpO2 override to {95,100} emitted orangeMax = 100.75 (impossible saturation), and a BODY_FAT override of {2,80} emitted orangeMin = -9.7 (negative percent). Default-range case 210 already hard-coded orangeMax: 100 for the same reason — the override path was the only branch missing the invariant. Tests: 305 → 306 (1 new: clamps override orangeMax to physiological 100%). Typecheck: clean. Audit-2026-05-07-V3 / closes Doctor + Senior-Dev medical-citation findings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): Bearer-scope wildcard handling + GDPR Art.17 account-delete erasure Two CRITICAL fixes from audit V3. NEW-V3-1: Bearer-scope bypass. apiToken.permissions ["*"] is meant to be the wildcard "session-equivalent" scope (the iOS app receives this on login). The previous check permissions.includes(requiredPermission) treated "*" as a literal string — never matched. Today many sensitive routes call requireAuth() *without* a requiredPermission, so a leaked iOS token can act as a full-scope token (account delete, settings wipe). Once those routes adopt requireAuth("scope:name"), this wildcard handling keeps the iOS app working while narrower-scoped tokens (e.g. user-issued ["medication:ingest"]) get correctly 403'd. Same wildcard handling added to the standalone permission checks in src/app/api/ingest/medication/route.ts:65, 119. NEW-V3-2: GDPR Art. 17 account-delete erasure completeness. Feedback + AuditLog rows had `onDelete: SetNull` in the schema, which preserved PII (free-text symptom descriptions, IP addresses, login-city geolocation) after the user was deleted. We now explicitly prisma.feedback.deleteMany + prisma.auditLog.deleteMany before the cascade-delete, making account deletion genuinely complete erasure. Tests 306/306 still pass; typecheck clean. Audit V3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(pdf): real text-content assertions for body composition + SpO2 rows Replaces bytes-only "renders body composition rows" theatre test with pdf-parse-driven text extraction that asserts the actual rendered labels + values for TOTAL_BODY_WATER, BONE_MASS, OXYGEN_SATURATION (DE + EN). Closes V3 audit finding STILL-V2-NEW-3: the previous test only verified "%PDF-" header + byte-count, so a regression that drops a row would still pass. Now a missing label fails the test. - adds dev dep: pdf-parse@^2.4.5 - 308/308 tests pass (was 306, +2 SpO2 + EN-locale coverage) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(measurements): close enum drift cousins — derive 5 type-arrays from canonical enum + extend 2 contract enums V3 audit finding "enum drift cousins": 7 hardcoded module-level type arrays silently dropped SpO2/TBW/BoneMass/BloodGlucose from their respective domains. Adding a new measurement type required hunting these down. Now all aggregation paths derive from `measurementTypeEnum.options`: - /api/insights/comprehensive (AI insights) - /api/dashboard/summary (iOS dashboard adapter) - /api/analytics (analytics aggregator) - /lib/insights/general-status (AI general-status fetch) - /api/import (Zod schema — round-trip now covers all 11 types) External-contract enums extended additively (no breakage for old clients): - /api/measurements/series kindEnum: + oxygen, totalBodyWater, boneMass - /api/dashboard/widgets widgetIdEnum: + oxygenSaturation - DashboardWidgetId + DEFAULT_DASHBOARD_LAYOUT: + oxygenSaturation row - formatMeasurementsForExport: glucoseContext now round-trips Coverage test (`measurement-type-enum-coverage.test.ts`) asserts the canonical enum stays the source of truth and documents PDF exclusions (BLOOD_GLUCOSE renders via context section, SLEEP/STEPS lifestyle-only). 313/313 tests pass (was 308, +5 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): close 6 V3-audit HIGH findings (CSP, web-push SSRF, IP geo, AI provider leak, import rate-limit) - CSP: chatgpt.com + api.openai.com only on /settings/ai/** routes (was blanket connect-src on every page including /auth/login → DOM-XSS exfil channel) - web-push: webPushSubscriptionSchema.endpoint requires HTTPS + isPublicUrl refinement; was z.url() only → blind SSRF probe via Push subscription - isPublicUrl: also fixes a regression where DNS labels starting with "fc"/"fd" (fcm.googleapis.com etc.) were falsely classified as IPv6 unique-local. Check is now gated on a colon being present in the host. - /api/ai/test: stop returning provider err.message + bodyExcerpt to the client (leaked provider URLs / partial keys / internal headers). Now returns categorised generic message; full details land in the wide-event via annotate() for the operator. - /lib/geo (auditLog IP geo lookup): switch default provider to ipwho.is over HTTPS, accept ipwho.is + ip-api pro response shapes, add IP_GEO_LOOKUP_DISABLED env, refuse non-HTTPS configured URLs. Previously leaked IP+timestamp over plaintext HTTP every auth event (GDPR Art. 32 + Art. 44). - /api/import: add 5/hour rate-limit (was unlimited; bulk-injection vector) Tests: 325/325 pass (was 313, +12 new — webPushSubscriptionSchema SSRF guards, fc/fd-prefix domain regression, geo HTTPS-only, geo provider shapes, geo opt-out env). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security,gdpr): trusted-proxy IP, audit-log retention, idempotency cachable filter, Bearer mock tightening V3 audit follow-on bundle covering 4 HIGH findings. (I) getClientIp — TRUST_PROXY_HOPS env (default 1) replaces blind XFF trust. Reads chain right-to-left so client-supplied leftmost entries cannot rotate the per-IP rate-limit bucket. Default 1 matches typical single-proxy self-host (Coolify/Caddy/Cloudflare-Tunnel); 0 forces x-real-ip / null. New 7-test suite covers the rotation attack + malformed entries + zero-hop fallback. (F) Audit-log retention job — daily 03:15 cleanup of audit_logs older than AUDIT_LOG_RETENTION_DAYS (default 365). Closes GDPR Art. 5(1)(e) "storage limitation" gap; previously IPs + city + login events accumulated forever. 6-test suite covers default + override + misconfig guard (rejects <7 days). (G) isCachableStatus — extracted the inline 4xx/5xx filter into a named, exported, tested function. 7 cases cover the full do-not-cache contract (401 / 403 / 408 / 429 / any 5xx) and confirm 2xx + 4xx validation are still cached. (H) Bearer-mock tightening — apiToken.findUnique mocks in require-auth-bearer.test + idempotency.test now assert the where.tokenHash shape uses the hashed value (not the raw bearer). A regression that switched back to raw-token comparison would have been silent without this assertion. Tests: 345/345 pass (was 332, +13 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): encrypt moodLog webhook secret at rest with AES-256-GCM (V3 audit STILL-V2-C-2) The mood_log_webhook_secret column previously stored a plaintext secret that the webhook handler compared timing-safe against every enabled user. A DB dump or backup snapshot leaked active webhook credentials. New contract — no schema migration required: - Writes go through encryptMoodLogSecret() (AES-256-GCM via existing crypto helper, ENCRYPTION_KEY). - Reads go through readMoodLogSecret() which transparently decrypts envelopes and tolerates legacy plaintext rows during the transition (graceful upgrade path — no breaking change for self-hosted users). - Webhook lookup decrypts each candidate before timing-safe compare. - Status endpoint decrypts for the user's settings page. One-shot startup migration in reminder-worker rotates any leftover plaintext rows on next worker boot. Idempotent; encrypted rows are skipped. Tests: 350/350 pass (was 345, +5 new — round-trip, legacy passthrough, isLegacyPlaintext semantics, rotateLegacy idempotence). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: sync README + AGENTS + CLAUDE + CHANGELOG + OpenAPI for v1.3.3 (V3 audit doc-drift) V3 audit Doku Specialist findings — drift between code and meta-docs. - README §Key Features now mentions pulse oximetry (SpO₂) + Withings ScanWatch sync; Tech Stack model count corrected 23 → 25. - AGENTS.md status promoted v1.3.2 → v1.3.3; vitest config corrected `vitest.config.ts` → `vitest.config.mts`; migration range corrected `0001–0022` → `0001–0024`; model count + list updated to 25 (adds Device, IdempotencyKey). - CLAUDE.md model count updated (2 occurrences). - CHANGELOG.md v1.3.3 expanded with Security + Internal sections that were missing the audit-fix-marathon work (Bearer-scope, account-delete cascade, moodLog encryption, CSP tightening, Web-Push SSRF, IP-geo HTTPS, AI-test leak fix, import rate-limit, trusted-proxy XFF, audit-log retention, idempotency cachable filter, Bearer mock tightening, enum-drift cousins, PDF text-content tests). - OpenAPI Bearer description corrected `SHA-256 hashes` → `HMAC-SHA-256 hashes (keyed with API_TOKEN_HMAC_KEY)` so the docs match the actual server-side implementation. Wildcard scope contract documented. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(audit-review): close 4 HIGH + 2 MEDIUM findings from multi-agent review pass Multi-agent review of the audit-fix branch surfaced four HIGH issues and a couple of MEDIUM hardenings. All addressed before merge. HIGH-1 — OpenAPI MetricKind enum drift (iOS strict-decode crash risk). The dashboard-summary route now emits totalBodyWater/boneMass/ oxygenSaturation MetricCards, but the spec still listed only the original 7 kinds. Generated iOS Codable would have crashed the dash the moment a user logged SpO2. MetricKind extended with the 3 new kinds + comment documenting unknown-value defensive decoding. HIGH-2 — kindEnum naming consistency on /api/measurements/series. The series adapter shipped "oxygen" while the dashboard summary used "oxygenSaturation"; both routes now use "oxygenSaturation" so iOS decoders can share a single MetricKind type across endpoints. HIGH-3 — /api/ai/test no-leak guard had no test. Added 4 tests that mock provider errors with sk-leaked-key + provider URLs in the err fields and assert the response body never echoes them, plus the 3-status-categorised generic messages (401/429/5xx/other). HIGH-4 — /api/import 5/hour rate-limit had no test. Added 2 tests that mock checkRateLimit allowed/disallowed and assert the 429 path uses the right key shape (`import:<userId>`, 5, 1h) and that the within- quota path proceeds. MEDIUM-3 — getClientIp under-length XFF chain regression. With TRUST_PROXY_HOPS=2 and a chain of length 1, the previous clamp returned the leftmost (attacker-controlled) entry, silently re-introducing the rotation attack the helper was meant to close. Now: when chain is shorter than configured hops, refuse XFF entirely (fall back to x-real-ip / null). Added regression test. LOW-1 — TRUST_PROXY_HOPS=garbage now throws at boot instead of silently degrading to hops=0. Added regression test. Tests: 358/358 pass (was 350, +8 new). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
435 lines
22 KiB
Markdown
435 lines
22 KiB
Markdown
<p align="center">
|
||
<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>
|
||
</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>
|
||
<img src="https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
||
<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/Self--Hosted-yes-success" alt="Self-Hosted" />
|
||
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome" />
|
||
</p>
|
||
|
||
<p align="center">
|
||
A self-hosted, privacy-first health tracking PWA.<br/>
|
||
Weight, blood pressure, mood, medications, AI insights -- all under your control.
|
||
</p>
|
||
|
||
<p align="center">
|
||
<a href="https://healthlog.dev">Website</a> ·
|
||
<a href="https://demo.healthlog.dev">Live Demo</a> ·
|
||
<a href="https://docs.healthlog.dev">Documentation</a>
|
||
</p>
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
---
|
||
|
||
## 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 ESC/ESH 2018, ADA 2024, and consensus pulse-oximeter guidance (NICE NG115). Body-composition + SpO₂ metrics sync automatically from Withings Body+ scales and ScanWatch devices.
|
||
|
||
**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** -- 5-point scale with tags, notes, and trend analytics. Syncs automatically from moodLog.app via webhook.
|
||
|
||
**Medication Compliance** -- Flexible scheduling with time windows, recurrence patterns, intake logging (take/skip/snooze), and compliance heatmaps. External API for iOS Shortcuts integration.
|
||
|
||
**Withings Integration** -- OAuth2 device sync for scales, blood pressure monitors, and activity trackers with automatic deduplication.
|
||
|
||
**Multi-Provider AI Insights** -- Pick OpenAI, Anthropic Claude, or any local OpenAI-compatible endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared key. Cached daily. Local endpoints keep all data on your network.
|
||
|
||
**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.
|
||
|
||
**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.
|
||
|
||
**Multi-Channel Notifications** -- Telegram (with inline action buttons), ntfy (self-hostable), and Web Push. Medication reminders with late/missed escalation.
|
||
|
||
**Gamification** -- 38+ persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
|
||
|
||
**Internationalization** -- English (default) and German UI with 1500+ translation keys, guarded by a CI integrity test that fails the build on duplicate keys or drift between locales. Numbers, dates, units, and AI prompts all locale-aware via `useFormatters()`. Browser-based detection with per-user override.
|
||
|
||
---
|
||
|
||
## Quick Start
|
||
|
||
Plan ~5 minutes for 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`.
|
||
|
||
```bash
|
||
git clone https://github.com/MBombeck/HealthLog.git
|
||
cd HealthLog
|
||
cp .env.example .env
|
||
```
|
||
|
||
Generate the four required secrets and paste them into `.env`:
|
||
|
||
```bash
|
||
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
|
||
echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env
|
||
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||
```
|
||
|
||
Then bring the stack up:
|
||
|
||
```bash
|
||
docker compose up -d
|
||
```
|
||
|
||
Open **http://localhost:3000**. The first registered user becomes admin.
|
||
|
||
> 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.
|
||
|
||
---
|
||
|
||
## Tech Stack
|
||
|
||
| Layer | Technology |
|
||
| ------------- | ------------------------------------------------- |
|
||
| Framework | Next.js 16 (App Router, React Server Components) |
|
||
| Language | TypeScript (strict mode) |
|
||
| Database | PostgreSQL 16 + Prisma 7 (25 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) |
|
||
| PDF | jsPDF (client-side generation) |
|
||
| Testing | Vitest 4 |
|
||
| Deployment | Docker (multi-stage Alpine) |
|
||
|
||
---
|
||
|
||
## Security and Privacy
|
||
|
||
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) are encrypted at rest.
|
||
- **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.
|
||
- **Audit logging** -- All sensitive operations tracked with IP addresses.
|
||
|
||
---
|
||
|
||
## Environment Variables
|
||
|
||
### Required
|
||
|
||
| Variable | Description |
|
||
| -------------------- | ------------------------------------------------------------- |
|
||
| `POSTGRES_PASSWORD` | Password for the bundled Postgres service (Docker Compose) |
|
||
| `DATABASE_URL` | PostgreSQL connection string (uses `POSTGRES_PASSWORD` above) |
|
||
| `SESSION_SECRET` | 64-char hex string for session signing |
|
||
| `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 |
|
||
| `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
|
||
|
||
```
|
||
src/
|
||
├── app/ # Next.js App Router pages & API routes
|
||
│ ├── api/ # REST API endpoints (100+ 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, 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.
|
||
|
||
<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 |
|
||
| `POST` | `/api/integrations/moodlog/webhook` | moodLog.app webhook |
|
||
|
||
</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 + Gravatar URL |
|
||
| `POST` | `/api/auth/password` | Change password |
|
||
| `PATCH`| `/api/auth/profile` | Update profile fields |
|
||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows (4 sub-routes) |
|
||
| `GET` | `/api/auth/passkeys` | List enrolled passkeys |
|
||
| `GET` | `/api/auth/codex/authorize` | ChatGPT (codex) OAuth start |
|
||
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
|
||
| `POST` | `/api/withings/sync` | Trigger manual Withings sync |
|
||
| `POST` | `/api/withings/webhook` | Withings notification webhook |
|
||
| `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 |
|
||
|
||
</details>
|
||
|
||
---
|
||
|
||
## Integrations
|
||
|
||
| Integration | Setup | Purpose |
|
||
| --------------- | ------------- | ---------------------------------------- |
|
||
| **Withings** | Env vars | Auto-sync weight, BP, and activity |
|
||
| **Telegram** | Admin Panel | Medication reminders with inline buttons |
|
||
| **ntfy** | User Settings | Self-hosted push notifications |
|
||
| **Web Push** | Admin Panel | Browser-native VAPID notifications |
|
||
| **OpenAI** | User Settings | AI health insights (BYOK) |
|
||
| **moodLog.app** | User Settings | Mood tracking sync |
|
||
| **Umami** | Admin Panel | Privacy-friendly analytics |
|
||
| **GlitchTip** | Admin Panel | Sentry-compatible error tracking |
|
||
|
||
---
|
||
|
||
## Local Development
|
||
|
||
```bash
|
||
# Prerequisites: Node.js 20+, 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/).
|
||
|
||
---
|
||
|
||
## 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.
|
||
|
||
---
|
||
|
||
## License
|
||
|
||
HealthLog is licensed under the [GNU Affero General Public License v3.0](LICENSE).
|
||
|
||
---
|
||
|
||
<p align="center">
|
||
<a href="https://healthlog.dev">healthlog.dev</a> ·
|
||
<a href="https://demo.healthlog.dev">Live Demo</a> ·
|
||
<a href="https://docs.healthlog.dev">Docs</a>
|
||
</p>
|