Compare commits

..
36 Commits
Author SHA1 Message Date
Marc-André Bombeck 79764d3467 fix(compose): replace ${VAR:?required} with entrypoint validation (1.3.1)
The ${VAR:?error} syntax in docker-compose.yml broke Coolify-style
deploys: that platform parses the compose file eagerly and stores the
fallback error string as the env-var value when the var is unset, which
then propagates into DATABASE_URL and breaks the running app with
P1000 auth failures.

Switched to plain ${VAR} interpolation. Validation moves into
docker-entrypoint.sh, which checks DATABASE_URL, SESSION_SECRET,
ENCRYPTION_KEY, API_TOKEN_HMAC_KEY before connecting to the database
and exits with a deploy-platform-agnostic error listing the unset vars.
2026-04-27 20:19:30 +02:00
Marc-André Bombeck 0e510944ce ci: keep ESLint non-blocking pending settings-page split
The pre-existing settings-page monolith has multiple set-state-in-effect
patterns that can only be cleaned up properly when the 2100-line file
gets broken into per-section sub-components (planned for 1.4.0). Until
then, blocking lint would just gate every PR. TypeScript strict checks
remain blocking.
2026-04-27 00:28:28 +02:00
Marc-André Bombeck 05142841fa fix(lint): suppress react-hooks/set-state-in-effect on pre-existing sync patterns
CI now blocks on ESLint, which surfaced two pre-existing useEffect
sync-from-server patterns. Annotated with targeted eslint-disable
comments and a note that the proper fix lives in 1.4.0 alongside the
settings-page split — refactoring derivation here would touch a
2100-line file.
2026-04-27 00:28:28 +02:00
Marc-André Bombeck 53235a0fb7 fix(security): close SSRF bypasses via hex/decimal IPv4 + IPv4-mapped IPv6
Security review of #106 surfaced two HIGH-severity gaps in the SSRF
guard. Both are documented bypass classes with public CVE precedent
(github.com/projectdiscovery/nuclei-templates' SSRF templates list
hex and IPv4-mapped IPv6 explicitly).

- Hex-notation IPv4: 'http://0x7f.0.0.1' / 'http://0x7f000001' /
  'http://0xa.0.0.1' all parse to internal addresses depending on
  the runtime's URL semantics. Reject the raw form before parsing.
- Decimal-notation IPv4: 'http://2130706433' is 127.0.0.1, etc.
  Reject any pure-numeric host of plausible IP length.
- IPv4-mapped IPv6: '[::ffff:127.0.0.1]' and the hex variant
  '[::ffff:7f00:1]' both target loopback. Match either form and
  apply the private-IPv4 check to the embedded address.
- '[::]' (IPv6 unspecified, alias for 0.0.0.0) blocked alongside
  '[::1]'.

Refactored the per-octet private-range checks into isPrivateIpv4()
since both the IPv4 and the IPv4-mapped-IPv6 paths now use them.

Test suite extended with regression cases for hex, decimal, and
IPv4-mapped IPv6. Now 208/208 passing.
2026-04-27 00:28:28 +02:00
Marc-André Bombeck 846eda80fa chore(release): 1.3.0
Bumps version, drafts CHANGELOG body, and adds total-body-water /
bone-mass mention to README's Key Features list. Tag will be created
post-merge to trigger the GHCR multi-arch build.
2026-04-27 00:28:28 +02:00
Marc-André Bombeck ef3e37b7da test(security): add SSRF guard tests + measurement validation tests
- New unit tests for isPublicUrl covering loopback, link-local, RFC1918,
  CGNAT, IPv6 ranges, and the leading-zero IPv4 octal-bypass regression
  (URL parser silently turns '010.0.0.1' into '8.0.0.1' — caught now).
- Strengthened isPublicUrl: pre-URL leading-zero check on the raw input,
  bracket-aware IPv6, deny on malformed IPv4 quads instead of falling
  through to 'public' (better safe than sorry on user-supplied URLs).
- New unit tests for measurement validation covering the new
  TOTAL_BODY_WATER and BONE_MASS types: ranges, glucoseContext rejection
  on non-glucose types, and unrecognised type rejection.
2026-04-27 00:28:28 +02:00
Marc-André Bombeck 3de2e37ed5 fix(ux): touch targets, a11y on phase config, admin label i18n
UX/a11y residuals from 2026-04-26 audit:

- bottom-nav: touch targets sized to WCAG 2.5.5 minimum (44x44 CSS px)
  via min-h-11 min-w-11 on each Link. Nav height bumped to h-16 to
  accommodate. Visual icon stays at 20px so design doesn't shift.
- phase-config-dialog: decorative coloured dot now aria-hidden because
  the redundant text label ("Green"/"Yellow"/...) already conveys the
  phase. Screen readers no longer announce a meaningless 'image' node.
- admin: 'Web Push' and 'Bug Report' labels were hard-coded English in
  StatusItem and SettingsToggle calls; now go through t() with new
  admin.integrationWebPush / admin.integrationBugReport keys (en + de).
- medication-card: false-positive en-CA / en-US locale audit findings
  documented inline — those are intentional locale-independent helpers
  (en-CA gives YYYY-MM-DD for day-comparison, en-US gives a parseable
  string for the toBerlinDate timezone hack), not user-facing display.
2026-04-27 00:28:28 +02:00
Marc-André Bombeck 908532be73 feat(security): SSRF strict IPv4, PAT logging redaction, threshold rate-limit, N+1 fix, pg-boss SIGTERM, blocking CI
Security hardening from 2026-04-26 audit:

- SEC-002: isPublicUrl() now uses a strict IPv4 parser that rejects
  leading-zero forms like 010.0.0.1, which historically slipped past
  the h.startsWith('10.') check. Also covers CGNAT (100.64/10),
  IPv6 unique-local (fc00::/7), and additional metadata-service edge
  cases. Documented as input-time only (DNS rebinding still possible
  at fetch time).
- SEC-004: GitHub PAT is now redacted from logged error bodies in
  publish-github.ts before flowing to Wide Events / Loki. Adds an
  escapeRegex helper for safe RegExp construction.
- SEC-006: PUT /api/user/thresholds now goes through checkRateLimit
  (30 writes / 5 min, per-user) — audit-log was already there but
  unbounded. Makes audit-log enumeration unattractive.
- PERF-001: insights/comprehensive route now batches medication-intake
  events into a single 'medicationId IN (...)' query and groups in
  memory instead of N+1 per medication. Latency was scaling poorly
  for users with many active meds.
- REL-003: pg-boss now installs SIGTERM/SIGINT handlers that call
  boss.stop({ graceful: true, timeout: 30s }) so in-flight handlers
  drain on docker stop / coolify redeploy instead of being killed.
- LOG-001: Replaced console.log/error in reminder-worker with a
  workerLog helper. Lifecycle errors still go to stderr (only audience
  outside any handler context); routine init messages are silent
  because pg-boss own events + Wide Events from handlers carry the
  state.
- CI: ESLint and TypeScript checks are now blocking — continue-on-error
  removed. Tests already were. Required pre-existing 'any' types in
  api-handler.ts to be properly justified with eslint-disable comments
  (Next.js variadic handler signature; the bound type still constrains
  return to Promise<Response>).
2026-04-27 00:28:28 +02:00
Marc-André Bombeck 11ed6faedd feat(measurements): add total body water and bone mass types (#89)
- Adds TOTAL_BODY_WATER and BONE_MASS to MeasurementType enum
- Migration 0022 (purely additive ALTER TYPE ... ADD VALUE)
- Both stored canonically in kilograms (matches Withings hydration/bone mass measures and Health Connect TotalBodyWaterRecord/BoneMassRecord)
- Validation, formatters, i18n keys (en + de) for measurement form and doctor-report PDF
- Dashboard widgets registered (default-invisible, opt-in via settings)
- Withings client maps measure types 77 (hydration) and 88 (bone mass)
- Doctor report PDF includes both new types in vital-signs table when data exists
2026-04-27 00:28:28 +02:00
Marc-André Bombeck 59448d473c chore(release): 1.2.1 2026-04-27 00:08:55 +02:00
Marc-André BombeckandClaude Opus 4.7 75d142df25 feat(ci): publish multi-arch Docker image to GHCR (#88)
Issue #88's biggest blocker was that docs and README pointed at
ghcr.io/mbombeck/healthlog:latest, but no workflow ever pushed an
image — every self-hoster trying to follow the docs hit "manifest
unknown".

This adds .github/workflows/docker-publish.yml. The workflow:

- Triggers on push to main, on v* tags, on PRs (build only, no push),
  and via workflow_dispatch
- Builds linux/amd64 + linux/arm64 multi-arch images via buildx
- Tags with `latest` (main only), semver (`1.2.0`, `1.2`), branch name,
  PR number, and short SHA — using docker/metadata-action defaults
- Attaches SLSA build provenance and an SBOM to every image
- Caches build layers in GitHub Actions cache for fast iterations
- Uses concurrency group + cancel-in-progress so superseded runs stop

The bundled docker-compose.yml now references
`ghcr.io/mbombeck/healthlog:latest` as the primary `image:` and keeps
`build:` as a fallback so contributors can still
`docker compose up --build` against their working tree.

SECURITY.md gains a Supply Chain section explaining where images come
from, how to verify provenance and SBOMs, and how to pin a specific
version. README's Quick Start updated to reflect that no build is
needed for self-hosters.

The first run of this workflow on main will publish the image; until
then `docker compose up` will fall back to the `build:` block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:38:08 +02:00
Marc-André BombeckandClaude Opus 4.7 5e721e0791 fix(onboarding): surface errors and supply default schedule (#87)
Issue #87 (kevinpapst): medications added during onboarding were
never visible afterwards. Root cause: the createMedication schema
requires schedules.min(1) ("Mindestens ein Zeitfenster"), but the
onboarding wizard sent schedules:[]. The 422 was silently swallowed
and the user was redirected to the dashboard as if everything had
worked.

This patch is the minimal-impact fix. The proper fix lives in Phase
3.5 of the audit plan, which replaces the wizard entirely with a
dashboard-first empty-state flow. Until then:

- finishOnboarding() wraps each call in a try/catch, checks
  response.ok explicitly, and surfaces a sonner toast on failure.
  setSaving(false) on error so the user can retry instead of being
  stuck on a spinner.
- Each medication is sent with a default schedule (08:00-09:00) so
  it is actually persisted. A new medScheduleHint i18n key is shown
  under the meds list while the user is in the wizard, so the
  default schedule is not surprising.
- Errors are translated per stage (errorProfile, errorMedication
  with {name}, errorTargets) plus an errorGeneric fallback. en.json
  and de.json kept symmetric.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:22:10 +02:00
Marc-André BombeckandClaude Opus 4.7 be7df4bc17 chore: bump to 1.2.0 and sync model count to 23
package.json was still on 1.1.0 even though CHANGELOG and the released
features (Custom Thresholds, Glucose, Dashboard Customization,
Built-in Feedback, Multi-Provider AI) shipped as 1.2.0 on 2026-04-18.

CLAUDE.md and AGENTS.md said 21 / 22 models in different places; the
schema actually has 23. Feedback (added in v1.2 for the in-app
bug-report flow) was missing from the model list in AGENTS.md, and
AGENTS.md still claimed status "Feature-complete (v1.0.0)".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:22:10 +02:00
Marc-André BombeckandClaude Opus 4.7 85f9a07daf fix(setup): make docker compose work out of the box (#88)
Issue #88 (kevinpapst): the bundled docker-compose.yml didn't actually
work. Three concrete blockers:

- expose:3000 → ports:"3000:3000" so the host can reach the app
- POSTGRES_PASSWORD was hard-coded to "healthlog" — now required via
  env var, and DATABASE_URL inside the compose service interpolates
  the same variable so the two cannot drift apart
- .env.example DATABASE_URL pointed at localhost:5432, which never
  resolves inside the app container; replaced with db:5432 plus a
  note explaining the local-dev variant

.env.example is also rewritten as proper documentation: each variable
explains what it does, why it matters, and how to generate it
(openssl rand -hex 32 for the three 32-byte secrets, openssl rand
-base64 24 for the Postgres password). Required vs optional are now
clearly grouped.

README 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 site.

Pre-built GHCR images and a docs-vs-repo single-source-of-truth for
docker-compose are tracked separately in Phase 2 of the audit plan
(see docs/audit/2026-04-26-PLAN.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:22:10 +02:00
Marc-André BombeckandClaude Opus 4.7 9d0983dc22 docs(audit): add 2026-04-26 full-audit plan
Captures the eight-phase remediation plan synthesized from the
2026-04-26 multi-agent audit covering Issues #87, #88, #89 and the
broader codebase health (security, perf, reliability, i18n, a11y,
test coverage, docs consistency).

Plan reorders the original sequence to do the typed apiClient refactor
(Phase 4) BEFORE the onboarding redesign (Phase 3.5), so the new flow
inherits the .ok-checked client wrapper and the silent-failure class
of bugs (root cause of #87) is structurally eliminated rather than
re-introduced.

Custom measurement types are explicitly deferred to v1.5+ via the
Cronometer pattern when user demand crosses a threshold; v1.3 ships
TBW + Bone Mass hardcoded (#89, Phase 3) following the v1.2 glucose
blueprint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:22:10 +02:00
dependabot[bot] 068c40270f build(deps): bump lucide-react from 1.8.0 to 1.11.0 (#101)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 1.8.0 to 1.11.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.11.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 16:02:51 +00:00
dependabot[bot] 8258f33d02 build(deps): bump pg-boss from 12.15.0 to 12.18.1 (#100)
Bumps [pg-boss](https://github.com/timgit/pg-boss) from 12.15.0 to 12.18.1.
- [Release notes](https://github.com/timgit/pg-boss/releases)
- [Commits](https://github.com/timgit/pg-boss/compare/12.15.0...12.18.1)

---
updated-dependencies:
- dependency-name: pg-boss
  dependency-version: 12.18.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 16:02:00 +00:00
dependabot[bot] 37af3e9e50 build(deps): bump @tanstack/react-query from 5.99.2 to 5.100.5 (#99)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.99.2 to 5.100.5.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.100.5/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.100.5
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 16:00:52 +00:00
dependabot[bot] 0081134cd7 build(deps): bump @prisma/client from 7.7.0 to 7.8.0 (#97)
Bumps [@prisma/client](https://github.com/prisma/prisma/tree/HEAD/packages/client) from 7.7.0 to 7.8.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.8.0/packages/client)

---
updated-dependencies:
- dependency-name: "@prisma/client"
  dependency-version: 7.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 16:00:31 +00:00
dependabot[bot] 50e23a38a1 build(deps-dev): bump shadcn from 4.3.0 to 4.5.0 (#93)
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.3.0 to 4.5.0.
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.5.0/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 15:59:01 +00:00
dependabot[bot] 6fab09041a build(deps-dev): bump @tailwindcss/postcss from 4.2.2 to 4.2.4 (#94)
Bumps [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) from 4.2.2 to 4.2.4.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/@tailwindcss-postcss)

---
updated-dependencies:
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 15:58:42 +00:00
dependabot[bot] bfb346db33 build(deps): bump react-hook-form from 7.72.1 to 7.74.0 (#98)
Bumps [react-hook-form](https://github.com/react-hook-form/react-hook-form) from 7.72.1 to 7.74.0.
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.72.1...v7.74.0)

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-version: 7.74.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 15:58:05 +00:00
dependabot[bot] 46aa01bb8a build(deps-dev): bump prettier-plugin-tailwindcss from 0.7.2 to 0.7.3 (#96)
Bumps [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) from 0.7.2 to 0.7.3.
- [Release notes](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.7.2...v0.7.3)

---
updated-dependencies:
- dependency-name: prettier-plugin-tailwindcss
  dependency-version: 0.7.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 15:56:53 +00:00
dependabot[bot] 922648d375 build(deps-dev): bump tailwindcss from 4.2.2 to 4.2.4 (#95)
Bumps [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) from 4.2.2 to 4.2.4.
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.2.4/packages/tailwindcss)

---
updated-dependencies:
- dependency-name: tailwindcss
  dependency-version: 4.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 15:56:32 +00:00
dependabot[bot] 4655a1b030 build(deps-dev): bump vitest from 4.1.4 to 4.1.5 (#92)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.4 to 4.1.5.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.5/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 15:55:15 +00:00
dependabot[bot] 3cfde9fa7a build(deps): bump prisma from 7.7.0 to 7.8.0 (#91)
Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 7.7.0 to 7.8.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.8.0/packages/cli)

---
updated-dependencies:
- dependency-name: prisma
  dependency-version: 7.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 15:54:45 +00:00
dependabot[bot] d7cd2bc3e6 build(deps): bump @prisma/adapter-pg from 7.7.0 to 7.8.0 (#90)
Bumps [@prisma/adapter-pg](https://github.com/prisma/prisma/tree/HEAD/packages/adapter-pg) from 7.7.0 to 7.8.0.
- [Release notes](https://github.com/prisma/prisma/releases)
- [Commits](https://github.com/prisma/prisma/commits/7.8.0/packages/adapter-pg)

---
updated-dependencies:
- dependency-name: "@prisma/adapter-pg"
  dependency-version: 7.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 15:53:53 +00:00
dependabot[bot] 38bfb0520d build(deps): bump react-dom from 19.2.4 to 19.2.5 (#85)
Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) from 19.2.4 to 19.2.5.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-19 16:03:03 +00:00
dependabot[bot] 588b7c66c1 build(deps-dev): bump typescript from 6.0.2 to 6.0.3 (#86)
Bumps [typescript](https://github.com/microsoft/TypeScript) from 6.0.2 to 6.0.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v6.0.2...v6.0.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-19 16:00:44 +00:00
dependabot[bot] 6a159e7ac3 build(deps): bump dotenv from 17.4.1 to 17.4.2 (#83)
Bumps [dotenv](https://github.com/motdotla/dotenv) from 17.4.1 to 17.4.2.
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v17.4.1...v17.4.2)

---
updated-dependencies:
- dependency-name: dotenv
  dependency-version: 17.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-19 15:59:25 +00:00
dependabot[bot] 1c13a16d49 build(deps): bump lucide-react from 1.7.0 to 1.8.0 (#82)
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 1.7.0 to 1.8.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.8.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-19 15:58:48 +00:00
dependabot[bot] f4c983c8ba build(deps-dev): bump shadcn from 4.2.0 to 4.3.0 (#81)
Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.2.0 to 4.3.0.
- [Release notes](https://github.com/shadcn-ui/ui/releases)
- [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md)
- [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.3.0/packages/shadcn)

---
updated-dependencies:
- dependency-name: shadcn
  dependency-version: 4.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-19 15:58:42 +00:00
dependabot[bot] 784b62b656 build(deps): bump @tanstack/react-query from 5.99.0 to 5.99.2 (#84)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.99.0 to 5.99.2.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.99.2/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.99.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-19 15:57:16 +00:00
dependabot[bot] adcacfd1a4 build(deps-dev): bump prettier from 3.8.2 to 3.8.3 (#80)
Bumps [prettier](https://github.com/prettier/prettier) from 3.8.2 to 3.8.3.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.8.2...3.8.3)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.8.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-19 15:56:33 +00:00
dependabot[bot] fd6170618a build(deps): bump next from 16.2.3 to 16.2.4 (#79)
Bumps [next](https://github.com/vercel/next.js) from 16.2.3 to 16.2.4.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.3...v16.2.4)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-19 15:54:23 +00:00
dependabot[bot] 2e8279a2fd build(deps-dev): bump eslint-config-next from 16.2.3 to 16.2.4 (#78)
Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.2.3 to 16.2.4.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.4/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-version: 16.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-19 15:53:47 +00:00
39 changed files with 2514 additions and 1325 deletions

No files matched your search

+92 -23
View File
@@ -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=""
+78
View File
@@ -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
+3 -1
View File
@@ -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
+3 -3
View File
@@ -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
+135
View File
@@ -1,5 +1,140 @@
# Changelog
## [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
+2 -2
View File
@@ -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
+87 -81
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+235
View File
@@ -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)
+12 -1
View File
@@ -89,6 +89,8 @@
"typeBodyFat": "Körperfettanteil",
"typeSleep": "Schlafdauer",
"typeSteps": "Aktivität (Schritte)",
"typeTotalBodyWater": "Gesamtkörperwasser",
"typeBoneMass": "Knochenmasse",
"unitSteps": "Schritte",
"bmiUnderweight": "Untergewicht",
"bmiNormal": "Normalgewicht",
@@ -243,6 +245,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)",
@@ -819,7 +823,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 +1107,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",
+12 -1
View File
@@ -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",
@@ -243,6 +245,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)",
@@ -819,7 +823,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 +1107,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",
+16 -16
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.1.0",
"version": "1.3.1",
"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": [
+970 -993
View File
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';
+2
View File
@@ -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
View File
@@ -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>
)}
+2
View File
@@ -29,6 +29,8 @@ const widgetIdEnum = z.enum([
"sleep",
"steps",
"glucose",
"totalBodyWater",
"boneMass",
"bpInTarget",
]);
+23 -8
View File
@@ -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
View File
@@ -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;
+87 -51
View File
@@ -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")}
+7
View File
@@ -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]);
+10 -4
View File
@@ -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>
)}
+19 -2
View File
@@ -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>
+1
View File
@@ -37,6 +37,7 @@ export function PasswordStrength({
useEffect(() => {
if (!password) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setResult(null);
return;
}
+7
View File
@@ -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);
+5 -3
View File
@@ -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 (
+14 -16
View File
@@ -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()),
+17 -1
View File
@@ -74,6 +74,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.
@@ -146,7 +155,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,
+45 -8
View File
@@ -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 -4
View File
@@ -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) ||
+143 -23
View File
@@ -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("")),
+2
View File
@@ -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 {