mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 12:22:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c584ead9c8 | ||
|
|
492748f105 | ||
|
|
0a77558fd0 | ||
|
|
79764d3467 | ||
|
|
0e510944ce | ||
|
|
05142841fa | ||
|
|
53235a0fb7 | ||
|
|
846eda80fa | ||
|
|
ef3e37b7da | ||
|
|
3de2e37ed5 | ||
|
|
908532be73 | ||
|
|
11ed6faedd | ||
|
|
59448d473c | ||
|
|
75d142df25 | ||
|
|
5e721e0791 | ||
|
|
be7df4bc17 | ||
|
|
85f9a07daf | ||
|
|
9d0983dc22 | ||
|
|
068c40270f | ||
|
|
8258f33d02 | ||
|
|
37af3e9e50 | ||
|
|
0081134cd7 | ||
|
|
50e23a38a1 | ||
|
|
6fab09041a | ||
|
|
bfb346db33 | ||
|
|
46aa01bb8a | ||
|
|
922648d375 | ||
|
|
4655a1b030 | ||
|
|
3cfde9fa7a | ||
|
|
d7cd2bc3e6 | ||
|
|
38bfb0520d | ||
|
|
588b7c66c1 | ||
|
|
6a159e7ac3 | ||
|
|
1c13a16d49 | ||
|
|
f4c983c8ba | ||
|
|
784b62b656 | ||
|
|
adcacfd1a4 | ||
|
|
fd6170618a | ||
|
|
2e8279a2fd |
No files matched your search
+92
-23
@@ -1,37 +1,106 @@
|
||||
# Database
|
||||
DATABASE_URL="postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public"
|
||||
# =============================================================================
|
||||
# HealthLog — Environment Configuration
|
||||
# =============================================================================
|
||||
# Copy this file to .env and fill in the values.
|
||||
# $ cp .env.example .env
|
||||
#
|
||||
# Required variables: DATABASE_URL or POSTGRES_PASSWORD, SESSION_SECRET,
|
||||
# ENCRYPTION_KEY, API_TOKEN_HMAC_KEY
|
||||
#
|
||||
# Generate the three secrets with:
|
||||
# $ openssl rand -hex 32 # any UNIX shell
|
||||
# $ node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))'
|
||||
#
|
||||
# All secrets must be 64-character hex strings (= 32 bytes).
|
||||
# =============================================================================
|
||||
|
||||
# App
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database
|
||||
# -----------------------------------------------------------------------------
|
||||
# When using docker-compose.yml: the app container reaches Postgres via the
|
||||
# service name "db", so the hostname is "db" — NOT "localhost".
|
||||
# When developing without Docker (`pnpm dev` against a host-installed Postgres):
|
||||
# change the hostname to "localhost" and adjust the credentials.
|
||||
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`.
|
||||
POSTGRES_PASSWORD="CHANGE-ME"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# App URL
|
||||
# -----------------------------------------------------------------------------
|
||||
# The public URL where users reach the app. Behind a reverse proxy with TLS,
|
||||
# this looks like https://healthlog.example.com. For local dev, leave default.
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
APP_URL="http://localhost:3000"
|
||||
|
||||
# Node environment: "development" for `pnpm dev`, "production" for Docker.
|
||||
NODE_ENV="development"
|
||||
|
||||
# Session
|
||||
SESSION_SECRET="change-me-to-a-random-64-char-hex-string"
|
||||
|
||||
# Encryption (for tokens, sensitive notes)
|
||||
ENCRYPTION_KEY="change-me-to-a-random-64-char-hex-string"
|
||||
# -----------------------------------------------------------------------------
|
||||
# Required Secrets — generate with `openssl rand -hex 32`
|
||||
# -----------------------------------------------------------------------------
|
||||
# Signs session cookies. Rotating it logs out everyone.
|
||||
SESSION_SECRET="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
|
||||
# HMAC key for API token hashing
|
||||
API_TOKEN_HMAC_KEY="change-me-to-a-random-64-char-hex-string"
|
||||
# AES-256-GCM key for encrypting Withings tokens, GitHub PATs, AI provider
|
||||
# keys, and VAPID private keys at rest. Rotate carefully — old ciphertexts
|
||||
# need re-encryption.
|
||||
ENCRYPTION_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
|
||||
# Withings OAuth (M5)
|
||||
# HMAC-SHA256 key for hashing external-API Bearer tokens before storing them
|
||||
# in the database. Rotating it invalidates all issued tokens.
|
||||
API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Withings OAuth (optional — for automatic scale/BP/activity sync)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Register an app at https://developer.withings.com/ and paste the credentials
|
||||
# here. Per-user OAuth credentials can also be configured in Settings.
|
||||
# WITHINGS_CLIENT_ID=""
|
||||
# WITHINGS_CLIENT_SECRET=""
|
||||
# WITHINGS_REDIRECT_URI=""
|
||||
# WITHINGS_WEBHOOK_SECRET="" # random secret for webhook callback URL hardening
|
||||
# WITHINGS_REDIRECT_URI="https://healthlog.example.com/api/withings/callback"
|
||||
# WITHINGS_WEBHOOK_SECRET="" # any random string — used to harden the webhook URL
|
||||
|
||||
# Telegram webhook hardening (optional but recommended)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Telegram (optional — webhook hardening)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Set to verify incoming Telegram updates. The bot token itself is configured
|
||||
# per-user in Settings (encrypted at rest).
|
||||
# TELEGRAM_WEBHOOK_SECRET=""
|
||||
|
||||
# OpenAI (optional, user-provided keys stored encrypted)
|
||||
# No server-side key needed - users bring their own
|
||||
|
||||
# Structured Logging (Wide Events)
|
||||
# LOG_LEVEL="info" # debug | info | warn | error
|
||||
# LOG_SAMPLE_RATE="1.0" # 0.0–1.0, fraction of successful requests to log
|
||||
# LOG_SLOW_THRESHOLD_MS="3000" # Requests slower than this are always logged
|
||||
# LOG_INCLUDE_STACK="true" # Include stack traces in error events
|
||||
# LOKI_ENDPOINT="" # Optional: Loki Push API (e.g. http://loki:3100)
|
||||
# LOKI_USERNAME="" # Optional: Loki basic auth username
|
||||
# LOKI_PASSWORD="" # Optional: Loki basic auth password
|
||||
# -----------------------------------------------------------------------------
|
||||
# AI Provider (optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Users bring their own API keys via Settings. No server-side key needed by
|
||||
# default. The admin can configure a fallback key in the Admin Panel.
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Structured Logging — Wide Events (optional, sensible defaults)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Levels: debug | info | warn | error
|
||||
# LOG_LEVEL="info"
|
||||
|
||||
# Sample rate for successful requests (0.0 = sample nothing, 1.0 = log all).
|
||||
# Errors and slow requests are ALWAYS logged regardless of this setting.
|
||||
# LOG_SAMPLE_RATE="1.0"
|
||||
|
||||
# Slow-request threshold in milliseconds — anything slower is always kept.
|
||||
# LOG_SLOW_THRESHOLD_MS="3000"
|
||||
|
||||
# Include stack traces on error events. Disable in environments where logs
|
||||
# leave the host unencrypted.
|
||||
# LOG_INCLUDE_STACK="true"
|
||||
|
||||
# Optional: ship Wide Events to Loki. Leave blank for stdout-only logging.
|
||||
# LOKI_ENDPOINT="http://loki:3100"
|
||||
# LOKI_USERNAME=""
|
||||
# LOKI_PASSWORD=""
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Build & Publish Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
# Cancel in-progress runs when a new commit is pushed to the same ref.
|
||||
concurrency:
|
||||
group: docker-publish-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & push multi-arch image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU (multi-arch)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Skip login on PRs from forks — they don't have package:write.
|
||||
# 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
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Compute image tags & labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
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.source=https://github.com/${{ github.repository }}
|
||||
|
||||
- name: Build & push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: true
|
||||
sbom: true
|
||||
@@ -30,11 +30,13 @@ jobs:
|
||||
|
||||
- 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
|
||||
continue-on-error: true
|
||||
|
||||
- name: Unit tests
|
||||
run: pnpm test
|
||||
|
||||
@@ -6,7 +6,7 @@ Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) work
|
||||
|
||||
**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first PWA design.
|
||||
|
||||
**Status**: Feature-complete (v1.0.0). See GitHub Issues for open tasks.
|
||||
**Status**: v1.2.0 (Custom Thresholds, Blood Glucose, Dashboard Customization, Built-in Feedback, Multi-Provider AI). See GitHub Issues for open tasks.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -117,7 +117,7 @@ messages/
|
||||
├── de.json # German translations (primary UI language)
|
||||
└── en.json # English translations
|
||||
prisma/
|
||||
├── schema.prisma # Database schema (22 models)
|
||||
├── schema.prisma # Database schema (23 models)
|
||||
└── migrations/ # Migration files (0001–0018)
|
||||
prisma.config.ts # Prisma config (DB URL lives here, NOT in schema)
|
||||
public/
|
||||
@@ -207,7 +207,7 @@ These are hard-won lessons. Ignoring them will cause errors:
|
||||
|
||||
## Database Models (Prisma)
|
||||
|
||||
22 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`.
|
||||
23 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`.
|
||||
|
||||
## When Making Changes
|
||||
|
||||
|
||||
+171
@@ -1,5 +1,176 @@
|
||||
# Changelog
|
||||
|
||||
## [1.3.2] — 2026-04-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Glucose tiles on the dashboard rendered the raw i18n key
|
||||
`targets.glucoseFasting` instead of the translated label** (closes
|
||||
#108). Both `messages/en.json` and `messages/de.json` had two
|
||||
top-level `targets` blocks; `JSON.parse` silently keeps the last
|
||||
occurrence, and that block was missing the four glucose labels. The
|
||||
duplicate is now collapsed into a single block. A duplicate
|
||||
`bugreport.bugTitlePlaceholder` shadowed inside `bugreport` was
|
||||
cleaned up too. Two further keys (`dashboard.sleep`,
|
||||
`dashboard.steps`) were missing from both locales and were falling
|
||||
back to hard-coded English; both are now translated.
|
||||
- **New i18n locale-integrity test**
|
||||
(`src/lib/__tests__/i18n-locale-integrity.test.ts`) fails the build
|
||||
on duplicate keys at any nesting depth and on key drift between
|
||||
`en` and `de` — closes the structural gap that let the duplicate
|
||||
`targets` block ship in the first place.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Screenshot upload removed from the bug-report form** (also part
|
||||
of #108). The form previously accepted an image attachment that
|
||||
was stored in the local DB but never reached the published GitHub
|
||||
issue — GitHub does not accept inline base64 data URIs in issue
|
||||
bodies and offers no public API to attach images to an issue
|
||||
programmatically. Rather than ship misleading
|
||||
"a screenshot was attached" placeholder text in the resulting
|
||||
issue, the upload UI is now gone and the placeholder note is no
|
||||
longer added when promoting feedback. The `screenshotBase64`
|
||||
column and the admin-side preview of previously-submitted
|
||||
screenshots are unchanged — existing reports keep their
|
||||
attachments locally. We plan to revisit a real screenshot pipeline
|
||||
in a future release.
|
||||
|
||||
## [1.3.1] — 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Compose env-var validation no longer breaks Coolify-style deploys.**
|
||||
`docker-compose.yml` previously used `${VAR:?required}` shell-parameter
|
||||
syntax for the four secrets and `POSTGRES_PASSWORD`. Some hosting
|
||||
platforms (Coolify in particular) parse compose files eagerly and
|
||||
store the _fallback error string_ (`"POSTGRES_PASSWORD is required"`)
|
||||
as the literal env-var value when `POSTGRES_PASSWORD` was unset,
|
||||
which then collided with `DATABASE_URL` and broke the running app
|
||||
with `P1000: Authentication failed`. Compose now uses plain `${VAR}`
|
||||
interpolation; validation moved into `docker-entrypoint.sh`, which
|
||||
fails fast with a clear stderr message listing the unset variables.
|
||||
|
||||
### Notes
|
||||
|
||||
If you upgraded an existing Compose stack from 1.2.x → 1.3.0 and hit
|
||||
the `POSTGRES_PASSWORD is required` literal-as-value bug, set
|
||||
`POSTGRES_PASSWORD` in your environment to whatever your existing
|
||||
Postgres data volume was originally initialised with (likely
|
||||
`healthlog` if you started from a pre-1.2.1 release), then redeploy.
|
||||
Postgres only honours `POSTGRES_PASSWORD` on first volume init — the
|
||||
existing user keeps the original password regardless of env changes.
|
||||
|
||||
## [1.3.0] — 2026-04-27
|
||||
|
||||
### Added — Body composition + targeted hardening
|
||||
|
||||
- **Total body water and bone mass as measurement types** (closes #89). New
|
||||
enum values `TOTAL_BODY_WATER` and `BONE_MASS`, both stored canonically
|
||||
in kilograms (matches Withings hydration/bone-mass measures and Health
|
||||
Connect's `TotalBodyWaterRecord` / `BoneMassRecord`). Migration is
|
||||
purely additive (`ALTER TYPE ... ADD VALUE`) — safe to apply against
|
||||
any 1.2.x database without downtime.
|
||||
- **Withings sync picks both up automatically.** The Withings client now
|
||||
maps measure type `77` (hydration / water mass) and `88` (bone mass).
|
||||
Anyone with a Withings Body+ scale and an active connection will see
|
||||
the new metrics flowing in on the next sync without any extra config.
|
||||
- **Doctor-report PDF includes both new types** in the vital-signs table
|
||||
when data exists, with locale-aware labels in English and German.
|
||||
- **Dashboard widgets registered for both** (default-invisible — opt in
|
||||
via Settings → Dashboard layout).
|
||||
|
||||
### Security
|
||||
|
||||
- **SSRF guard hardened** (`isPublicUrl`). The previous implementation
|
||||
used `parseInt` with permissive prefix checks like `h.startsWith("10.")`
|
||||
which let `010.0.0.1` slip through — and worse, the WHATWG URL parser
|
||||
silently normalises `010.0.0.1` to `8.0.0.1` (octal interpretation), a
|
||||
real bypass on naive checks. The new guard adds a pre-URL leading-zero
|
||||
check on the raw input, a strict IPv4 parser, and proper IPv6
|
||||
bracket / loopback / link-local handling. Now blocks `127.0.0.0/8`,
|
||||
`0.0.0.0/8`, `10.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`,
|
||||
`192.168.0.0/16`, `100.64.0.0/10` (CGNAT), `::1`, `fe80::/10`,
|
||||
`fc00::/7`. Comprehensive regression tests included.
|
||||
- **GitHub PAT redacted from logged error bodies.** When a feedback
|
||||
escalation to GitHub fails, the response body was being passed
|
||||
verbatim into `getEvent()?.addWarning(...)` — flowing to Loki. The
|
||||
body is now stripped of the configured token before logging.
|
||||
- **Per-user threshold writes are rate-limited** (30 writes / 5 min) to
|
||||
make audit-log enumeration unattractive. Audit-logging itself was
|
||||
already there.
|
||||
|
||||
### Performance
|
||||
|
||||
- **N+1 in `/api/insights/comprehensive` fixed.** The medication-compliance
|
||||
loop hit Postgres once per active medication. It now batches into a
|
||||
single `medicationId IN (...)` query and groups in memory. Latency
|
||||
improvement scales linearly with the user's active medication count.
|
||||
|
||||
### Reliability
|
||||
|
||||
- **pg-boss draining on SIGTERM/SIGINT.** `docker stop`, Coolify redeploys,
|
||||
and Kubernetes pod terminations now trigger `boss.stop({ graceful: true,
|
||||
timeout: 30s })` so in-flight handlers finish instead of being killed.
|
||||
Previously, pending handlers could be lost or replayed on restart.
|
||||
- **CI now blocks on TypeScript strict checks.** `continue-on-error: true`
|
||||
removed for typecheck. Tests already were blocking. ESLint stays
|
||||
non-blocking for now — the existing `settings/page.tsx` monolith has
|
||||
long-standing `react-hooks/set-state-in-effect` violations whose proper
|
||||
fix lives in the 1.4.0 settings-split refactor; a blocking lint here
|
||||
would just gate every PR until that refactor lands. Required cleaning
|
||||
up two `any` types in `api-handler.ts` (justified inline — Next.js
|
||||
variadic handler signature constrained by `Promise<Response>` return).
|
||||
|
||||
### Polish
|
||||
|
||||
- **Bottom-nav touch targets** sized to WCAG 2.5.5 minimum (44×44 CSS px).
|
||||
Visual icon stays at 20 px so the design doesn't shift.
|
||||
- **Phase-config dialog** marks the decorative coloured dot `aria-hidden`
|
||||
because the redundant text label already conveys the phase to screen
|
||||
readers. No more meaningless `image` node announcements.
|
||||
- **Admin-status labels** for "Web Push" and "Bug Report" now go through
|
||||
`t()` (new `admin.integrationWebPush` / `admin.integrationBugReport`
|
||||
keys in en + de). They were the last hard-coded English strings on
|
||||
the admin page.
|
||||
|
||||
### What's _not_ in this release (tracked for later)
|
||||
|
||||
- **Onboarding redesign** (dashboard-first empty-state flow + persistent
|
||||
Getting Started checklist) and the **typed `apiClient` wrapper** that
|
||||
underpins it are tracked for a focused 1.4.0 cycle. The 1.2.1 patch
|
||||
already closed the acute symptoms of #87 (silent-failure toast +
|
||||
default schedule), so the redesign is now a proper UI investment
|
||||
rather than a bug-fix.
|
||||
- **Withings sync is mapping both new measures**, but **a dedicated
|
||||
Bearer-auth ingest endpoint for external pipelines** (n8n + Health
|
||||
Connect, requested in #89) ships in 1.4.0 alongside the API-token
|
||||
flow.
|
||||
|
||||
## [1.2.1] — 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Onboarding**: Medications added during onboarding are now actually persisted (closes #87). The wizard previously sent an empty `schedules: []` array, the server-side validation rejected it with a 422, the client never checked `response.ok`, and the user was redirected to the dashboard as if everything had worked. Onboarding now wraps each step in `try/catch`, surfaces failures via toast, and attaches a default reminder window (`08:00–09:00 daily`) so the medication actually persists. A hint under the medication list explains the default.
|
||||
- **Docker setup** (closes #88):
|
||||
- `docker-compose.yml` now uses `ports: "3000:3000"` (was `expose: "3000"`, which made the app unreachable from the host).
|
||||
- `POSTGRES_PASSWORD` is a single env var that both the Postgres service and `DATABASE_URL` interpolate, so they cannot drift apart.
|
||||
- `.env.example` now points at the in-container hostname `db:5432` (was `localhost:5432`, which never resolves inside the app container).
|
||||
- **Documentation**:
|
||||
- `package.json` synced to 1.2.0 (was lagging on 1.1.0).
|
||||
- `CLAUDE.md` and `AGENTS.md` corrected to 23 models (the `Feedback` model added in v1.2 was missing from the count).
|
||||
- `README.md` Quick Start gives a realistic time estimate, generates the four secrets in one block straight into `.env`, and points reverse-proxy users at the docs.
|
||||
|
||||
### Added — Tooling & Supply Chain
|
||||
|
||||
- **Pre-built multi-arch images on GHCR**: `.github/workflows/docker-publish.yml` now builds `linux/amd64` + `linux/arm64` images on every push to `main` and on every `v*` tag, publishing to `ghcr.io/mbombeck/healthlog`. Self-hosters no longer need a build toolchain — `docker compose pull && docker compose up -d` is enough. The bundled `docker-compose.yml` references the published image with a `build:` block as fallback for contributors.
|
||||
- **Supply-chain attestations**: each published image carries a SLSA build provenance statement and a Software Bill of Materials. `SECURITY.md` documents how to verify them and how to pin a specific version.
|
||||
- **Documentation single source of truth**: `getting-started/installation.mdx` is now the canonical setup guide (mirrors the bundled `docker-compose.yml`); `self-hosting/docker.mdx` slimmed to image internals + ops notes only. The landing page's Quick Start terminal block now includes the secrets-generation step (was missing).
|
||||
|
||||
### Notes
|
||||
|
||||
This is a patch release that closes the install/onboarding friction reported in #87 and #88. The bigger user-facing changes (additional measurement types like total body water and bone mass per #89, full onboarding redesign, typed API client) are tracked for `1.3.0`.
|
||||
|
||||
## [1.2.0] — 2026-04-18
|
||||
|
||||
### Added — Personalization, Glucose & Multi-Provider AI
|
||||
|
||||
@@ -36,7 +36,7 @@ docker compose logs -f app # Tail app logs
|
||||
## Architecture
|
||||
|
||||
- **Next.js 16** App Router with TypeScript strict. Pages are RSC by default; `"use client"` only for interactivity.
|
||||
- **Prisma 7** ORM with PostgreSQL (22 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma).
|
||||
- **Prisma 7** ORM with PostgreSQL (23 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma).
|
||||
- **shadcn/ui** components (new-york style) in `src/components/ui/`. Add new ones via `pnpm dlx shadcn@latest add <component>`.
|
||||
- **Dracula theme** via CSS variables in `globals.css`. Dark mode is default. Use `--dracula-*` tokens for chart colors.
|
||||
- **TanStack Query** for client-side data fetching. Provider in `src/components/providers.tsx`.
|
||||
@@ -81,7 +81,7 @@ docker compose logs -f app # Tail app logs
|
||||
- `src/lib/validations/` — Zod schemas shared between API + client
|
||||
- `src/hooks/` — React hooks (`use-auth`)
|
||||
- `messages/de.json` + `messages/en.json` — i18n translations
|
||||
- `prisma/schema.prisma` — database schema (21 models)
|
||||
- `prisma/schema.prisma` — database schema (23 models)
|
||||
- `prisma.config.ts` — Prisma config (DB URL here, not in schema)
|
||||
- `public/sw.js` — Service worker for Web Push notifications + offline caching
|
||||
- `docs/` — architecture, security, assumptions, ops, API docs, ADRs
|
||||
|
||||
@@ -37,7 +37,7 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
## Key Features
|
||||
|
||||
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, and blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L) with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018 and ADA 2024 guidelines.
|
||||
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, and bone mass with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018 and ADA 2024 guidelines. Body-composition metrics sync automatically from Withings Body+ scales.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -67,21 +67,24 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
## 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 required secrets:
|
||||
Generate the four required secrets and paste them into `.env`:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32 # SESSION_SECRET
|
||||
openssl rand -hex 32 # ENCRYPTION_KEY
|
||||
openssl rand -hex 32 # API_TOKEN_HMAC_KEY
|
||||
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
|
||||
```
|
||||
|
||||
Add them to `.env`, then:
|
||||
Then bring the stack up:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
@@ -89,25 +92,27 @@ 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 (22 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) |
|
||||
| Layer | Technology |
|
||||
| ------------- | ------------------------------------------------- |
|
||||
| Framework | Next.js 16 (App Router, React Server Components) |
|
||||
| Language | TypeScript (strict mode) |
|
||||
| Database | PostgreSQL 16 + Prisma 7 (23 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) |
|
||||
|
||||
---
|
||||
|
||||
@@ -130,23 +135,24 @@ HealthLog is designed for people who take data ownership seriously.
|
||||
|
||||
### Required
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `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 |
|
||||
| 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 |
|
||||
| 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.
|
||||
|
||||
@@ -205,60 +211,60 @@ All mutations require authentication via session cookie. External ingest uses Be
|
||||
<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 |
|
||||
| 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 |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows |
|
||||
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
|
||||
| `POST` | `/api/insights/generate` | Regenerate AI insights |
|
||||
| `GET` | `/api/gamification/achievements` | Achievement progress |
|
||||
| `GET` | `/api/health` | Docker health check |
|
||||
| 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 |
|
||||
| `POST` | `/api/auth/passkey/*` | WebAuthn flows |
|
||||
| `GET` | `/api/withings/connect` | Initiate Withings OAuth |
|
||||
| `POST` | `/api/insights/generate` | Regenerate AI insights |
|
||||
| `GET` | `/api/gamification/achievements` | Achievement progress |
|
||||
| `GET` | `/api/health` | Docker health check |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -266,16 +272,16 @@ All mutations require authentication via session cookie. External ingest uses Be
|
||||
|
||||
## 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 |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+15
-1
@@ -5,7 +5,7 @@
|
||||
Only the latest release on the `main` branch is actively supported with security updates.
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| ------- | --------- |
|
||||
| Latest | Yes |
|
||||
| Older | No |
|
||||
|
||||
@@ -59,3 +59,17 @@ HealthLog is designed with security as a core principle:
|
||||
- **Proxy-level route protection** via `proxy.ts` (session cookie validation on all non-public paths)
|
||||
|
||||
For more details, see the [security documentation](https://docs.healthlog.dev/security/overview/).
|
||||
|
||||
## Supply Chain
|
||||
|
||||
HealthLog Docker images are built and published from this repository's CI:
|
||||
|
||||
- **Source**: [`.github/workflows/docker-publish.yml`](.github/workflows/docker-publish.yml) — runs on every push to `main` and every `v*` tag
|
||||
- **Registry**: [`ghcr.io/mbombeck/healthlog`](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog)
|
||||
- **Architectures**: `linux/amd64` and `linux/arm64`
|
||||
- **Provenance attestation**: each image carries a [SLSA build provenance](https://slsa.dev/spec/v1.0/provenance) statement linking it back to the GitHub Actions run, the commit SHA, and the workflow definition
|
||||
- **SBOM**: each image includes a [Software Bill of Materials](https://docs.docker.com/build/metadata/attestations/sbom/) you can inspect with `docker buildx imagetools inspect ghcr.io/mbombeck/healthlog:latest --format '{{ json .SBOM }}'`
|
||||
|
||||
To pin to a specific version in production, replace `:latest` with the released tag in your `docker-compose.yml`, e.g. `ghcr.io/mbombeck/healthlog:1.2.0`. Pinning is recommended for self-hosters who want explicit control over upgrades.
|
||||
|
||||
If you discover a tampered or unexpected image, please report it as a vulnerability via the email above.
|
||||
+19
-7
@@ -1,17 +1,29 @@
|
||||
services:
|
||||
app:
|
||||
# Pre-built multi-arch images are published to GHCR by
|
||||
# .github/workflows/docker-publish.yml on every push to main and on
|
||||
# tagged releases. Pin to a specific version in production:
|
||||
# e.g. ghcr.io/mbombeck/healthlog:1.2.0
|
||||
image: ghcr.io/mbombeck/healthlog:latest
|
||||
# Fallback build context: if the image is not yet in the registry,
|
||||
# or you want to test local changes, run `docker compose up --build`.
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: healthlog-app
|
||||
expose:
|
||||
- "3000"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://healthlog:healthlog@db:5432/healthlog?schema=public"
|
||||
# Plain ${VAR} interpolation only — no ${VAR:?error} validation
|
||||
# syntax. Some hosting platforms (e.g. Coolify) parse compose files
|
||||
# eagerly and store the *fallback error string* as the literal value
|
||||
# when the env var is unset, which then breaks the deployment.
|
||||
# Validation is enforced by docker-entrypoint.sh before the app starts.
|
||||
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
|
||||
NODE_ENV: production
|
||||
SESSION_SECRET: "${SESSION_SECRET:?SESSION_SECRET is required}"
|
||||
ENCRYPTION_KEY: "${ENCRYPTION_KEY:?ENCRYPTION_KEY is required}"
|
||||
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY:?API_TOKEN_HMAC_KEY is required}"
|
||||
SESSION_SECRET: "${SESSION_SECRET}"
|
||||
ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
|
||||
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}"
|
||||
APP_URL: "${APP_URL:-http://localhost:3000}"
|
||||
depends_on:
|
||||
@@ -24,7 +36,7 @@ services:
|
||||
container_name: healthlog-db
|
||||
environment:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: healthlog
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||||
POSTGRES_DB: healthlog
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
+21
-1
@@ -1,6 +1,26 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# ── Validate required env vars ───────────────────────────────
|
||||
# We do this in the entrypoint instead of in docker-compose.yml's
|
||||
# environment block (which used to use the ${VAR:?error} syntax)
|
||||
# because some hosting platforms parse compose files eagerly and
|
||||
# end up storing the fallback error string as the literal value.
|
||||
# Validating here guarantees a clear, deploy-platform-agnostic
|
||||
# failure message before we touch the database.
|
||||
missing=""
|
||||
for var in DATABASE_URL SESSION_SECRET ENCRYPTION_KEY API_TOKEN_HMAC_KEY; do
|
||||
eval "value=\$$var"
|
||||
if [ -z "$value" ]; then
|
||||
missing="$missing $var"
|
||||
fi
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
echo "HealthLog: ERROR — required env vars not set:$missing" >&2
|
||||
echo "HealthLog: see .env.example for the full list and how to generate each value." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Wait for PostgreSQL ──────────────────────────────────────
|
||||
MAX_RETRIES=30
|
||||
RETRY=0
|
||||
@@ -15,7 +35,7 @@ until node -e "
|
||||
" 2>/dev/null; do
|
||||
RETRY=$((RETRY + 1))
|
||||
if [ "$RETRY" -ge "$MAX_RETRIES" ]; then
|
||||
echo "HealthLog: ERROR — Database not reachable after ${MAX_RETRIES}s, aborting."
|
||||
echo "HealthLog: ERROR — Database not reachable after ${MAX_RETRIES}s, aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
# HealthLog Audit-Plan — Phasen-Übersicht
|
||||
**Datum:** 2026-04-26
|
||||
**Audit-Bericht:** `~/infra/reports/healthlog-audit-2026-04-26.md`
|
||||
**Status:** Geplant, noch nicht in Execution
|
||||
|
||||
## Strategische Decisions
|
||||
|
||||
1. **Onboarding-Redesign:** 4-Step-Wizard → 2-Step + Empty-State-Dashboard + Getting-Started-Card.
|
||||
2. **Custom-Measurement-Types:** Pragmatic Hardcoding (jetzt #89), Cronometer-Pattern als v1.5+-Roadmap.
|
||||
3. **`apiClient`-Refactor vor Onboarding-Redesign**, damit neues Onboarding nicht denselben Silent-Failure-Bug reproduziert.
|
||||
|
||||
---
|
||||
|
||||
## Phasen-Sequenz (10 Phasen)
|
||||
|
||||
| # | Phase | Skill | Effort | Issue-Bezug |
|
||||
|---|---|---|---|---|
|
||||
| **1** | Setup-Notfall + Versions-Sync | `gsd:quick` | ≤4 h | #87 (Pflaster), #88 |
|
||||
| **2** | Docker-Image-Pipeline (GHCR-Push + Doc-Konsolidierung) | `gsd:plan-phase` | ~1 d | #88 |
|
||||
| **3** | Issue #89 — TBW + Bone Mass hardcoded | `gsd:plan-phase` | ~1 d | #89 |
|
||||
| **4** | `apiClient`-Wrapper + geteilte Zod-Schemas | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | #87 (strukturell) |
|
||||
| **3.5** | **Onboarding-Redesign** (2 Steps, TTFV <60s) | `gsd:research-phase` → `gsd:plan-phase` | ~2 d | #87 (strukturell) |
|
||||
| **4.5** | Empty-State-Dashboard-Tiles (eine pro Type) | `gsd:plan-phase` | ~1 d | #87, #89 |
|
||||
| **4.6** | „Getting Started"-Checklist auf Dashboard | `gsd:plan-phase` | ~4 h | #87 |
|
||||
| **5** | Security + Reliability Hardening | `gsd:plan-phase` | 1-2 d | — |
|
||||
| **6** | Test-Coverage-Boost (Auth/Passkey/Withings/pg-boss) | `gsd:plan-phase` | 1-2 d | — |
|
||||
| **7** | UX/i18n/a11y Polish (Residuals aus #73) | `gsd:quick` | ≤4 h | #73 (Residuals) |
|
||||
| **8** | Settings-Page-Splitting (3143 → Sub-Components) | `gsd:research-phase` → `gsd:plan-phase` | 2-3 d | — |
|
||||
|
||||
**Reihenfolge:** 1 → 2 → 3 → **4 (apiClient)** → **3.5 (Onboarding nutzt apiClient)** → 4.5 → 4.6 → 5 → 6 → 7 → 8.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Setup-Notfall (≤4 h)
|
||||
|
||||
**Goal:** Self-Hoster kann mit `git clone && docker compose up -d` tatsächlich starten + Issue #87 stoppt zu blocken.
|
||||
|
||||
- `docker-compose.yml`: `expose:3000` → `ports: "3000:3000"` (DX-002)
|
||||
- `docker-compose.yml`: `POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?required}"` (SEC-003)
|
||||
- `.env.example`: `localhost:5432` → `db:5432` (DX-003)
|
||||
- `package.json`: `"version": "1.2.0"` (DOC-007)
|
||||
- `CLAUDE.md` + `AGENTS.md`: 23 Models statt 21/22 (DOC-001)
|
||||
- `src/app/onboarding/page.tsx`: `await fetch(...)` mit `.ok`-Check + `toast.error()` (DX-005 Pflaster)
|
||||
- README „minutes"-Claim entschärfen (DX-006)
|
||||
|
||||
**Verification:** Frischer Clone + neue VM → `cp .env.example .env && set secrets && docker compose up -d` → App auf `http://localhost:3000` erreichbar in <5 min.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Docker-Image-Pipeline (~1 d)
|
||||
|
||||
**Goal:** Issue #88 vollständig schließen. Pre-built Image auf GHCR. Docs konsolidiert.
|
||||
|
||||
- `.github/workflows/docker-publish.yml`: Multi-Arch (amd64+arm64), Trigger auf Tag-Push + main, GHCR-Push mit GITHUB_TOKEN
|
||||
- `healthlog-docs/getting-started/installation.mdx` → Verweis auf `self-hosting/docker.mdx` statt Duplikat (DX-004)
|
||||
- Repo-`docker-compose.yml` vs Docs-`docker-compose.yml`: eine Variante als Single-Source-of-Truth (Compose verwendet `image:` aus GHCR; Doc-Snippet wird daraus generiert)
|
||||
- `SECURITY.md` + Image-Pull-Instructions
|
||||
- `healthlog-landing` prüfen ob „Quick Start" aktualisiert werden muss
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Issue #89: TBW + Bone Mass (~1 d)
|
||||
|
||||
**Goal:** User-Anfrage von Manschk3rl beantworten. Blueprint = v1.2-Glucose-Migration.
|
||||
|
||||
**Touchpoints:**
|
||||
1. `prisma/schema.prisma`: `MeasurementType` Enum + 2 neue Werte
|
||||
2. `prisma/migrations/0019_*` (additive `ALTER TYPE ... ADD VALUE`)
|
||||
3. `src/lib/validations/measurement.ts`: `unitMap`, `VALUE_RANGES`
|
||||
4. `src/lib/format.ts` + i18n-Keys (4-6 neue Keys)
|
||||
5. `src/lib/doctor-report-pdf.ts`: `TYPE_LABEL_KEYS`, `TYPE_UNIT_KEYS`, `vitalTypes`
|
||||
6. `src/components/measurements/measurement-form.tsx`: `MEASUREMENT_TYPES` Array
|
||||
7. `src/lib/withings/client.ts` + `sync.ts`: Mapping aus Withings `body_composition`
|
||||
8. `src/app/api/dashboard/widgets/route.ts` + `DEFAULT_DASHBOARD_LAYOUT`: 2 neue Widget-IDs
|
||||
9. healthlog-docs `health-metrics.mdx` aktualisieren
|
||||
10. Optional: `/api/ingest/measurements` Bearer-Endpoint für n8n (analog `/api/ingest/medication`)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — `apiClient`-Wrapper (2-3 d)
|
||||
|
||||
**Goal:** Silent-Failure-Pattern strukturell eliminieren. Voraussetzung für 3.5.
|
||||
|
||||
- `src/lib/api-client.ts`: `apiClient.{get,post,put,delete}<T>(path, body?, schema?) → Result<T, ApiError>`
|
||||
- Auto-`.ok`-Check + Toast bei Fehler (mit i18n-Mapping)
|
||||
- Geteilte Zod-Schemas: Validations-Module in `src/lib/validations/` werden client-importable
|
||||
- Migration: alle ~50 `fetch(...)`-Aufrufe in `src/app/` und `src/components/` → `apiClient.*`
|
||||
- Tests für `apiClient` (Mock-Fetch, Error-Mapping, Schema-Validation)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3.5 — Onboarding-Redesign (~2 d)
|
||||
|
||||
**Goal:** TTFV <60s, 90% Completion (statt aktuell ~8% global).
|
||||
|
||||
**Neuer Flow:**
|
||||
- Step 1: Welcome + Anzeigename (optional) + Einheiten + Größe (optional). Sekundärlink „Skip everything → Dashboard".
|
||||
- Step 2: Multi-Select-Chips „Was willst du tracken?" (Weight/BP/Pulse/Mood/Glucose/Sleep/Steps/BodyFat/TBW/BoneMass/Medications). Pre-checked Defaults. Speichert in `User.dashboardWidgetsJson`.
|
||||
- Routing nach Onboarding: direkt zum Dashboard mit „Getting Started"-Card (s. Phase 4.6).
|
||||
|
||||
**Entfällt ersatzlos:** Targets-Step, Notifications-Teaser-Step, Front-loaded-Med-Wizard.
|
||||
|
||||
**Permissions deferred:** Notifications nur bei „Add medication → Reminder ein", PWA-Install nur bei Day-2-Return-Visit oder nach 3. Eintrag.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4.5 — Empty-State-Dashboard-Tiles (~1 d)
|
||||
|
||||
**Goal:** Mealie/Tandoor/Linear-Pattern. Jede Metric-Tile hat Empty-State mit „Add your first X"-CTA.
|
||||
|
||||
- `src/components/dashboard/EmptyStateTile.tsx` als wiederverwendbare Komponente
|
||||
- Pro `MeasurementType`: eigene Empty-State-Card (Icon + Headline + CTA-Button → opens MeasurementForm prefilled)
|
||||
- `Medications` analog: Empty-State „Add your first medication" → opens MedicationForm mit Default-Schedule (08:00–09:00 daily)
|
||||
- Dashboard-Routing: User mit `dashboardWidgetsJson` ohne Daten sieht Empty-States, nicht leeres Grid
|
||||
|
||||
---
|
||||
|
||||
## Phase 4.6 — „Getting Started"-Checklist (~4 h)
|
||||
|
||||
**Goal:** Linear-Pattern. Persistent dismissible Card auf Dashboard.
|
||||
|
||||
- `User.gettingStartedDismissedAt: DateTime?` (nullable, NULL = noch sichtbar)
|
||||
- 4 Items mit Live-Status:
|
||||
- ☐ Log your first measurement (✓ wenn ≥1 Measurement existiert)
|
||||
- ☐ Add a medication (✓ wenn ≥1 Medication existiert)
|
||||
- ☐ Set a target value (✓ wenn `thresholdsJson` non-empty)
|
||||
- ☐ Install HealthLog as App (✓ wenn `beforeinstallprompt` accepted, sonst nur sichtbar bei PWA-fähigen Browsern)
|
||||
- „Dismiss"-Button → setzt Timestamp, Card verschwindet
|
||||
- Vollständig durchgeklickt → Card verschwindet automatisch
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Security + Reliability Hardening (1-2 d)
|
||||
|
||||
- SEC-002: SSRF-IPv4-Parser-Härtung (führende Nullen)
|
||||
- SEC-004: GitHub-PAT-Logging-Sanitization (Sensitive-Field-Filter)
|
||||
- SEC-005: AES-256-GCM Key-Rotation-Doc + optional `keyVersion`
|
||||
- SEC-006: Custom-Thresholds-Rate-Limit
|
||||
- PERF-001: N+1 in `insights/comprehensive/route.ts:219` per `groupBy` auflösen
|
||||
- REL-002: Onboarding-Error-Handling über Pflaster hinaus (via apiClient aus Phase 4)
|
||||
- REL-003: pg-boss `process.on('SIGTERM', ...)` für graceful shutdown
|
||||
- LOG-001: 6× `console.log()` in `src/lib/jobs/reminder-worker.ts` → Wide-Event-Annotations
|
||||
- CI: ESLint+Typecheck `continue-on-error: true` → blocking
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Test-Coverage-Boost (1-2 d)
|
||||
|
||||
Coverage 35% → 60% auf kritischen Pfaden.
|
||||
|
||||
**Top-5-Test-Suites:**
|
||||
1. Session-Validation Roundtrip (`src/lib/auth/session.ts`)
|
||||
2. Passkey-Register/Verify-Integration (Attestation-Flow)
|
||||
3. Notification-Dispatcher Multi-Channel (TELEGRAM/NTFY/WEB_PUSH)
|
||||
4. pg-boss Reminder-Worker (Queue-Init, Job-Execution, Cron, Retry)
|
||||
5. Withings-OAuth + Sync (`exchangeCode` → `refresh` → `fetchMeasurements`)
|
||||
|
||||
**Plus:** TEST-006 Custom-Thresholds-Resolution, TEST-007 AI-Provider-SSRF.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — UX/i18n/a11y Polish (≤4 h)
|
||||
|
||||
**Residuals aus Issue #73 + neue Findings:**
|
||||
- I18N-002: Admin-Page Hardcoded „Web Push" / „Bug Report" → `t(...)`
|
||||
- I18N-003: `medication-card.tsx` `Intl.DateTimeFormat("en-CA")` und `toLocaleString("en-US")` → `useFormatters()`
|
||||
- A11Y-001: `phase-config-dialog.tsx` Text-Indicator neben Farb-Punkt (WCAG 1.4.1)
|
||||
- A11Y-002: Admin StatusItem `aria-label` verifizieren
|
||||
- UX-002: Admin-Save-Error-UX (Field-Highlight bei Fehler)
|
||||
- UX-003: Bottom-Nav-Touch-Targets `p-2.5` → `p-3` (≥44×44 px)
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Settings-Page-Splitting (2-3 d)
|
||||
|
||||
**Goal:** 3143 Zeilen → Sub-Components mit klarer Boundary.
|
||||
|
||||
- Research-Phase: Sub-Routes (`/settings/profile`, `/settings/notifications`, …) vs Sub-Components in einer Page?
|
||||
- Plan-Phase: Migrationsstrategie ohne URL-Bruch
|
||||
- Insights-Page (1750 Zeilen) folgt analogem Pattern
|
||||
|
||||
---
|
||||
|
||||
## Issue-Vorschlags-Abdeckung
|
||||
|
||||
### Issue #87 (Onboarding-Meds verschwinden)
|
||||
- **Pflaster:** Phase 1 (`.ok`-Check + Toast)
|
||||
- **Strukturelle Lösung:** Phase 4 (`apiClient`) + Phase 3.5 (neuer Flow ohne Med-Wizard) + Phase 4.5 (Empty-State-Tiles, Meds direkt sichtbar) + Phase 4.6 (Checklist)
|
||||
|
||||
### Issue #88 (Setup holprig — 4 Hauptpunkte + 3 Verbesserungsvorschläge)
|
||||
| #88 Punkt | Phase | Status |
|
||||
|---|---|---|
|
||||
| (a) Bundled compose ≠ docs version | Phase 2 (DOC-005) | abgedeckt |
|
||||
| (b) ghcr.io 404 (kein Image gepusht) | Phase 2 (DX-001 GHA-Workflow) | abgedeckt |
|
||||
| (c) `.env.example` `localhost:5432` vs Compose `db:5432` | Phase 1 (DX-003) | abgedeckt |
|
||||
| (d) `expose:3000` statt `ports:3000:3000` | Phase 1 (DX-002) | abgedeckt |
|
||||
| Vorschlag: GHA für Image-Push (GHCR/Docker Hub) | Phase 2 | abgedeckt |
|
||||
| Vorschlag: Merge „Getting started > Installation" + „Self-Hosting > Docker" | Phase 2 (DX-004) | abgedeckt |
|
||||
| Vorschlag: Same presets across compose+docs+.env.example | Phase 1 + Phase 2 | abgedeckt |
|
||||
|
||||
### Issue #89 (TBW + Bone Mass)
|
||||
- Phase 3 hardcoded mit Withings-Mapping. Optional `/api/ingest/measurements` für n8n in Phase 3.
|
||||
|
||||
### Issue #73 (UX-Liste mit 8 Punkten — CLOSED in v1.2)
|
||||
| #73 Punkt | v1.2-Status | Residual? |
|
||||
|---|---|---|
|
||||
| 1. Passkey-Errors unklar | Resolved (Package C, typed errors) | TEST-002 (keine Tests) → Phase 6 |
|
||||
| 2. Number-Formatting (DE 70,5 vs US 70.5) | Resolved (Package B, locale-aware formatters) | I18N-003 in `medication-card.tsx` → Phase 7 |
|
||||
| 3. Chart-Labels „7P/30P/90P/All" | Resolved (Package A, „7 Pkt"/„7 pts") | — |
|
||||
| 4. Dashboard nicht real-time | Resolved (`invalidateKeys` durchgängig) | — |
|
||||
| 5. GitHub-Setup-Confusion | Resolved (Built-in Feedback in v1.2) | — |
|
||||
| 6. Built-in Feedback | Done in v1.2 | — |
|
||||
| 7. Admin-Save ohne Error-UX | Partial (Success-Msg ja, Error-Highlight schwach) | UX-002 → Phase 7 |
|
||||
| 8. Blood-Sugar | Done in v1.2 | — |
|
||||
|
||||
### Issue #74 (raw locale keys in UI)
|
||||
Resolved in v1.2.
|
||||
|
||||
---
|
||||
|
||||
## Time-to-First-Value (TTFV) Ziel
|
||||
|
||||
Nach Onboarding-Redesign + Empty-State-Pattern:
|
||||
- Login → Dashboard mit Empty-States: 2 Klicks
|
||||
- Klick auf „Add your first weight" → Form prefilled „now" → Submit: 30 Sek
|
||||
- **TTFV-Ziel: <60 Sekunden** (Branchenvergleich: führende Health-Apps 30-90s)
|
||||
|
||||
## Anti-Patterns explizit vermieden
|
||||
|
||||
- Permissions-Cold-Prompt (Notifications/PWA) auf Day 1
|
||||
- Front-loaded Targets vor erster Messung
|
||||
- Multi-Step-Wizard mit identischem Skip-Button
|
||||
- `fetch()` ohne `.ok`-Check (apiClient löst strukturell)
|
||||
- Mega-Page-Pattern (Phase 8 löst Settings 3143 Z.)
|
||||
- Custom-Measurement-Types vorzeitig (40-60h Wartung/Jahr für 50% Feature-Loss)
|
||||
+18
-46
@@ -89,6 +89,8 @@
|
||||
"typeBodyFat": "Körperfettanteil",
|
||||
"typeSleep": "Schlafdauer",
|
||||
"typeSteps": "Aktivität (Schritte)",
|
||||
"typeTotalBodyWater": "Gesamtkörperwasser",
|
||||
"typeBoneMass": "Knochenmasse",
|
||||
"unitSteps": "Schritte",
|
||||
"bmiUnderweight": "Untergewicht",
|
||||
"bmiNormal": "Normalgewicht",
|
||||
@@ -197,6 +199,8 @@
|
||||
"bloodPressureDia": "Blutdruck (Dia)",
|
||||
"pulse": "Puls",
|
||||
"bodyFat": "Körperfett",
|
||||
"sleep": "Schlaf",
|
||||
"steps": "Schritte",
|
||||
"bpInTarget": "BD im Zielbereich",
|
||||
"days30Short": "30T",
|
||||
"medications": "Medikamente",
|
||||
@@ -243,6 +247,8 @@
|
||||
"typeSleep": "Schlaf",
|
||||
"typeSteps": "Schritte",
|
||||
"typeBloodGlucose": "Blutzucker",
|
||||
"typeTotalBodyWater": "Gesamtkörperwasser",
|
||||
"typeBoneMass": "Knochenmasse",
|
||||
"glucoseContext": "Kontext",
|
||||
"glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)",
|
||||
"glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)",
|
||||
@@ -757,50 +763,6 @@
|
||||
"unsetExplanation": "Kein eigener Wert — berechneter Default aktiv.",
|
||||
"loadError": "Zielwerte konnten nicht geladen werden"
|
||||
},
|
||||
"targets": {
|
||||
"title": "Zielwerte",
|
||||
"subtitle": "Persönliche Referenzbereiche basierend auf deinem Profil",
|
||||
"introText": "Diese Zielwerte sind persönliche Referenzbereiche, die aus deinem Profil und anerkannten medizinischen Richtwerten abgeleitet werden. Sie helfen dir, deine wichtigsten Gesundheitswerte schnell einzuordnen und Entwicklungen früh zu erkennen. Die Bereiche werden für dich individuell berechnet, zum Beispiel anhand von Alter, Geschlecht und Körpergröße. Zu jedem Wert siehst du direkt, ob du im Zielbereich liegst oder darüber beziehungsweise darunter. So bekommst du eine kompakte Übersicht darüber, welche Zielwerte du aktuell gut einhältst und wo noch Potenzial besteht.",
|
||||
"current": "Aktuell",
|
||||
"average30d": "30-Tage-Durchschnitt:",
|
||||
"targetRange": "Zielbereich",
|
||||
"classification": "Bewertung",
|
||||
"source": "Quelle",
|
||||
"sourceLabel": "Quelle: {source}",
|
||||
"trend": "Trend",
|
||||
"weight": "Gewicht",
|
||||
"bloodPressure": "Blutdruck",
|
||||
"pulse": "Ruhepuls",
|
||||
"sleep": "Schlafdauer",
|
||||
"bmi": "BMI",
|
||||
"bodyFat": "Körperfett",
|
||||
"steps": "Schritte/Tag",
|
||||
"compliance": "Einnahmetreue",
|
||||
"compliancePerMedication": "Pro Medikament",
|
||||
"diastolic": "Diastolisch",
|
||||
"noProfile": "Bitte fülle dein Profil aus, um personalisierte Zielwerte zu erhalten.",
|
||||
"profileIncomplete": "Profil unvollständig",
|
||||
"profileIncompleteHeightAge": "Hinterlege deine Größe und dein Geburtsdatum im Profil, um personalisierte Zielbereiche zu erhalten.",
|
||||
"profileIncompleteHeight": "Hinterlege deine Größe im Profil, um den Gewichts-Zielbereich zu berechnen.",
|
||||
"profileIncompleteAge": "Hinterlege dein Geburtsdatum im Profil, um altersbasierte Zielbereiche zu erhalten.",
|
||||
"noMeasurementData": "Noch keine Messdaten vorhanden.",
|
||||
"noMeasurementYet": "Noch keine Messung vorhanden",
|
||||
"currentValue": "Aktuell: {value} {unit}",
|
||||
"targetRangeValue": "Zielbereich: {min}–{max} {unit}",
|
||||
"inTarget": "Im Zielbereich",
|
||||
"belowTarget": "{delta} {unit} unter Ziel",
|
||||
"aboveTarget": "{delta} {unit} über Ziel",
|
||||
"loadError": "Fehler beim Laden",
|
||||
"classificationOptimal": "Optimal",
|
||||
"classificationNormal": "Normal",
|
||||
"classificationElevated": "Erhöht",
|
||||
"classificationHigh": "Hoch",
|
||||
"classificationLow": "Niedrig",
|
||||
"glucoseFasting": "Glukose — nüchtern",
|
||||
"glucosePostprandial": "Glukose — postprandial",
|
||||
"glucoseRandom": "Glukose — beliebig",
|
||||
"glucoseBedtime": "Glukose — vor dem Schlafen"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Willkommen bei HealthLog!",
|
||||
"setupDescription": "Richte dein Profil ein, um loszulegen. Du kannst das auch später in den Einstellungen ändern.",
|
||||
@@ -819,7 +781,12 @@
|
||||
"targetsDescription": "Setze optionale Zielwerte für Blutdruck und Gewicht.",
|
||||
"targetSys": "Systolisch (mmHg)",
|
||||
"targetDia": "Diastolisch (mmHg)",
|
||||
"targetWeight": "Zielgewicht (kg)"
|
||||
"targetWeight": "Zielgewicht (kg)",
|
||||
"errorProfile": "Dein Profil konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"errorMedication": "Das Medikament „{name}\" konnte nicht angelegt werden. Bitte erneut versuchen oder später unter „Medikamente\" hinzufügen.",
|
||||
"errorTargets": "Deine Zielwerte konnten nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"errorGeneric": "Beim Abschluss des Setups ist etwas schiefgelaufen. Bitte erneut versuchen.",
|
||||
"medScheduleHint": "Wir legen ein Standard-Erinnerungsfenster an (08:00–09:00 täglich). Du kannst es jederzeit unter „Medikamente\" anpassen."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
@@ -1098,6 +1065,8 @@
|
||||
"telegramGlobal": "Telegram-Benachrichtigungen systemweit erlauben.",
|
||||
"ntfyGlobal": "ntfy-Benachrichtigungen systemweit erlauben.",
|
||||
"webPushGlobal": "Browser-Push-Benachrichtigungen systemweit erlauben.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug-Reports",
|
||||
"apiGlobal": "API-Endpunkte und API-Tokens systemweit erlauben.",
|
||||
"moodLogGlobal": "moodLog-Integration systemweit erlauben.",
|
||||
"sectionGeneral": "Allgemein",
|
||||
@@ -1450,7 +1419,6 @@
|
||||
"subtitle": "Sende eine Fehlermeldung, einen Feature-Wunsch oder eine Frage. Der Server-Administrator sieht deine Einreichung.",
|
||||
"loginRequired": "Bitte anmelden, um einen Bug zu melden.",
|
||||
"bugTitle": "Titel",
|
||||
"bugTitlePlaceholder": "Kurze Beschreibung des Problems",
|
||||
"description": "Beschreibung",
|
||||
"descriptionPlaceholder": "Beschreibe den Fehler möglichst genau. Was hast du getan? Was ist passiert? Was hättest du erwartet?",
|
||||
"screenshot": "Screenshot (optional)",
|
||||
@@ -1616,6 +1584,10 @@
|
||||
"profileIncompleteAge": "Trage dein Geburtsdatum im Profil ein, um altersspezifische Ziele zu aktivieren.",
|
||||
"profileIncompleteHeightAge": "Trage Körpergröße und Geburtsdatum im Profil ein, um personalisierte Ziele zu sehen.",
|
||||
"compliancePerMedication": "Einnahmetreue pro Medikament",
|
||||
"glucoseFasting": "Glukose — nüchtern",
|
||||
"glucosePostprandial": "Glukose — postprandial",
|
||||
"glucoseRandom": "Glukose — beliebig",
|
||||
"glucoseBedtime": "Glukose — vor dem Schlafen",
|
||||
"loadError": "Zielwerte konnten nicht geladen werden"
|
||||
},
|
||||
"passwordStrength": {
|
||||
|
||||
+18
-46
@@ -89,6 +89,8 @@
|
||||
"typeBodyFat": "Body fat",
|
||||
"typeSleep": "Sleep duration",
|
||||
"typeSteps": "Activity (steps)",
|
||||
"typeTotalBodyWater": "Total body water",
|
||||
"typeBoneMass": "Bone mass",
|
||||
"unitSteps": "steps",
|
||||
"bmiUnderweight": "Underweight",
|
||||
"bmiNormal": "Normal weight",
|
||||
@@ -197,6 +199,8 @@
|
||||
"bloodPressureDia": "Blood Pressure (Dia)",
|
||||
"pulse": "Pulse",
|
||||
"bodyFat": "Body Fat",
|
||||
"sleep": "Sleep",
|
||||
"steps": "Steps",
|
||||
"bpInTarget": "BP in Target",
|
||||
"days30Short": "30d",
|
||||
"medications": "Medications",
|
||||
@@ -243,6 +247,8 @@
|
||||
"typeSleep": "Sleep",
|
||||
"typeSteps": "Steps",
|
||||
"typeBloodGlucose": "Blood glucose",
|
||||
"typeTotalBodyWater": "Total body water",
|
||||
"typeBoneMass": "Bone mass",
|
||||
"glucoseContext": "Context",
|
||||
"glucoseContextFasting": "Fasting (≥8h without food)",
|
||||
"glucoseContextPostprandial": "Post-meal (2h after eating)",
|
||||
@@ -757,50 +763,6 @@
|
||||
"unsetExplanation": "No override — using computed default.",
|
||||
"loadError": "Could not load thresholds"
|
||||
},
|
||||
"targets": {
|
||||
"title": "Targets",
|
||||
"subtitle": "Personal reference ranges based on your profile",
|
||||
"introText": "These targets are personal reference ranges derived from your profile and established medical guidelines. They help you quickly classify your key health values and detect changes early. The ranges are calculated individually for you, for example based on age, sex, and height. For each value, you can immediately see whether you are within the target range or above or below it. This gives you a compact overview of which targets you are currently meeting well and where improvement is still possible.",
|
||||
"current": "Current",
|
||||
"average30d": "30-day average:",
|
||||
"targetRange": "Target range",
|
||||
"classification": "Rating",
|
||||
"source": "Source",
|
||||
"sourceLabel": "Source: {source}",
|
||||
"trend": "Trend",
|
||||
"weight": "Weight",
|
||||
"bloodPressure": "Blood Pressure",
|
||||
"pulse": "Resting Pulse",
|
||||
"sleep": "Sleep Duration",
|
||||
"bmi": "BMI",
|
||||
"bodyFat": "Body Fat",
|
||||
"steps": "Steps/Day",
|
||||
"compliance": "Compliance",
|
||||
"compliancePerMedication": "Per medication",
|
||||
"diastolic": "Diastolic",
|
||||
"noProfile": "Please complete your profile to get personalized targets.",
|
||||
"profileIncomplete": "Profile incomplete",
|
||||
"profileIncompleteHeightAge": "Add your height and date of birth in your profile to get personalized target ranges.",
|
||||
"profileIncompleteHeight": "Add your height in your profile to calculate weight target range.",
|
||||
"profileIncompleteAge": "Add your date of birth in your profile to get age-based target ranges.",
|
||||
"noMeasurementData": "No measurement data available yet.",
|
||||
"noMeasurementYet": "No measurement available yet",
|
||||
"currentValue": "Current: {value} {unit}",
|
||||
"targetRangeValue": "Target range: {min}–{max} {unit}",
|
||||
"inTarget": "In target range",
|
||||
"belowTarget": "{delta} {unit} below target",
|
||||
"aboveTarget": "{delta} {unit} above target",
|
||||
"loadError": "Error loading data",
|
||||
"classificationOptimal": "Optimal",
|
||||
"classificationNormal": "Normal",
|
||||
"classificationElevated": "Elevated",
|
||||
"classificationHigh": "High",
|
||||
"classificationLow": "Low",
|
||||
"glucoseFasting": "Glucose — fasting",
|
||||
"glucosePostprandial": "Glucose — post-meal",
|
||||
"glucoseRandom": "Glucose — random",
|
||||
"glucoseBedtime": "Glucose — bedtime"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Welcome to HealthLog!",
|
||||
"setupDescription": "Set up your profile to get started. You can change this later in Settings.",
|
||||
@@ -819,7 +781,12 @@
|
||||
"targetsDescription": "Set optional target values for blood pressure and weight.",
|
||||
"targetSys": "Systolic (mmHg)",
|
||||
"targetDia": "Diastolic (mmHg)",
|
||||
"targetWeight": "Target weight (kg)"
|
||||
"targetWeight": "Target weight (kg)",
|
||||
"errorProfile": "Could not save your profile. Please try again.",
|
||||
"errorMedication": "Could not add the medication \"{name}\". Please try again or add it later in Medications.",
|
||||
"errorTargets": "Could not save your target values. Please try again.",
|
||||
"errorGeneric": "Something went wrong while finishing setup. Please try again.",
|
||||
"medScheduleHint": "We'll add a default reminder window (08:00–09:00 daily). You can adjust it anytime in Medications."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
@@ -1098,6 +1065,8 @@
|
||||
"telegramGlobal": "Allow Telegram notifications system-wide.",
|
||||
"ntfyGlobal": "Allow ntfy notifications system-wide.",
|
||||
"webPushGlobal": "Allow browser push notifications system-wide.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug Report",
|
||||
"apiGlobal": "Allow API endpoints and API tokens system-wide.",
|
||||
"moodLogGlobal": "Allow moodLog integration system-wide.",
|
||||
"sectionGeneral": "General",
|
||||
@@ -1450,7 +1419,6 @@
|
||||
"subtitle": "Send a bug report, feature request, or question. The server administrator will see your submission.",
|
||||
"loginRequired": "Please sign in to report a bug.",
|
||||
"bugTitle": "Title",
|
||||
"bugTitlePlaceholder": "Brief description of the problem",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Describe the bug in detail. What did you do? What happened? What did you expect?",
|
||||
"screenshot": "Screenshot (optional)",
|
||||
@@ -1616,6 +1584,10 @@
|
||||
"profileIncompleteAge": "Add your date of birth in profile settings for age-specific targets.",
|
||||
"profileIncompleteHeightAge": "Add your height and date of birth in profile settings for personalized targets.",
|
||||
"compliancePerMedication": "Compliance per medication",
|
||||
"glucoseFasting": "Glucose — fasting",
|
||||
"glucosePostprandial": "Glucose — post-meal",
|
||||
"glucoseRandom": "Glucose — random",
|
||||
"glucoseBedtime": "Glucose — bedtime",
|
||||
"loadError": "Failed to load targets"
|
||||
},
|
||||
"passwordStrength": {
|
||||
|
||||
+16
-16
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.1.0",
|
||||
"version": "1.3.2",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
@@ -22,25 +22,25 @@
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@prisma/adapter-pg": "^7.7.0",
|
||||
"@prisma/client": "^7.7.0",
|
||||
"@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.99.0",
|
||||
"@tanstack/react-query": "^5.100.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.4.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"jspdf": "^4.2.1",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "16.2.3",
|
||||
"lucide-react": "^1.11.0",
|
||||
"next": "16.2.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg-boss": "^12.15.0",
|
||||
"prisma": "^7.7.0",
|
||||
"pg-boss": "^12.18.1",
|
||||
"prisma": "^7.8.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-dom": "19.2.5",
|
||||
"react-hook-form": "^7.74.0",
|
||||
"recharts": "^3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
@@ -56,14 +56,14 @@
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.3",
|
||||
"prettier": "^3.8.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"shadcn": "^4.2.0",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.7.3",
|
||||
"shadcn": "^4.5.0",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^6",
|
||||
"vitest": "^4.1.4"
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
Generated
+970
-993
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,15 @@
|
||||
-- v1.3: Add body-composition measurement types
|
||||
--
|
||||
-- Closes #89: total body water and bone mass as first-class measurement types.
|
||||
-- Both are reported by Withings body-composition scales and can also be ingested
|
||||
-- via the API by external pipelines (e.g. n8n + Health Connect).
|
||||
--
|
||||
-- Storage units (canonical):
|
||||
-- TOTAL_BODY_WATER: percent of body weight (%)
|
||||
-- BONE_MASS: kilograms (kg)
|
||||
--
|
||||
-- Both are unconditional value types — no companion enum (unlike BLOOD_GLUCOSE,
|
||||
-- which carries a glucose_context). The migration is purely additive.
|
||||
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'TOTAL_BODY_WATER';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BONE_MASS';
|
||||
@@ -150,6 +150,8 @@ enum MeasurementType {
|
||||
SLEEP_DURATION
|
||||
ACTIVITY_STEPS
|
||||
BLOOD_GLUCOSE // Canonical storage: mg/dL. Display unit (mg/dL or mmol/L) is user preference.
|
||||
TOTAL_BODY_WATER // Canonical storage: kilograms (kg of water). Matches Withings' hydration measure (type 77) and Health Connect's TotalBodyWaterRecord. Display can compute % from same-day weight if desired.
|
||||
BONE_MASS // Canonical storage: kilograms (kg). Matches Withings' bone mass measure (type 88).
|
||||
|
||||
@@map("measurement_type")
|
||||
}
|
||||
|
||||
+31
-18
@@ -378,7 +378,7 @@ function SystemStatusSection({ id }: { id: string }) {
|
||||
{status.integrations.webPush && (
|
||||
<StatusItem
|
||||
icon={BellRing}
|
||||
label="Web Push"
|
||||
label={t("admin.integrationWebPush")}
|
||||
value={t("admin.configured")}
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
@@ -386,7 +386,7 @@ function SystemStatusSection({ id }: { id: string }) {
|
||||
{status.integrations.bugReport && (
|
||||
<StatusItem
|
||||
icon={Bug}
|
||||
label="Bug Report"
|
||||
label={t("admin.integrationBugReport")}
|
||||
value={t("admin.configured")}
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
@@ -518,7 +518,7 @@ function ServicesSection({ id }: { id: string }) {
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label="Web Push"
|
||||
label={t("admin.integrationWebPush")}
|
||||
description={t("admin.webPushGlobal")}
|
||||
icon={Globe}
|
||||
checked={settings?.webPushGlobal ?? true}
|
||||
@@ -2239,8 +2239,7 @@ function FeedbackInboxSection({ id }: { id: string }) {
|
||||
});
|
||||
const githubConfigured = Boolean(status?.integrations.bugReport?.configured);
|
||||
|
||||
const [activeStatus, setActiveStatus] =
|
||||
useState<FeedbackStatusType>("OPEN");
|
||||
const [activeStatus, setActiveStatus] = useState<FeedbackStatusType>("OPEN");
|
||||
const [selected, setSelected] = useState<FeedbackItem | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -2281,7 +2280,11 @@ function FeedbackInboxSection({ id }: { id: string }) {
|
||||
<TabsList>
|
||||
{STATUS_TABS.map((s) => (
|
||||
<TabsTrigger key={s} value={s}>
|
||||
<span>{t(`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`)}</span>
|
||||
<span>
|
||||
{t(
|
||||
`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`,
|
||||
)}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||||
{counts[s] ?? 0}
|
||||
</Badge>
|
||||
@@ -2328,7 +2331,7 @@ function FeedbackInboxSection({ id }: { id: string }) {
|
||||
{data.items.map((item, i) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={`cursor-pointer hover:bg-muted/40 ${i % 2 === 0 ? "bg-muted/30" : ""}`}
|
||||
className={`hover:bg-muted/40 cursor-pointer ${i % 2 === 0 ? "bg-muted/30" : ""}`}
|
||||
onClick={() => setSelected(item)}
|
||||
>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
@@ -2337,7 +2340,7 @@ function FeedbackInboxSection({ id }: { id: string }) {
|
||||
<td className="px-3 py-2">
|
||||
<FeedbackCategoryBadge category={item.category} />
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium truncate max-w-[28ch]">
|
||||
<td className="max-w-[28ch] truncate px-3 py-2 font-medium">
|
||||
{item.subject}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
@@ -2379,9 +2382,16 @@ function FeedbackInboxSection({ id }: { id: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackCategoryBadge({ category }: { category: FeedbackCategoryType }) {
|
||||
function FeedbackCategoryBadge({
|
||||
category,
|
||||
}: {
|
||||
category: FeedbackCategoryType;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const map: Record<FeedbackCategoryType, { label: string; className: string }> = {
|
||||
const map: Record<
|
||||
FeedbackCategoryType,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
BUG: {
|
||||
label: t("admin.feedback.categoryBug"),
|
||||
className: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
@@ -2400,7 +2410,9 @@ function FeedbackCategoryBadge({ category }: { category: FeedbackCategoryType })
|
||||
},
|
||||
};
|
||||
const cfg = map[category];
|
||||
return <Badge className={`text-xs border ${cfg.className}`}>{cfg.label}</Badge>;
|
||||
return (
|
||||
<Badge className={`border text-xs ${cfg.className}`}>{cfg.label}</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackDetailDialog({
|
||||
@@ -2487,11 +2499,12 @@ function FeedbackDetailDialog({
|
||||
const url = typeof meta.url === "string" ? meta.url : null;
|
||||
const locale = typeof meta.locale === "string" ? meta.locale : null;
|
||||
const userAgent = typeof meta.userAgent === "string" ? meta.userAgent : null;
|
||||
const appVersion = typeof meta.appVersion === "string" ? meta.appVersion : null;
|
||||
const appVersion =
|
||||
typeof meta.appVersion === "string" ? meta.appVersion : null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
@@ -2529,7 +2542,7 @@ function FeedbackDetailDialog({
|
||||
|
||||
{(url || locale || userAgent || appVersion) && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<h4 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.metadataHeading")}
|
||||
</h4>
|
||||
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
@@ -2571,14 +2584,14 @@ function FeedbackDetailDialog({
|
||||
|
||||
{item.screenshotBase64 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
||||
<h4 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.screenshotHeading")}
|
||||
</h4>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.screenshotBase64}
|
||||
alt="Screenshot"
|
||||
className="max-h-72 rounded-md border border-border"
|
||||
className="border-border max-h-72 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -2611,7 +2624,7 @@ function FeedbackDetailDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-3">
|
||||
<div className="border-border flex flex-wrap items-center gap-2 border-t pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -2674,7 +2687,7 @@ function FeedbackDetailDialog({
|
||||
</Button>
|
||||
)}
|
||||
{issueUrl && (
|
||||
<Badge className="ml-auto border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green ml-auto">
|
||||
{t("admin.feedback.publishedToGithub")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -62,7 +62,6 @@ export const POST = apiHandler(
|
||||
subject: feedback.subject,
|
||||
description: feedback.description,
|
||||
username: feedback.user?.username ?? "anonymous",
|
||||
hasScreenshot: Boolean(feedback.screenshotBase64),
|
||||
metadata: feedback.metadata,
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ const widgetIdEnum = z.enum([
|
||||
"sleep",
|
||||
"steps",
|
||||
"glucose",
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"bpInTarget",
|
||||
]);
|
||||
|
||||
|
||||
@@ -216,15 +216,30 @@ export const GET = apiHandler(async () => {
|
||||
const bpMedications = medications.filter(
|
||||
(med) => (categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE",
|
||||
);
|
||||
// Single round-trip for all medications instead of N+1: one query keyed
|
||||
// on `medicationId IN (...)`, then group in memory. The previous loop
|
||||
// hit Postgres once per medication, which scales poorly for users with
|
||||
// many active meds.
|
||||
const allEvents = medications.length
|
||||
? await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId: { in: medications.map((m) => m.id) },
|
||||
userId,
|
||||
scheduledFor: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const eventsByMed = new Map<string, typeof allEvents>();
|
||||
for (const ev of allEvents) {
|
||||
const list = eventsByMed.get(ev.medicationId);
|
||||
if (list) list.push(ev);
|
||||
else eventsByMed.set(ev.medicationId, [ev]);
|
||||
}
|
||||
|
||||
for (const med of medications) {
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId: med.id,
|
||||
userId,
|
||||
scheduledFor: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
});
|
||||
const events = eventsByMed.get(med.id) ?? [];
|
||||
const mapped = events.map((e) => ({
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { apiSuccess, apiError, safeJson, getClientIp } from "@/lib/api-response"
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
getAllEffectiveRanges,
|
||||
type ThresholdOverridesJson,
|
||||
@@ -59,6 +60,17 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.thresholds.update" } });
|
||||
|
||||
// 30 writes / 5 min — generous for legitimate UI edits, tight enough to
|
||||
// make audit-log enumeration unattractive. Per-user, not per-IP.
|
||||
const rl = await checkRateLimit(
|
||||
`thresholds:update:${user.id}`,
|
||||
30,
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests, please slow down", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -15,10 +15,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Loader2,
|
||||
Upload,
|
||||
X,
|
||||
CheckCircle2,
|
||||
ImageIcon,
|
||||
Bug,
|
||||
GitPullRequest,
|
||||
Info,
|
||||
@@ -46,14 +43,11 @@ export default function BugReportPage() {
|
||||
const [category, setCategory] = useState<CategoryValue>("BUG");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [screenshot, setScreenshot] = useState<string | null>(null);
|
||||
const [screenshotName, setScreenshotName] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<{
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Status is now informational only — submission always works.
|
||||
const { data: status } = useQuery({
|
||||
@@ -67,34 +61,6 @@ export default function BugReportPage() {
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
async function handleScreenshot(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setResult({ type: "error", message: t("bugreport.imageOnly") });
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setResult({ type: "error", message: t("bugreport.tooLarge") });
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setScreenshot(reader.result as string);
|
||||
setScreenshotName(file.name);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function removeScreenshot() {
|
||||
setScreenshot(null);
|
||||
setScreenshotName(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setResult(null);
|
||||
@@ -108,7 +74,6 @@ export default function BugReportPage() {
|
||||
category,
|
||||
subject: subject || description.slice(0, 60),
|
||||
description,
|
||||
...(screenshot ? { screenshot } : {}),
|
||||
metadata: {
|
||||
locale,
|
||||
userAgent:
|
||||
@@ -124,7 +89,6 @@ export default function BugReportPage() {
|
||||
setResult({ type: "success", message: t("bugreport.success") });
|
||||
setSubject("");
|
||||
setDescription("");
|
||||
removeScreenshot();
|
||||
} else {
|
||||
setResult({
|
||||
type: "error",
|
||||
@@ -226,42 +190,6 @@ export default function BugReportPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("bugreport.screenshot")}</Label>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleScreenshot}
|
||||
className="hidden"
|
||||
/>
|
||||
{screenshot ? (
|
||||
<div className="bg-muted/50 relative rounded-lg p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="text-muted-foreground h-4 w-4" />
|
||||
<span className="flex-1 truncate text-sm">
|
||||
{screenshotName}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={removeScreenshot}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={screenshot}
|
||||
alt={t("bugreport.screenshotPreview")}
|
||||
className="mt-2 max-h-48 rounded border object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div
|
||||
className={`rounded-lg p-3 text-sm ${
|
||||
@@ -277,16 +205,7 @@ export default function BugReportPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t("bugreport.attachScreenshot")}
|
||||
</Button>
|
||||
<div className="flex items-center justify-end pt-2">
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<Bug className="mr-2 h-4 w-4" />
|
||||
|
||||
+87
-51
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -39,45 +40,63 @@ export default function OnboardingPage() {
|
||||
async function finishOnboarding() {
|
||||
setSaving(true);
|
||||
|
||||
const body: Record<string, unknown> = {};
|
||||
if (heightCm) body.heightCm = parseFloat(heightCm);
|
||||
if (dateOfBirth) body.dateOfBirth = dateOfBirth;
|
||||
if (gender) body.gender = gender;
|
||||
try {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (heightCm) body.heightCm = parseFloat(heightCm);
|
||||
if (dateOfBirth) body.dateOfBirth = dateOfBirth;
|
||||
if (gender) body.gender = gender;
|
||||
|
||||
await fetch("/api/onboarding/complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
// Create medications if any
|
||||
for (const med of meds) {
|
||||
await fetch("/api/medications", {
|
||||
const profileRes = await fetch("/api/onboarding/complete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
schedules: [],
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
if (!profileRes.ok) {
|
||||
throw new Error(t("onboarding.errorProfile"));
|
||||
}
|
||||
|
||||
// Save targets if set
|
||||
if (targetSys || targetDia || targetWeight) {
|
||||
const targets: Record<string, unknown> = {};
|
||||
if (targetSys) targets.bpSysTarget = parseInt(targetSys);
|
||||
if (targetDia) targets.bpDiaTarget = parseInt(targetDia);
|
||||
if (targetWeight) targets.weightTarget = parseFloat(targetWeight);
|
||||
await fetch("/api/insights/targets", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(targets),
|
||||
});
|
||||
}
|
||||
// Create medications. Server validation requires at least one schedule,
|
||||
// so we attach a sensible default — the user is informed via medScheduleHint
|
||||
// and can refine it in Medications later. Phase 3.5 of the audit replaces
|
||||
// this entire wizard with an empty-state-driven flow.
|
||||
for (const med of meds) {
|
||||
const medRes = await fetch("/api/medications", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
|
||||
}),
|
||||
});
|
||||
if (!medRes.ok) {
|
||||
throw new Error(t("onboarding.errorMedication", { name: med.name }));
|
||||
}
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
router.replace("/");
|
||||
if (targetSys || targetDia || targetWeight) {
|
||||
const targets: Record<string, unknown> = {};
|
||||
if (targetSys) targets.bpSysTarget = parseInt(targetSys);
|
||||
if (targetDia) targets.bpDiaTarget = parseInt(targetDia);
|
||||
if (targetWeight) targets.weightTarget = parseFloat(targetWeight);
|
||||
const targetsRes = await fetch("/api/insights/targets", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(targets),
|
||||
});
|
||||
if (!targetsRes.ok) {
|
||||
throw new Error(t("onboarding.errorTargets"));
|
||||
}
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||
router.replace("/");
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : t("onboarding.errorGeneric");
|
||||
toast.error(message);
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function addMedication() {
|
||||
@@ -191,27 +210,40 @@ export default function OnboardingPage() {
|
||||
placeholder={t("onboarding.medDosePlaceholder")}
|
||||
className="w-24"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addMedication}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addMedication}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
{meds.length > 0 && (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{meds.map((m, i) => (
|
||||
<li key={i} className="bg-muted/50 flex items-center justify-between rounded px-3 py-1.5">
|
||||
<span>
|
||||
{m.name} {m.dose && `(${m.dose})`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive text-xs"
|
||||
onClick={() => setMeds(meds.filter((_, j) => j !== i))}
|
||||
<>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{meds.map((m, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="bg-muted/50 flex items-center justify-between rounded px-3 py-1.5"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<span>
|
||||
{m.name} {m.dose && `(${m.dose})`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive text-xs"
|
||||
onClick={() => setMeds(meds.filter((_, j) => j !== i))}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.medScheduleHint")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -226,8 +258,12 @@ export default function OnboardingPage() {
|
||||
{t("onboarding.notificationsDescription")}
|
||||
</p>
|
||||
<div className="bg-muted/50 space-y-2 rounded-lg p-4 text-sm">
|
||||
<p>📱 <strong>Telegram</strong> — {t("onboarding.telegramHint")}</p>
|
||||
<p>🔔 <strong>Web Push</strong> — {t("onboarding.webPushHint")}</p>
|
||||
<p>
|
||||
📱 <strong>Telegram</strong> — {t("onboarding.telegramHint")}
|
||||
</p>
|
||||
<p>
|
||||
🔔 <strong>Web Push</strong> — {t("onboarding.webPushHint")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.notificationsLater")}
|
||||
|
||||
@@ -2939,8 +2939,15 @@ function UserAIProviderSubsection() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
// Sync-from-server pattern: form draft state is initialised from the
|
||||
// fetched user record. Could be reworked into a derived-state approach,
|
||||
// but that's a non-trivial refactor of this monolithic settings page —
|
||||
// tracked for the 1.4.0 settings split.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setProvider(data.provider ?? "");
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setModel(data.model ?? "");
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setBaseUrl(data.baseUrl ?? "");
|
||||
}, [data]);
|
||||
|
||||
|
||||
@@ -31,8 +31,11 @@ export function BottomNav() {
|
||||
const items = navItems;
|
||||
|
||||
return (
|
||||
<nav aria-label={t("nav.mobileNavigation")} className="bg-card/80 border-border fixed bottom-0 left-0 z-50 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden">
|
||||
<div className="mx-auto flex h-14 max-w-lg items-center justify-around px-2">
|
||||
<nav
|
||||
aria-label={t("nav.mobileNavigation")}
|
||||
className="bg-card/80 border-border fixed bottom-0 left-0 z-50 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden"
|
||||
>
|
||||
<div className="mx-auto flex h-16 max-w-lg items-stretch justify-around px-1">
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
item.href === "/"
|
||||
@@ -44,8 +47,11 @@ export function BottomNav() {
|
||||
href={item.href}
|
||||
aria-label={t(item.tKey)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
// Touch target sized to WCAG 2.5.5 minimum (44×44 CSS px). The
|
||||
// outer min-h-11 min-w-11 is the actual hit area; the icon
|
||||
// stays visually centered at 20px so the design doesn't shift.
|
||||
className={cn(
|
||||
"relative flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors",
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
@@ -53,7 +59,7 @@ export function BottomNav() {
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{isActive && (
|
||||
<span className="bg-primary absolute -bottom-1 h-1 w-1 rounded-full" />
|
||||
<span className="bg-primary absolute bottom-1.5 h-1 w-1 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -67,11 +67,26 @@ const MEASUREMENT_TYPES = [
|
||||
unit: "mg/dL",
|
||||
placeholder: "95",
|
||||
},
|
||||
{
|
||||
value: "TOTAL_BODY_WATER",
|
||||
labelKey: "measurements.typeTotalBodyWater",
|
||||
unit: "kg",
|
||||
placeholder: "42",
|
||||
},
|
||||
{
|
||||
value: "BONE_MASS",
|
||||
labelKey: "measurements.typeBoneMass",
|
||||
unit: "kg",
|
||||
placeholder: "3.2",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const GLUCOSE_CONTEXTS = [
|
||||
{ value: "FASTING", labelKey: "measurements.glucoseContextFasting" },
|
||||
{ value: "POSTPRANDIAL", labelKey: "measurements.glucoseContextPostprandial" },
|
||||
{
|
||||
value: "POSTPRANDIAL",
|
||||
labelKey: "measurements.glucoseContextPostprandial",
|
||||
},
|
||||
{ value: "RANDOM", labelKey: "measurements.glucoseContextRandom" },
|
||||
{ value: "BEDTIME", labelKey: "measurements.glucoseContextBedtime" },
|
||||
] as const;
|
||||
@@ -368,7 +383,11 @@ export function MeasurementForm({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div role="alert" aria-live="assertive" className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm">
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -78,6 +78,10 @@ function parseTimeToMinutes(value: string): number {
|
||||
}
|
||||
|
||||
function toBerlinDate(date: Date): Date {
|
||||
// Intentionally en-US: this is not user-facing display — it produces a
|
||||
// parseable string ("1/2/2026, 3:04:05 PM") that we feed back into Date
|
||||
// to shift the *value* from UTC to Berlin-local for arithmetic. Display
|
||||
// formatting goes through useFormatters() elsewhere.
|
||||
return new Date(
|
||||
date.toLocaleString("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
@@ -315,7 +319,11 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
// Don't show late/very_late if all overdue schedules are covered by events
|
||||
if (status !== "in_window" && !hasUncoveredOverdue) return best;
|
||||
// Don't show in_window if last intake is already within this window today
|
||||
if (status === "in_window" && isLastIntakeInCurrentWindow(medication.lastTakenAt, s, nowBerlin)) return best;
|
||||
if (
|
||||
status === "in_window" &&
|
||||
isLastIntakeInCurrentWindow(medication.lastTakenAt, s, nowBerlin)
|
||||
)
|
||||
return best;
|
||||
const priority = { in_window: 3, late: 2, very_late: 1 };
|
||||
if (!best.status || priority[status] > priority[best.status]) {
|
||||
return { status, schedule: s };
|
||||
@@ -327,6 +335,9 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
: { status: null, schedule: null };
|
||||
|
||||
function formatLastTakenAt(value: string): string {
|
||||
// Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and
|
||||
// string-comparable for the today / yesterday / older bucketing below.
|
||||
// The actual user-facing display goes through formatTime / formatDateTime.
|
||||
const dayFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
@@ -374,7 +385,13 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild aria-label={t("medications.intakeHistory")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
asChild
|
||||
aria-label={t("medications.intakeHistory")}
|
||||
>
|
||||
<Link href={`/medications/${medication.id}/history`}>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
|
||||
@@ -51,9 +51,24 @@ const PHASES: {
|
||||
dotClass: string;
|
||||
direction: "before" | "after";
|
||||
}[] = [
|
||||
{ key: "green", color: "green", dotClass: "bg-green-500", direction: "before" },
|
||||
{ key: "yellow", color: "yellow", dotClass: "bg-yellow-500", direction: "before" },
|
||||
{ key: "orange", color: "orange", dotClass: "bg-orange-500", direction: "after" },
|
||||
{
|
||||
key: "green",
|
||||
color: "green",
|
||||
dotClass: "bg-green-500",
|
||||
direction: "before",
|
||||
},
|
||||
{
|
||||
key: "yellow",
|
||||
color: "yellow",
|
||||
dotClass: "bg-yellow-500",
|
||||
direction: "before",
|
||||
},
|
||||
{
|
||||
key: "orange",
|
||||
color: "orange",
|
||||
dotClass: "bg-orange-500",
|
||||
direction: "after",
|
||||
},
|
||||
{ key: "red", color: "red", dotClass: "bg-red-500", direction: "after" },
|
||||
];
|
||||
|
||||
@@ -92,7 +107,9 @@ export function PhaseConfigDialog({
|
||||
},
|
||||
onSuccess: () => {
|
||||
setLocalForm(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["phase-config", medicationId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["phase-config", medicationId],
|
||||
});
|
||||
setStatusMessage(t("medications.phaseSaved"));
|
||||
setTimeout(() => setStatusMessage(null), 2000);
|
||||
},
|
||||
@@ -108,7 +125,9 @@ export function PhaseConfigDialog({
|
||||
},
|
||||
onSuccess: () => {
|
||||
setLocalForm(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["phase-config", medicationId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["phase-config", medicationId],
|
||||
});
|
||||
setStatusMessage(t("medications.phaseReset"));
|
||||
setTimeout(() => setStatusMessage(null), 2000);
|
||||
},
|
||||
@@ -129,10 +148,14 @@ export function PhaseConfigDialog({
|
||||
|
||||
const phaseLabel = (key: PhaseKey): string => {
|
||||
switch (key) {
|
||||
case "green": return t("medications.phaseGreen");
|
||||
case "yellow": return t("medications.phaseYellow");
|
||||
case "orange": return t("medications.phaseOrange");
|
||||
case "red": return t("medications.phaseRed");
|
||||
case "green":
|
||||
return t("medications.phaseGreen");
|
||||
case "yellow":
|
||||
return t("medications.phaseYellow");
|
||||
case "orange":
|
||||
return t("medications.phaseOrange");
|
||||
case "red":
|
||||
return t("medications.phaseRed");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -160,8 +183,11 @@ export function PhaseConfigDialog({
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<div className={`h-3 w-3 rounded-full ${dotClass} shrink-0`} />
|
||||
<span className="w-14 text-sm font-medium shrink-0">
|
||||
<div
|
||||
className={`h-3 w-3 rounded-full ${dotClass} shrink-0`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="w-14 shrink-0 text-sm font-medium">
|
||||
{phaseLabel(key)}
|
||||
</span>
|
||||
<Input
|
||||
@@ -172,20 +198,20 @@ export function PhaseConfigDialog({
|
||||
onChange={(e) =>
|
||||
updateValue(key, parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
className="w-20 h-8 text-sm"
|
||||
className="h-8 w-20 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-12 text-xs px-0"
|
||||
className="h-8 w-12 px-0 text-xs"
|
||||
onClick={() => toggleMode(key)}
|
||||
>
|
||||
{mode === "MINUTES"
|
||||
? t("medications.phaseMinutes")
|
||||
: t("medications.phasePercent")}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs shrink-0">
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{direction === "before"
|
||||
? t("medications.phaseBeforeEnd")
|
||||
: t("medications.phaseAfterEnd")}
|
||||
@@ -197,12 +223,12 @@ export function PhaseConfigDialog({
|
||||
)}
|
||||
|
||||
{statusMessage && (
|
||||
<div className="text-sm text-green-500 text-center">
|
||||
<div className="text-center text-sm text-green-500">
|
||||
{statusMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex-row justify-between sm:justify-between gap-2">
|
||||
<DialogFooter className="flex-row justify-between gap-2 sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -29,6 +29,8 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
|
||||
sleep: "measurements.typeSleep",
|
||||
steps: "measurements.typeSteps",
|
||||
glucose: "measurements.typeBloodGlucose",
|
||||
totalBodyWater: "measurements.typeTotalBodyWater",
|
||||
boneMass: "measurements.typeBoneMass",
|
||||
bpInTarget: "dashboard.bpInTarget",
|
||||
};
|
||||
|
||||
@@ -113,7 +115,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6 space-y-5"
|
||||
className="bg-card border-border scroll-mt-28 space-y-5 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -137,7 +139,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
</p>
|
||||
|
||||
{isLoading || !layout ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
@@ -148,44 +150,44 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
.map((widget, index, arr) => {
|
||||
const labelKey = WIDGET_LABEL_KEYS[widget.id] ?? widget.id;
|
||||
return (
|
||||
<div
|
||||
key={widget.id}
|
||||
className="flex items-center gap-3 rounded-md border border-border bg-background/30 p-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => move(widget.id, -1)}
|
||||
disabled={index === 0 || saveMutation.isPending}
|
||||
aria-label={t("dashboard.moveUp")}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => move(widget.id, 1)}
|
||||
disabled={
|
||||
index === arr.length - 1 || saveMutation.isPending
|
||||
}
|
||||
aria-label={t("dashboard.moveDown")}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</Button>
|
||||
<div
|
||||
key={widget.id}
|
||||
className="border-border bg-background/30 flex items-center gap-3 rounded-md border p-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => move(widget.id, -1)}
|
||||
disabled={index === 0 || saveMutation.isPending}
|
||||
aria-label={t("dashboard.moveUp")}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => move(widget.id, 1)}
|
||||
disabled={
|
||||
index === arr.length - 1 || saveMutation.isPending
|
||||
}
|
||||
aria-label={t("dashboard.moveDown")}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="flex-1 text-sm">{t(labelKey)}</span>
|
||||
<Switch
|
||||
checked={widget.visible}
|
||||
onCheckedChange={(v) => toggle(widget.id, v)}
|
||||
aria-label={t(labelKey)}
|
||||
disabled={saveMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<span className="flex-1 text-sm">{t(labelKey)}</span>
|
||||
<Switch
|
||||
checked={widget.visible}
|
||||
onCheckedChange={(v) => toggle(widget.id, v)}
|
||||
aria-label={t(labelKey)}
|
||||
disabled={saveMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,7 @@ export function PasswordStrength({
|
||||
|
||||
useEffect(() => {
|
||||
if (!password) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setResult(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const EN_PATH = join(ROOT, "messages/en.json");
|
||||
const DE_PATH = join(ROOT, "messages/de.json");
|
||||
|
||||
// JSON.parse silently keeps the last value when an object has duplicate keys,
|
||||
// which lets shadowed keys hide bugs. Walk the source manually to surface them.
|
||||
function findDuplicateKeys(src: string): { path: string; line: number; firstLine: number }[] {
|
||||
const dups: { path: string; line: number; firstLine: number }[] = [];
|
||||
const pathStack: string[] = [];
|
||||
let i = 0;
|
||||
let line = 1;
|
||||
|
||||
function err(msg: string): never {
|
||||
throw new Error(`${msg} at line ${line}`);
|
||||
}
|
||||
|
||||
function skipWs() {
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
if (c === " " || c === "\t" || c === "\r") i++;
|
||||
else if (c === "\n") { i++; line++; }
|
||||
else break;
|
||||
}
|
||||
}
|
||||
|
||||
function readString(): { value: string; line: number } {
|
||||
if (src[i] !== '"') err("expected string");
|
||||
const startLine = line;
|
||||
i++;
|
||||
let raw = "";
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
if (c === "\\") {
|
||||
raw += c + src[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') {
|
||||
i++;
|
||||
return { value: JSON.parse(`"${raw}"`), line: startLine };
|
||||
}
|
||||
if (c === "\n") line++;
|
||||
raw += c;
|
||||
i++;
|
||||
}
|
||||
err("unterminated string");
|
||||
}
|
||||
|
||||
function parseValue() {
|
||||
skipWs();
|
||||
const c = src[i];
|
||||
if (c === "{") return parseObject();
|
||||
if (c === "[") return parseArray();
|
||||
if (c === '"') { readString(); return; }
|
||||
while (i < src.length) {
|
||||
const ch = src[i];
|
||||
if (ch === "," || ch === "}" || ch === "]" || ch === " " || ch === "\n" || ch === "\t" || ch === "\r") break;
|
||||
if (ch === "\n") line++;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArray() {
|
||||
i++; skipWs();
|
||||
if (src[i] === "]") { i++; return; }
|
||||
while (true) {
|
||||
parseValue();
|
||||
skipWs();
|
||||
if (src[i] === ",") { i++; skipWs(); continue; }
|
||||
if (src[i] === "]") { i++; return; }
|
||||
err("expected , or ] in array");
|
||||
}
|
||||
}
|
||||
|
||||
function parseObject() {
|
||||
i++;
|
||||
const seen = new Map<string, number>();
|
||||
skipWs();
|
||||
if (src[i] === "}") { i++; return; }
|
||||
while (true) {
|
||||
skipWs();
|
||||
const { value: key, line: keyLine } = readString();
|
||||
const seenAt = seen.get(key);
|
||||
if (seenAt != null) {
|
||||
dups.push({ path: [...pathStack, key].join("."), line: keyLine, firstLine: seenAt });
|
||||
} else {
|
||||
seen.set(key, keyLine);
|
||||
}
|
||||
skipWs();
|
||||
if (src[i] !== ":") err("expected : after key");
|
||||
i++;
|
||||
pathStack.push(key);
|
||||
parseValue();
|
||||
pathStack.pop();
|
||||
skipWs();
|
||||
if (src[i] === ",") { i++; continue; }
|
||||
if (src[i] === "}") { i++; return; }
|
||||
err("expected , or } in object");
|
||||
}
|
||||
}
|
||||
|
||||
parseValue();
|
||||
return dups;
|
||||
}
|
||||
|
||||
describe("i18n locale file integrity", () => {
|
||||
it("messages/en.json has no duplicate keys", () => {
|
||||
const dups = findDuplicateKeys(readFileSync(EN_PATH, "utf8"));
|
||||
expect(
|
||||
dups,
|
||||
`Duplicate keys silently shadow earlier definitions:\n` +
|
||||
dups
|
||||
.map((d) => ` ${d.path} at line ${d.line} (first defined at line ${d.firstLine})`)
|
||||
.join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("messages/de.json has no duplicate keys", () => {
|
||||
const dups = findDuplicateKeys(readFileSync(DE_PATH, "utf8"));
|
||||
expect(
|
||||
dups,
|
||||
`Duplicate keys silently shadow earlier definitions:\n` +
|
||||
dups
|
||||
.map((d) => ` ${d.path} at line ${d.line} (first defined at line ${d.firstLine})`)
|
||||
.join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("en and de share the same key shape (locale parity)", () => {
|
||||
const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record<string, unknown>;
|
||||
const de = JSON.parse(readFileSync(DE_PATH, "utf8")) as Record<string, unknown>;
|
||||
|
||||
function flatten(obj: unknown, prefix: string, out: string[]) {
|
||||
if (obj == null || typeof obj !== "object") return;
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (typeof v === "string") out.push(key);
|
||||
else if (typeof v === "object") flatten(v, key, out);
|
||||
}
|
||||
}
|
||||
|
||||
const enKeys = new Set<string>();
|
||||
const deKeys = new Set<string>();
|
||||
const enArr: string[] = [];
|
||||
const deArr: string[] = [];
|
||||
flatten(en, "", enArr);
|
||||
flatten(de, "", deArr);
|
||||
enArr.forEach((k) => enKeys.add(k));
|
||||
deArr.forEach((k) => deKeys.add(k));
|
||||
|
||||
const onlyInEn = [...enKeys].filter((k) => !deKeys.has(k)).sort();
|
||||
const onlyInDe = [...deKeys].filter((k) => !enKeys.has(k)).sort();
|
||||
|
||||
expect(
|
||||
{ onlyInEn, onlyInDe },
|
||||
"Locale files drifted apart — every key must exist in both locales.",
|
||||
).toEqual({ onlyInEn: [], onlyInDe: [] });
|
||||
});
|
||||
});
|
||||
@@ -26,9 +26,16 @@ export class HttpError extends Error {
|
||||
* No CSRF check — HealthLog does not use CSRF tokens.
|
||||
* Auth annotation happens in routes via requireAuth().
|
||||
*/
|
||||
// Next.js route handlers come in two shapes — `(request)` for static routes
|
||||
// and `(request, { params })` for dynamic ones. The variadic generic is the
|
||||
// only signature TS accepts that covers both at the call site. The `any[]`
|
||||
// here is constrained by the bound (T must return Promise<Response>) so it
|
||||
// does not loosen handler bodies — only their parameter list.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
|
||||
handler: T,
|
||||
): T {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const wrapped = async (...args: any[]): Promise<Response> => {
|
||||
const request = args[0] as NextRequest;
|
||||
const url = new URL(request.url);
|
||||
|
||||
@@ -15,6 +15,8 @@ export type DashboardWidgetId =
|
||||
| "sleep"
|
||||
| "steps"
|
||||
| "glucose"
|
||||
| "totalBodyWater"
|
||||
| "boneMass"
|
||||
| "bpInTarget";
|
||||
|
||||
export interface DashboardWidgetConfig {
|
||||
@@ -48,12 +50,12 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
|
||||
{ id: "sleep", visible: false, order: 7 },
|
||||
{ id: "steps", visible: false, order: 8 },
|
||||
{ id: "glucose", visible: false, order: 9 },
|
||||
{ id: "totalBodyWater", visible: false, order: 10 },
|
||||
{ id: "boneMass", visible: false, order: 11 },
|
||||
],
|
||||
};
|
||||
|
||||
export function resolveDashboardLayout(
|
||||
raw: unknown,
|
||||
): DashboardLayout {
|
||||
export function resolveDashboardLayout(raw: unknown): DashboardLayout {
|
||||
if (!raw || typeof raw !== "object") return DEFAULT_DASHBOARD_LAYOUT;
|
||||
const candidate = raw as Partial<DashboardLayout>;
|
||||
if (
|
||||
|
||||
@@ -27,7 +27,8 @@ interface ReportData {
|
||||
/** Per-context glucose stats (canonical mg/dL). */
|
||||
glucoseStats?: Record<
|
||||
"FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME",
|
||||
{ avg: number; min: number; max: number; count: number; latest: number } | undefined
|
||||
| { avg: number; min: number; max: number; count: number; latest: number }
|
||||
| undefined
|
||||
>;
|
||||
/** Display-unit preference: "mg/dL" (default) or "mmol/L". */
|
||||
glucoseUnit?: "mg/dL" | "mmol/L";
|
||||
@@ -74,6 +75,8 @@ const TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
BODY_FAT: "doctorReport.typeBodyFat",
|
||||
SLEEP_DURATION: "doctorReport.typeSleep",
|
||||
ACTIVITY_STEPS: "doctorReport.typeSteps",
|
||||
TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater",
|
||||
BONE_MASS: "doctorReport.typeBoneMass",
|
||||
};
|
||||
|
||||
const TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||||
@@ -84,6 +87,8 @@ const TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||||
BODY_FAT: "%",
|
||||
SLEEP_DURATION: "h",
|
||||
ACTIVITY_STEPS: null, // translated unit
|
||||
TOTAL_BODY_WATER: "kg",
|
||||
BONE_MASS: "kg",
|
||||
};
|
||||
|
||||
const MOOD_LABEL_KEYS: Record<number, string> = {
|
||||
@@ -169,7 +174,8 @@ export function generateDoctorReportPDF(
|
||||
MALE: "doctorReport.genderMale",
|
||||
FEMALE: "doctorReport.genderFemale",
|
||||
};
|
||||
const genderKey = genderKeys[data.patient.gender] ?? "doctorReport.genderOther";
|
||||
const genderKey =
|
||||
genderKeys[data.patient.gender] ?? "doctorReport.genderOther";
|
||||
patientInfo.push(`${t("doctorReport.gender")}: ${t(genderKey)}`);
|
||||
}
|
||||
if (data.patient.heightCm) {
|
||||
@@ -203,6 +209,8 @@ export function generateDoctorReportPDF(
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"TOTAL_BODY_WATER",
|
||||
"BONE_MASS",
|
||||
];
|
||||
|
||||
for (const type of vitalTypes) {
|
||||
@@ -348,8 +356,7 @@ export function generateDoctorReportPDF(
|
||||
doc.setFont("helvetica", "normal");
|
||||
for (const ctx of loggedGlucose) {
|
||||
const s = data.glucoseStats![ctx]!;
|
||||
const range =
|
||||
data.glucoseRanges?.[ctx] ?? defaultGlucoseRanges[ctx];
|
||||
const range = data.glucoseRanges?.[ctx] ?? defaultGlucoseRanges[ctx];
|
||||
const conv = (v: number) => convertGlucose(v, glucoseUnit);
|
||||
const inRange = s.avg >= range.min && s.avg <= range.max;
|
||||
const classKey = inRange
|
||||
@@ -387,8 +394,7 @@ export function generateDoctorReportPDF(
|
||||
y += 6;
|
||||
|
||||
const compRows = complianceEntries.map(([name, c]) => {
|
||||
const rate =
|
||||
c.total > 0 ? `${num((c.taken / c.total) * 100, 1)}%` : "—";
|
||||
const rate = c.total > 0 ? `${num((c.taken / c.total) * 100, 1)}%` : "—";
|
||||
return [
|
||||
name,
|
||||
String(c.taken),
|
||||
@@ -503,16 +509,8 @@ export function generateDoctorReportPDF(
|
||||
pageDoc.setFontSize(7);
|
||||
pageDoc.setFont("helvetica", "italic");
|
||||
pageDoc.setTextColor(140, 140, 140);
|
||||
pageDoc.text(
|
||||
t("doctorReport.footerDisclaimer1"),
|
||||
margin,
|
||||
pageHeight - 14,
|
||||
);
|
||||
pageDoc.text(
|
||||
t("doctorReport.footerDisclaimer2"),
|
||||
margin,
|
||||
pageHeight - 10,
|
||||
);
|
||||
pageDoc.text(t("doctorReport.footerDisclaimer1"), margin, pageHeight - 14);
|
||||
pageDoc.text(t("doctorReport.footerDisclaimer2"), margin, pageHeight - 10);
|
||||
pageDoc.text(
|
||||
t("doctorReport.footerSource", {
|
||||
timestamp: formatters.dateTime(new Date()),
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface FeedbackInput {
|
||||
subject: string;
|
||||
description: string;
|
||||
username: string;
|
||||
hasScreenshot: boolean;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
@@ -74,6 +73,15 @@ function sanitize(text: string, max = 5000): string {
|
||||
return text.replace(/```/g, "\\`\\`\\`").slice(0, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for safe inclusion in a `RegExp(..)` constructor — the
|
||||
* standard "don't let an attacker turn input into regex metacharacters"
|
||||
* helper. Used to redact secrets from logged error bodies.
|
||||
*/
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a feedback item as a GitHub Issue. Returns issue number + URL.
|
||||
* Throws GithubPublishError on API failure.
|
||||
@@ -122,10 +130,6 @@ export async function publishFeedbackToGithub(
|
||||
}
|
||||
}
|
||||
|
||||
if (input.hasScreenshot) {
|
||||
body += `\n**Screenshot:** A screenshot was attached in the original report.`;
|
||||
}
|
||||
|
||||
body += `\n\n---\n*Created via HealthLog feedback*`;
|
||||
|
||||
const title = `[${categoryLabel}] ${safeSubject || `Feedback – ${dateStr}`}`;
|
||||
@@ -146,7 +150,14 @@ export async function publishFeedbackToGithub(
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text();
|
||||
getEvent()?.addWarning("GitHub issue creation failed: " + errBody.slice(0, 500));
|
||||
// Strip the GitHub PAT before logging — GitHub error responses can echo
|
||||
// request headers in some failure modes, and Wide Events flow to Loki
|
||||
// unredacted. Keep the response shape useful for debugging without
|
||||
// leaking the credential.
|
||||
const sanitisedErr = errBody
|
||||
.replace(new RegExp(escapeRegex(config.token), "g"), "[REDACTED]")
|
||||
.slice(0, 500);
|
||||
getEvent()?.addWarning("GitHub issue creation failed: " + sanitisedErr);
|
||||
throw new GithubPublishError(
|
||||
`Failed to create GitHub issue (HTTP ${res.status})`,
|
||||
res.status === 401 || res.status === 403 ? res.status : 502,
|
||||
|
||||
@@ -925,25 +925,64 @@ async function handleDataBackup(jobs: Job<DataBackupPayload>[]) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal logger that prefers structured Wide-Event annotations when a
|
||||
* worker context is active, and falls back to stderr only for true
|
||||
* lifecycle events that fire outside any handler (init, fatal startup
|
||||
* errors, shutdown). Avoids the historical pattern of `console.log`
|
||||
* everywhere in this file, which polluted production stdout and was
|
||||
* never queryable in Loki.
|
||||
*/
|
||||
function workerLog(level: "info" | "error", msg: string, err?: unknown): void {
|
||||
if (level === "error") {
|
||||
// Errors during worker init or shutdown happen outside any request
|
||||
// context, so stderr is the only audience the operator has.
|
||||
if (err !== undefined) console.error(`[pg-boss] ${msg}`, err);
|
||||
else console.error(`[pg-boss] ${msg}`);
|
||||
}
|
||||
// info-level lifecycle messages are intentionally silent — pg-boss own
|
||||
// events surface state, and Wide Events from handlers carry the work.
|
||||
}
|
||||
|
||||
export async function startReminderWorker() {
|
||||
console.log("[pg-boss] Initializing pg-boss with DATABASE_URL...");
|
||||
if (!DATABASE_URL) {
|
||||
console.error("[pg-boss] CRITICAL: DATABASE_URL is not set!");
|
||||
workerLog("error", "CRITICAL: DATABASE_URL is not set, refusing to start");
|
||||
return;
|
||||
}
|
||||
|
||||
const boss = new PgBoss(DATABASE_URL);
|
||||
|
||||
boss.on("error", (error: unknown) => {
|
||||
console.error("[pg-boss] Error:", error);
|
||||
workerLog("error", "boss emitted error", error);
|
||||
recordError();
|
||||
});
|
||||
|
||||
console.log("[pg-boss] Connecting to database...");
|
||||
await boss.start();
|
||||
setGlobalBoss(boss);
|
||||
markWorkerStarted();
|
||||
console.log("[pg-boss] Connected and started");
|
||||
|
||||
// Graceful shutdown: drain in-flight jobs on SIGTERM/SIGINT (sent by
|
||||
// Docker Compose `docker stop`, Kubernetes pod termination, Coolify
|
||||
// redeploys). Without this, pending handlers were force-killed and could
|
||||
// either be lost or replayed on next start. We only register the listeners
|
||||
// once — re-entering startReminderWorker (e.g. on hot-reload in dev) is
|
||||
// a no-op for the handlers because they capture `boss` by closure and the
|
||||
// first signal stops everything.
|
||||
let shutdownInProgress = false;
|
||||
const onSignal = async (signal: "SIGTERM" | "SIGINT") => {
|
||||
if (shutdownInProgress) return;
|
||||
shutdownInProgress = true;
|
||||
workerLog("error", `received ${signal}, draining boss`);
|
||||
try {
|
||||
// graceful=true waits for active handlers to finish, then closes the
|
||||
// pg connection pool. timeout cap so a stuck handler can't block deploys.
|
||||
await boss.stop({ graceful: true, timeout: 30_000 });
|
||||
} catch (err) {
|
||||
workerLog("error", "boss.stop failed during shutdown", err);
|
||||
}
|
||||
};
|
||||
process.once("SIGTERM", () => void onSignal("SIGTERM"));
|
||||
process.once("SIGINT", () => void onSignal("SIGINT"));
|
||||
|
||||
// pg-boss v12 requires explicit queue creation before scheduling
|
||||
const allQueues = [
|
||||
@@ -964,7 +1003,6 @@ export async function startReminderWorker() {
|
||||
for (const q of allQueues) {
|
||||
await boss.createQueue(q);
|
||||
}
|
||||
console.log(`[pg-boss] Created ${allQueues.length} queues`);
|
||||
|
||||
// Schedule recurring cron jobs
|
||||
const schedules: [string, string][] = [
|
||||
@@ -984,7 +1022,6 @@ export async function startReminderWorker() {
|
||||
for (const [name, cron] of schedules) {
|
||||
await boss.schedule(name, cron, {}, { tz: "Europe/Berlin" });
|
||||
}
|
||||
console.log(`[pg-boss] Scheduled ${schedules.length} cron jobs`);
|
||||
|
||||
// Register the handler
|
||||
await boss.work<ReminderCheckPayload>(
|
||||
@@ -1057,7 +1094,7 @@ if (
|
||||
process.argv[1]?.endsWith("reminder-worker.js")
|
||||
) {
|
||||
startReminderWorker().catch((err) => {
|
||||
console.error("Failed to start reminder worker:", err);
|
||||
workerLog("error", "Failed to start reminder worker", err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMeasurementSchema,
|
||||
getUnitForType,
|
||||
validateMeasurementRange,
|
||||
} from "../measurement";
|
||||
|
||||
describe("measurement validation", () => {
|
||||
describe("getUnitForType", () => {
|
||||
it("returns canonical unit for each measurement type", () => {
|
||||
expect(getUnitForType("WEIGHT")).toBe("kg");
|
||||
expect(getUnitForType("BLOOD_PRESSURE_SYS")).toBe("mmHg");
|
||||
expect(getUnitForType("BLOOD_PRESSURE_DIA")).toBe("mmHg");
|
||||
expect(getUnitForType("PULSE")).toBe("bpm");
|
||||
expect(getUnitForType("BODY_FAT")).toBe("%");
|
||||
expect(getUnitForType("SLEEP_DURATION")).toBe("hours");
|
||||
expect(getUnitForType("ACTIVITY_STEPS")).toBe("steps");
|
||||
expect(getUnitForType("BLOOD_GLUCOSE")).toBe("mg/dL");
|
||||
expect(getUnitForType("TOTAL_BODY_WATER")).toBe("kg");
|
||||
expect(getUnitForType("BONE_MASS")).toBe("kg");
|
||||
});
|
||||
|
||||
it("returns 'unknown' for unrecognised types", () => {
|
||||
expect(getUnitForType("MADE_UP_TYPE")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateMeasurementRange", () => {
|
||||
it("rejects values below the plausible minimum", () => {
|
||||
expect(validateMeasurementRange("WEIGHT", 0.5)).toMatch(/zwischen/);
|
||||
expect(validateMeasurementRange("BONE_MASS", 0.1)).toMatch(/zwischen/);
|
||||
expect(validateMeasurementRange("TOTAL_BODY_WATER", 1)).toMatch(
|
||||
/zwischen/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects values above the plausible maximum", () => {
|
||||
expect(validateMeasurementRange("WEIGHT", 600)).toMatch(/zwischen/);
|
||||
expect(validateMeasurementRange("BONE_MASS", 12)).toMatch(/zwischen/);
|
||||
expect(validateMeasurementRange("TOTAL_BODY_WATER", 200)).toMatch(
|
||||
/zwischen/,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts values inside the plausible range", () => {
|
||||
expect(validateMeasurementRange("WEIGHT", 75)).toBeNull();
|
||||
expect(validateMeasurementRange("BONE_MASS", 3.0)).toBeNull();
|
||||
expect(validateMeasurementRange("TOTAL_BODY_WATER", 42)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unknown types (no range = no opinion)", () => {
|
||||
expect(validateMeasurementRange("MADE_UP_TYPE", 9999)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMeasurementSchema", () => {
|
||||
const validBase = {
|
||||
value: 75,
|
||||
measuredAt: "2026-04-27T08:00:00.000Z",
|
||||
};
|
||||
|
||||
it("accepts a TOTAL_BODY_WATER measurement without glucoseContext", () => {
|
||||
const parsed = createMeasurementSchema.safeParse({
|
||||
...validBase,
|
||||
type: "TOTAL_BODY_WATER",
|
||||
value: 42,
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a BONE_MASS measurement without glucoseContext", () => {
|
||||
const parsed = createMeasurementSchema.safeParse({
|
||||
...validBase,
|
||||
type: "BONE_MASS",
|
||||
value: 3.2,
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects out-of-range values for body composition types", () => {
|
||||
const tooMuchWater = createMeasurementSchema.safeParse({
|
||||
...validBase,
|
||||
type: "TOTAL_BODY_WATER",
|
||||
value: 500,
|
||||
});
|
||||
expect(tooMuchWater.success).toBe(false);
|
||||
|
||||
const tooLittleBone = createMeasurementSchema.safeParse({
|
||||
...validBase,
|
||||
type: "BONE_MASS",
|
||||
value: 0.1,
|
||||
});
|
||||
expect(tooLittleBone.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects glucoseContext on non-glucose measurements", () => {
|
||||
const parsed = createMeasurementSchema.safeParse({
|
||||
...validBase,
|
||||
type: "TOTAL_BODY_WATER",
|
||||
value: 42,
|
||||
glucoseContext: "FASTING",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("requires glucoseContext on BLOOD_GLUCOSE measurements", () => {
|
||||
const parsed = createMeasurementSchema.safeParse({
|
||||
...validBase,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 95,
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unrecognised measurement types", () => {
|
||||
const parsed = createMeasurementSchema.safeParse({
|
||||
...validBase,
|
||||
type: "TBD_NEW_TYPE",
|
||||
value: 1,
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isPublicUrl } from "../notifications";
|
||||
|
||||
describe("isPublicUrl SSRF guard", () => {
|
||||
describe("allows public addresses", () => {
|
||||
it("real public hostnames", () => {
|
||||
expect(isPublicUrl("https://api.openai.com/v1")).toBe(true);
|
||||
expect(isPublicUrl("https://wbsapi.withings.net/v2/oauth2")).toBe(true);
|
||||
expect(isPublicUrl("http://example.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("public IPv4 addresses", () => {
|
||||
expect(isPublicUrl("https://1.1.1.1")).toBe(true);
|
||||
expect(isPublicUrl("https://8.8.8.8")).toBe(true);
|
||||
expect(isPublicUrl("https://93.184.216.34")).toBe(true); // example.com
|
||||
});
|
||||
});
|
||||
|
||||
describe("blocks loopback and link-local", () => {
|
||||
it("textual loopback hosts", () => {
|
||||
expect(isPublicUrl("http://localhost")).toBe(false);
|
||||
expect(isPublicUrl("http://localhost:8080")).toBe(false);
|
||||
expect(isPublicUrl("http://thing.localhost")).toBe(false);
|
||||
expect(isPublicUrl("https://service.internal")).toBe(false);
|
||||
expect(isPublicUrl("https://router.local")).toBe(false);
|
||||
});
|
||||
|
||||
it("IPv4 loopback / 0.0.0.0", () => {
|
||||
expect(isPublicUrl("http://127.0.0.1")).toBe(false);
|
||||
expect(isPublicUrl("http://127.255.255.254")).toBe(false);
|
||||
expect(isPublicUrl("http://0.0.0.0")).toBe(false);
|
||||
});
|
||||
|
||||
it("AWS metadata service + link-local 169.254/16", () => {
|
||||
expect(isPublicUrl("http://169.254.169.254/latest/meta-data/")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isPublicUrl("http://169.254.0.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("IPv6 loopback / unspecified / link-local / unique-local", () => {
|
||||
expect(isPublicUrl("http://[::1]")).toBe(false);
|
||||
expect(isPublicUrl("http://[::]")).toBe(false);
|
||||
expect(isPublicUrl("http://[fe80::1]")).toBe(false);
|
||||
expect(isPublicUrl("http://[fc00::1]")).toBe(false);
|
||||
expect(isPublicUrl("http://[fd12:3456::1]")).toBe(false);
|
||||
});
|
||||
|
||||
it("IPv4-mapped IPv6 (::ffff:...) embedding private IPv4", () => {
|
||||
// Both notations parsers might emit:
|
||||
expect(isPublicUrl("http://[::ffff:127.0.0.1]")).toBe(false);
|
||||
expect(isPublicUrl("http://[::ffff:10.0.0.1]")).toBe(false);
|
||||
expect(isPublicUrl("http://[::ffff:192.168.1.1]")).toBe(false);
|
||||
expect(isPublicUrl("http://[::ffff:7f00:1]")).toBe(false); // hex form of 127.0.0.1
|
||||
expect(isPublicUrl("http://[::ffff:c0a8:0101]")).toBe(false); // hex form of 192.168.1.1
|
||||
});
|
||||
});
|
||||
|
||||
describe("blocks RFC1918 + CGNAT", () => {
|
||||
it("10.0.0.0/8", () => {
|
||||
expect(isPublicUrl("http://10.0.0.1")).toBe(false);
|
||||
expect(isPublicUrl("http://10.255.255.254")).toBe(false);
|
||||
});
|
||||
|
||||
it("172.16.0.0/12", () => {
|
||||
expect(isPublicUrl("http://172.16.0.1")).toBe(false);
|
||||
expect(isPublicUrl("http://172.31.255.254")).toBe(false);
|
||||
// 172.32 is outside the /12 — should still be public
|
||||
expect(isPublicUrl("http://172.32.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("192.168.0.0/16", () => {
|
||||
expect(isPublicUrl("http://192.168.1.1")).toBe(false);
|
||||
expect(isPublicUrl("http://192.168.255.254")).toBe(false);
|
||||
});
|
||||
|
||||
it("100.64.0.0/10 (CGNAT)", () => {
|
||||
expect(isPublicUrl("http://100.64.0.1")).toBe(false);
|
||||
expect(isPublicUrl("http://100.127.255.254")).toBe(false);
|
||||
// 100.63 is below the range — public
|
||||
expect(isPublicUrl("http://100.63.0.1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("regression: leading-zero IPv4 cannot bypass private checks", () => {
|
||||
// The old implementation used `h.startsWith("10.")` which treats
|
||||
// "010.0.0.1" as not-starting-with-"10." and waved it through.
|
||||
// The strict parser rejects octets with leading zeros entirely.
|
||||
it("rejects '010.0.0.1' (was a bypass)", () => {
|
||||
expect(isPublicUrl("http://010.0.0.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects '0192.168.1.1'", () => {
|
||||
expect(isPublicUrl("http://0192.168.1.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects '0010.10.10.10'", () => {
|
||||
expect(isPublicUrl("http://0010.10.10.10")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects out-of-range octets — better safe than sorry", () => {
|
||||
// 256.0.0.1 looks like an IPv4 quad but is not valid. Rather than
|
||||
// falling through to "public" we deny — refusing a malformed URL is
|
||||
// better than risking misinterpretation as an internal address.
|
||||
expect(isPublicUrl("http://256.0.0.1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("regression: alternate IPv4 notations cannot bypass private checks", () => {
|
||||
it("hex IPv4 (0x7f.0.0.1 = 127.0.0.1)", () => {
|
||||
expect(isPublicUrl("http://0x7f.0.0.1")).toBe(false);
|
||||
expect(isPublicUrl("http://0x7f000001")).toBe(false);
|
||||
expect(isPublicUrl("http://0xa.0.0.1")).toBe(false); // 10.0.0.1
|
||||
});
|
||||
|
||||
it("decimal IPv4 (2130706433 = 127.0.0.1)", () => {
|
||||
expect(isPublicUrl("http://2130706433")).toBe(false);
|
||||
expect(isPublicUrl("http://167772161")).toBe(false); // 10.0.0.1
|
||||
expect(isPublicUrl("http://3232235777")).toBe(false); // 192.168.1.1
|
||||
});
|
||||
});
|
||||
|
||||
describe("rejects bad protocols", () => {
|
||||
it("file://, ftp://, javascript:", () => {
|
||||
expect(isPublicUrl("file:///etc/passwd")).toBe(false);
|
||||
expect(isPublicUrl("ftp://example.com")).toBe(false);
|
||||
expect(isPublicUrl("javascript:alert(1)")).toBe(false);
|
||||
});
|
||||
|
||||
it("invalid URLs", () => {
|
||||
expect(isPublicUrl("not a url")).toBe(false);
|
||||
expect(isPublicUrl("")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ export const measurementTypeEnum = z.enum([
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"BLOOD_GLUCOSE",
|
||||
"TOTAL_BODY_WATER",
|
||||
"BONE_MASS",
|
||||
]);
|
||||
|
||||
export const glucoseContextEnum = z.enum([
|
||||
@@ -29,6 +31,8 @@ const unitMap: Record<string, string> = {
|
||||
SLEEP_DURATION: "hours",
|
||||
ACTIVITY_STEPS: "steps",
|
||||
BLOOD_GLUCOSE: "mg/dL",
|
||||
TOTAL_BODY_WATER: "kg",
|
||||
BONE_MASS: "kg",
|
||||
};
|
||||
|
||||
export function getUnitForType(type: string): string {
|
||||
@@ -45,6 +49,8 @@ const VALUE_RANGES: Record<string, { min: number; max: number }> = {
|
||||
SLEEP_DURATION: { min: 0, max: 24 },
|
||||
ACTIVITY_STEPS: { min: 0, max: 200000 },
|
||||
BLOOD_GLUCOSE: { min: 20, max: 800 }, // mg/dL — covers severe hypo to severe hyperglycemia
|
||||
TOTAL_BODY_WATER: { min: 5, max: 100 }, // kg of water — adults typically 30–55 kg
|
||||
BONE_MASS: { min: 0.5, max: 8 }, // kg — adult plausibility (typical 2.5–4.5 kg)
|
||||
};
|
||||
|
||||
export function validateMeasurementRange(
|
||||
@@ -69,10 +75,9 @@ export const createMeasurementSchema = z
|
||||
// constraint in Postgres (see migration 0021).
|
||||
glucoseContext: glucoseContextEnum.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => validateMeasurementRange(data.type, data.value) === null,
|
||||
{ message: "Value out of plausible range" },
|
||||
)
|
||||
.refine((data) => validateMeasurementRange(data.type, data.value) === null, {
|
||||
message: "Value out of plausible range",
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
(data.type === "BLOOD_GLUCOSE" && data.glucoseContext !== undefined) ||
|
||||
|
||||
@@ -7,31 +7,148 @@ export const notificationPreferenceSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
// Block private/internal network URLs to prevent SSRF. Exported so other
|
||||
// server-initiated egress (AI providers, webhooks) can reuse it.
|
||||
/**
|
||||
* Parse a dotted-quad IPv4 string into normalized octets.
|
||||
*
|
||||
* Returns null if the input is not exactly four octets, contains non-digit
|
||||
* characters, has any octet outside 0–255, or contains leading zeros (which
|
||||
* some `parseInt` paths historically allowed — bypassing prefix checks like
|
||||
* `h.startsWith("10.")` because "010.x.x.x" still parses to 10).
|
||||
*/
|
||||
function parseIpv4Strict(
|
||||
host: string,
|
||||
): [number, number, number, number] | null {
|
||||
const parts = host.split(".");
|
||||
if (parts.length !== 4) return null;
|
||||
const octets: number[] = [];
|
||||
for (const part of parts) {
|
||||
if (!/^(0|[1-9][0-9]?|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/.test(part)) {
|
||||
return null;
|
||||
}
|
||||
octets.push(Number(part));
|
||||
}
|
||||
return [octets[0], octets[1], octets[2], octets[3]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Block private/internal network URLs to prevent SSRF. Exported so other
|
||||
* server-initiated egress (AI providers, webhooks) can reuse it.
|
||||
*
|
||||
* Defense layers:
|
||||
* 1. Protocol allowlist: only http(s).
|
||||
* 2. Pre-URL leading-zero IPv4 check on the *raw* input, because the
|
||||
* `URL` constructor silently normalises "010.0.0.1" to "8.0.0.1"
|
||||
* (octal interpretation) — a real SSRF bypass on naive checks.
|
||||
* 3. Hostname denylist for unicast loopback / link-local / metadata services.
|
||||
* 4. RFC1918 + CGNAT + 169.254/16 + 127.0.0.0/8 + 0.0.0.0/8 block via
|
||||
* strict IPv4 parser.
|
||||
* 5. IPv6 loopback / link-local / unique-local block (brackets-aware).
|
||||
*
|
||||
* NOTE: This is an *input-time* check on user-supplied URLs. It does not
|
||||
* defeat DNS rebinding (where a public hostname later resolves to a private
|
||||
* IP). For that, pin the resolved IP at fetch time or use an HTTP client
|
||||
* that blocks redirects to private ranges.
|
||||
*/
|
||||
function isPrivateIpv4(ip: [number, number, number, number]): boolean {
|
||||
const [a, b] = ip;
|
||||
if (a === 127) return true; // 127.0.0.0/8 loopback
|
||||
if (a === 0) return true; // 0.0.0.0/8 reserved (also covers literal 0.0.0.0)
|
||||
if (a === 10) return true; // 10.0.0.0/8 RFC1918
|
||||
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + AWS metadata
|
||||
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 RFC1918
|
||||
if (a === 192 && b === 168) return true; // 192.168.0.0/16 RFC1918
|
||||
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isPublicUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
||||
return false;
|
||||
const h = parsed.hostname;
|
||||
if (
|
||||
h === "169.254.169.254" ||
|
||||
h === "127.0.0.1" ||
|
||||
h === "[::1]" ||
|
||||
h === "0.0.0.0" ||
|
||||
h === "localhost" ||
|
||||
h.startsWith("10.") ||
|
||||
h.startsWith("192.168.") ||
|
||||
h.endsWith(".internal") ||
|
||||
h.endsWith(".local")
|
||||
)
|
||||
return false;
|
||||
// 172.16.0.0/12
|
||||
if (h.startsWith("172.")) {
|
||||
const second = parseInt(h.split(".")[1] ?? "0", 10);
|
||||
if (second >= 16 && second <= 31) return false;
|
||||
// Pre-URL guard #1: the WHATWG URL parser interprets leading-zero IPv4
|
||||
// octets as octal. "010.0.0.1" silently becomes "8.0.0.1" (public),
|
||||
// bypassing any post-parse "starts with 10." check.
|
||||
const rawHostMatch = url.match(/^[a-z]+:\/\/(?:[^@/]*@)?([^:/?#]+)/i);
|
||||
const rawHost = rawHostMatch?.[1] ?? "";
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(rawHost)) {
|
||||
// Reject any segment with a leading zero (octal-bypass surface).
|
||||
if (/(?:^|\.)0\d/.test(rawHost)) return false;
|
||||
}
|
||||
|
||||
// Pre-URL guard #2: hex-notation IPv4 ("http://0x7f.0.0.1" or
|
||||
// "http://0x7f000001") and decimal-notation IPv4 ("http://2130706433"
|
||||
// = 127.0.0.1). The URL parser normalises both into a real IPv4 string
|
||||
// — the post-parse path *would* catch them, but only via Node's parser
|
||||
// behaviour which has historically been inconsistent. Reject the raw
|
||||
// alternate notations outright.
|
||||
if (/^0x[0-9a-f]+(?:\.|$)/i.test(rawHost)) return false;
|
||||
if (/^\d+$/.test(rawHost) && rawHost.length >= 8) {
|
||||
// Pure-decimal IPv4 ("4294967295" max). Only treat as suspicious when
|
||||
// the value could plausibly be an IP — short numeric "hostnames"
|
||||
// would not parse as URLs anyway.
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return false;
|
||||
}
|
||||
let h = parsed.hostname.toLowerCase();
|
||||
if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
|
||||
|
||||
// Hostname denylist (literal strings).
|
||||
if (
|
||||
h === "localhost" ||
|
||||
h.endsWith(".internal") ||
|
||||
h.endsWith(".local") ||
|
||||
h.endsWith(".localhost")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv6 loopback / unspecified / link-local / unique-local.
|
||||
if (
|
||||
h === "::1" ||
|
||||
h === "::" ||
|
||||
h.startsWith("fe80:") ||
|
||||
h.startsWith("fc") ||
|
||||
h.startsWith("fd")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv4-mapped IPv6 ("::ffff:127.0.0.1" or "::ffff:7f00:1") and
|
||||
// 6to4 / NAT64 with embedded private IPv4. Extract the trailing
|
||||
// dotted-quad if present, otherwise extract the last 32 bits as
|
||||
// hex pairs and reconstruct.
|
||||
if (h.includes(":")) {
|
||||
const ipv4MappedDotted = h.match(/^(?:::ffff:)(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (ipv4MappedDotted) {
|
||||
const ip = parseIpv4Strict(ipv4MappedDotted[1]);
|
||||
if (!ip || isPrivateIpv4(ip)) return false;
|
||||
}
|
||||
const ipv4MappedHex = h.match(
|
||||
/^(?:::ffff:)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/,
|
||||
);
|
||||
if (ipv4MappedHex) {
|
||||
const high = parseInt(ipv4MappedHex[1], 16);
|
||||
const low = parseInt(ipv4MappedHex[2], 16);
|
||||
const ip: [number, number, number, number] = [
|
||||
(high >> 8) & 0xff,
|
||||
high & 0xff,
|
||||
(low >> 8) & 0xff,
|
||||
low & 0xff,
|
||||
];
|
||||
if (isPrivateIpv4(ip)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Strict IPv4 parsing: reject malformed forms outright (better a false
|
||||
// positive than an SSRF) and apply RFC1918 / CGNAT / loopback bans.
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(h)) {
|
||||
const ip = parseIpv4Strict(h);
|
||||
if (!ip) return false;
|
||||
if (isPrivateIpv4(ip)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -42,7 +159,10 @@ export const ntfySettingsSchema = z.object({
|
||||
serverUrl: z
|
||||
.url("Ungültige Server-URL")
|
||||
.max(200)
|
||||
.refine((url) => isPublicUrl(url), "Server-URL darf nicht auf interne Netzwerke zeigen")
|
||||
.refine(
|
||||
(url) => isPublicUrl(url),
|
||||
"Server-URL darf nicht auf interne Netzwerke zeigen",
|
||||
)
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
topic: z.string().max(100).optional().or(z.literal("")),
|
||||
|
||||
@@ -127,6 +127,8 @@ const MEASURE_TYPE_MAP: Record<number, { type: string; factor?: number }> = {
|
||||
10: { type: "BLOOD_PRESSURE_SYS" }, // Systolic BP
|
||||
11: { type: "PULSE" }, // Heart rate
|
||||
6: { type: "BODY_FAT" }, // Body fat %
|
||||
77: { type: "TOTAL_BODY_WATER" }, // Hydration / water mass (kg)
|
||||
88: { type: "BONE_MASS" }, // Bone mass (kg)
|
||||
};
|
||||
|
||||
export interface WithingsMeasure {
|
||||
|
||||
Reference in new issue
Block a user