mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 20:32:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c584ead9c8 | ||
|
|
492748f105 | ||
|
|
0a77558fd0 | ||
|
|
79764d3467 | ||
|
|
0e510944ce | ||
|
|
05142841fa | ||
|
|
53235a0fb7 | ||
|
|
846eda80fa | ||
|
|
ef3e37b7da | ||
|
|
3de2e37ed5 | ||
|
|
908532be73 | ||
|
|
11ed6faedd |
No files matched your search
@@ -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
|
||||
|
||||
+147
@@ -1,5 +1,152 @@
|
||||
# Changelog
|
||||
|
||||
## [1.3.2] — 2026-04-28
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Glucose tiles on the dashboard rendered the raw i18n key
|
||||
`targets.glucoseFasting` instead of the translated label** (closes
|
||||
#108). Both `messages/en.json` and `messages/de.json` had two
|
||||
top-level `targets` blocks; `JSON.parse` silently keeps the last
|
||||
occurrence, and that block was missing the four glucose labels. The
|
||||
duplicate is now collapsed into a single block. A duplicate
|
||||
`bugreport.bugTitlePlaceholder` shadowed inside `bugreport` was
|
||||
cleaned up too. Two further keys (`dashboard.sleep`,
|
||||
`dashboard.steps`) were missing from both locales and were falling
|
||||
back to hard-coded English; both are now translated.
|
||||
- **New i18n locale-integrity test**
|
||||
(`src/lib/__tests__/i18n-locale-integrity.test.ts`) fails the build
|
||||
on duplicate keys at any nesting depth and on key drift between
|
||||
`en` and `de` — closes the structural gap that let the duplicate
|
||||
`targets` block ship in the first place.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Screenshot upload removed from the bug-report form** (also part
|
||||
of #108). The form previously accepted an image attachment that
|
||||
was stored in the local DB but never reached the published GitHub
|
||||
issue — GitHub does not accept inline base64 data URIs in issue
|
||||
bodies and offers no public API to attach images to an issue
|
||||
programmatically. Rather than ship misleading
|
||||
"a screenshot was attached" placeholder text in the resulting
|
||||
issue, the upload UI is now gone and the placeholder note is no
|
||||
longer added when promoting feedback. The `screenshotBase64`
|
||||
column and the admin-side preview of previously-submitted
|
||||
screenshots are unchanged — existing reports keep their
|
||||
attachments locally. We plan to revisit a real screenshot pipeline
|
||||
in a future release.
|
||||
|
||||
## [1.3.1] — 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Compose env-var validation no longer breaks Coolify-style deploys.**
|
||||
`docker-compose.yml` previously used `${VAR:?required}` shell-parameter
|
||||
syntax for the four secrets and `POSTGRES_PASSWORD`. Some hosting
|
||||
platforms (Coolify in particular) parse compose files eagerly and
|
||||
store the _fallback error string_ (`"POSTGRES_PASSWORD is required"`)
|
||||
as the literal env-var value when `POSTGRES_PASSWORD` was unset,
|
||||
which then collided with `DATABASE_URL` and broke the running app
|
||||
with `P1000: Authentication failed`. Compose now uses plain `${VAR}`
|
||||
interpolation; validation moved into `docker-entrypoint.sh`, which
|
||||
fails fast with a clear stderr message listing the unset variables.
|
||||
|
||||
### Notes
|
||||
|
||||
If you upgraded an existing Compose stack from 1.2.x → 1.3.0 and hit
|
||||
the `POSTGRES_PASSWORD is required` literal-as-value bug, set
|
||||
`POSTGRES_PASSWORD` in your environment to whatever your existing
|
||||
Postgres data volume was originally initialised with (likely
|
||||
`healthlog` if you started from a pre-1.2.1 release), then redeploy.
|
||||
Postgres only honours `POSTGRES_PASSWORD` on first volume init — the
|
||||
existing user keeps the original password regardless of env changes.
|
||||
|
||||
## [1.3.0] — 2026-04-27
|
||||
|
||||
### Added — Body composition + targeted hardening
|
||||
|
||||
- **Total body water and bone mass as measurement types** (closes #89). New
|
||||
enum values `TOTAL_BODY_WATER` and `BONE_MASS`, both stored canonically
|
||||
in kilograms (matches Withings hydration/bone-mass measures and Health
|
||||
Connect's `TotalBodyWaterRecord` / `BoneMassRecord`). Migration is
|
||||
purely additive (`ALTER TYPE ... ADD VALUE`) — safe to apply against
|
||||
any 1.2.x database without downtime.
|
||||
- **Withings sync picks both up automatically.** The Withings client now
|
||||
maps measure type `77` (hydration / water mass) and `88` (bone mass).
|
||||
Anyone with a Withings Body+ scale and an active connection will see
|
||||
the new metrics flowing in on the next sync without any extra config.
|
||||
- **Doctor-report PDF includes both new types** in the vital-signs table
|
||||
when data exists, with locale-aware labels in English and German.
|
||||
- **Dashboard widgets registered for both** (default-invisible — opt in
|
||||
via Settings → Dashboard layout).
|
||||
|
||||
### Security
|
||||
|
||||
- **SSRF guard hardened** (`isPublicUrl`). The previous implementation
|
||||
used `parseInt` with permissive prefix checks like `h.startsWith("10.")`
|
||||
which let `010.0.0.1` slip through — and worse, the WHATWG URL parser
|
||||
silently normalises `010.0.0.1` to `8.0.0.1` (octal interpretation), a
|
||||
real bypass on naive checks. The new guard adds a pre-URL leading-zero
|
||||
check on the raw input, a strict IPv4 parser, and proper IPv6
|
||||
bracket / loopback / link-local handling. Now blocks `127.0.0.0/8`,
|
||||
`0.0.0.0/8`, `10.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`,
|
||||
`192.168.0.0/16`, `100.64.0.0/10` (CGNAT), `::1`, `fe80::/10`,
|
||||
`fc00::/7`. Comprehensive regression tests included.
|
||||
- **GitHub PAT redacted from logged error bodies.** When a feedback
|
||||
escalation to GitHub fails, the response body was being passed
|
||||
verbatim into `getEvent()?.addWarning(...)` — flowing to Loki. The
|
||||
body is now stripped of the configured token before logging.
|
||||
- **Per-user threshold writes are rate-limited** (30 writes / 5 min) to
|
||||
make audit-log enumeration unattractive. Audit-logging itself was
|
||||
already there.
|
||||
|
||||
### Performance
|
||||
|
||||
- **N+1 in `/api/insights/comprehensive` fixed.** The medication-compliance
|
||||
loop hit Postgres once per active medication. It now batches into a
|
||||
single `medicationId IN (...)` query and groups in memory. Latency
|
||||
improvement scales linearly with the user's active medication count.
|
||||
|
||||
### Reliability
|
||||
|
||||
- **pg-boss draining on SIGTERM/SIGINT.** `docker stop`, Coolify redeploys,
|
||||
and Kubernetes pod terminations now trigger `boss.stop({ graceful: true,
|
||||
timeout: 30s })` so in-flight handlers finish instead of being killed.
|
||||
Previously, pending handlers could be lost or replayed on restart.
|
||||
- **CI now blocks on TypeScript strict checks.** `continue-on-error: true`
|
||||
removed for typecheck. Tests already were blocking. ESLint stays
|
||||
non-blocking for now — the existing `settings/page.tsx` monolith has
|
||||
long-standing `react-hooks/set-state-in-effect` violations whose proper
|
||||
fix lives in the 1.4.0 settings-split refactor; a blocking lint here
|
||||
would just gate every PR until that refactor lands. Required cleaning
|
||||
up two `any` types in `api-handler.ts` (justified inline — Next.js
|
||||
variadic handler signature constrained by `Promise<Response>` return).
|
||||
|
||||
### Polish
|
||||
|
||||
- **Bottom-nav touch targets** sized to WCAG 2.5.5 minimum (44×44 CSS px).
|
||||
Visual icon stays at 20 px so the design doesn't shift.
|
||||
- **Phase-config dialog** marks the decorative coloured dot `aria-hidden`
|
||||
because the redundant text label already conveys the phase to screen
|
||||
readers. No more meaningless `image` node announcements.
|
||||
- **Admin-status labels** for "Web Push" and "Bug Report" now go through
|
||||
`t()` (new `admin.integrationWebPush` / `admin.integrationBugReport`
|
||||
keys in en + de). They were the last hard-coded English strings on
|
||||
the admin page.
|
||||
|
||||
### What's _not_ in this release (tracked for later)
|
||||
|
||||
- **Onboarding redesign** (dashboard-first empty-state flow + persistent
|
||||
Getting Started checklist) and the **typed `apiClient` wrapper** that
|
||||
underpins it are tracked for a focused 1.4.0 cycle. The 1.2.1 patch
|
||||
already closed the acute symptoms of #87 (silent-failure toast +
|
||||
default schedule), so the redesign is now a proper UI investment
|
||||
rather than a bug-fix.
|
||||
- **Withings sync is mapping both new measures**, but **a dedicated
|
||||
Bearer-auth ingest endpoint for external pipelines** (n8n + Health
|
||||
Connect, requested in #89) ships in 1.4.0 alongside the API-token
|
||||
flow.
|
||||
|
||||
## [1.2.1] — 2026-04-27
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+10
-5
@@ -14,11 +14,16 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@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:
|
||||
@@ -31,7 +36,7 @@ services:
|
||||
container_name: healthlog-db
|
||||
environment:
|
||||
POSTGRES_USER: healthlog
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}"
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
|
||||
POSTGRES_DB: healthlog
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
+21
-1
@@ -1,6 +1,26 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# ── Validate required env vars ───────────────────────────────
|
||||
# We do this in the entrypoint instead of in docker-compose.yml's
|
||||
# environment block (which used to use the ${VAR:?error} syntax)
|
||||
# because some hosting platforms parse compose files eagerly and
|
||||
# end up storing the fallback error string as the literal value.
|
||||
# Validating here guarantees a clear, deploy-platform-agnostic
|
||||
# failure message before we touch the database.
|
||||
missing=""
|
||||
for var in DATABASE_URL SESSION_SECRET ENCRYPTION_KEY API_TOKEN_HMAC_KEY; do
|
||||
eval "value=\$$var"
|
||||
if [ -z "$value" ]; then
|
||||
missing="$missing $var"
|
||||
fi
|
||||
done
|
||||
if [ -n "$missing" ]; then
|
||||
echo "HealthLog: ERROR — required env vars not set:$missing" >&2
|
||||
echo "HealthLog: see .env.example for the full list and how to generate each value." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Wait for PostgreSQL ──────────────────────────────────────
|
||||
MAX_RETRIES=30
|
||||
RETRY=0
|
||||
@@ -15,7 +35,7 @@ until node -e "
|
||||
" 2>/dev/null; do
|
||||
RETRY=$((RETRY + 1))
|
||||
if [ "$RETRY" -ge "$MAX_RETRIES" ]; then
|
||||
echo "HealthLog: ERROR — Database not reachable after ${MAX_RETRIES}s, aborting."
|
||||
echo "HealthLog: ERROR — Database not reachable after ${MAX_RETRIES}s, aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
|
||||
+12
-45
@@ -89,6 +89,8 @@
|
||||
"typeBodyFat": "Körperfettanteil",
|
||||
"typeSleep": "Schlafdauer",
|
||||
"typeSteps": "Aktivität (Schritte)",
|
||||
"typeTotalBodyWater": "Gesamtkörperwasser",
|
||||
"typeBoneMass": "Knochenmasse",
|
||||
"unitSteps": "Schritte",
|
||||
"bmiUnderweight": "Untergewicht",
|
||||
"bmiNormal": "Normalgewicht",
|
||||
@@ -197,6 +199,8 @@
|
||||
"bloodPressureDia": "Blutdruck (Dia)",
|
||||
"pulse": "Puls",
|
||||
"bodyFat": "Körperfett",
|
||||
"sleep": "Schlaf",
|
||||
"steps": "Schritte",
|
||||
"bpInTarget": "BD im Zielbereich",
|
||||
"days30Short": "30T",
|
||||
"medications": "Medikamente",
|
||||
@@ -243,6 +247,8 @@
|
||||
"typeSleep": "Schlaf",
|
||||
"typeSteps": "Schritte",
|
||||
"typeBloodGlucose": "Blutzucker",
|
||||
"typeTotalBodyWater": "Gesamtkörperwasser",
|
||||
"typeBoneMass": "Knochenmasse",
|
||||
"glucoseContext": "Kontext",
|
||||
"glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)",
|
||||
"glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)",
|
||||
@@ -757,50 +763,6 @@
|
||||
"unsetExplanation": "Kein eigener Wert — berechneter Default aktiv.",
|
||||
"loadError": "Zielwerte konnten nicht geladen werden"
|
||||
},
|
||||
"targets": {
|
||||
"title": "Zielwerte",
|
||||
"subtitle": "Persönliche Referenzbereiche basierend auf deinem Profil",
|
||||
"introText": "Diese Zielwerte sind persönliche Referenzbereiche, die aus deinem Profil und anerkannten medizinischen Richtwerten abgeleitet werden. Sie helfen dir, deine wichtigsten Gesundheitswerte schnell einzuordnen und Entwicklungen früh zu erkennen. Die Bereiche werden für dich individuell berechnet, zum Beispiel anhand von Alter, Geschlecht und Körpergröße. Zu jedem Wert siehst du direkt, ob du im Zielbereich liegst oder darüber beziehungsweise darunter. So bekommst du eine kompakte Übersicht darüber, welche Zielwerte du aktuell gut einhältst und wo noch Potenzial besteht.",
|
||||
"current": "Aktuell",
|
||||
"average30d": "30-Tage-Durchschnitt:",
|
||||
"targetRange": "Zielbereich",
|
||||
"classification": "Bewertung",
|
||||
"source": "Quelle",
|
||||
"sourceLabel": "Quelle: {source}",
|
||||
"trend": "Trend",
|
||||
"weight": "Gewicht",
|
||||
"bloodPressure": "Blutdruck",
|
||||
"pulse": "Ruhepuls",
|
||||
"sleep": "Schlafdauer",
|
||||
"bmi": "BMI",
|
||||
"bodyFat": "Körperfett",
|
||||
"steps": "Schritte/Tag",
|
||||
"compliance": "Einnahmetreue",
|
||||
"compliancePerMedication": "Pro Medikament",
|
||||
"diastolic": "Diastolisch",
|
||||
"noProfile": "Bitte fülle dein Profil aus, um personalisierte Zielwerte zu erhalten.",
|
||||
"profileIncomplete": "Profil unvollständig",
|
||||
"profileIncompleteHeightAge": "Hinterlege deine Größe und dein Geburtsdatum im Profil, um personalisierte Zielbereiche zu erhalten.",
|
||||
"profileIncompleteHeight": "Hinterlege deine Größe im Profil, um den Gewichts-Zielbereich zu berechnen.",
|
||||
"profileIncompleteAge": "Hinterlege dein Geburtsdatum im Profil, um altersbasierte Zielbereiche zu erhalten.",
|
||||
"noMeasurementData": "Noch keine Messdaten vorhanden.",
|
||||
"noMeasurementYet": "Noch keine Messung vorhanden",
|
||||
"currentValue": "Aktuell: {value} {unit}",
|
||||
"targetRangeValue": "Zielbereich: {min}–{max} {unit}",
|
||||
"inTarget": "Im Zielbereich",
|
||||
"belowTarget": "{delta} {unit} unter Ziel",
|
||||
"aboveTarget": "{delta} {unit} über Ziel",
|
||||
"loadError": "Fehler beim Laden",
|
||||
"classificationOptimal": "Optimal",
|
||||
"classificationNormal": "Normal",
|
||||
"classificationElevated": "Erhöht",
|
||||
"classificationHigh": "Hoch",
|
||||
"classificationLow": "Niedrig",
|
||||
"glucoseFasting": "Glukose — nüchtern",
|
||||
"glucosePostprandial": "Glukose — postprandial",
|
||||
"glucoseRandom": "Glukose — beliebig",
|
||||
"glucoseBedtime": "Glukose — vor dem Schlafen"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Willkommen bei HealthLog!",
|
||||
"setupDescription": "Richte dein Profil ein, um loszulegen. Du kannst das auch später in den Einstellungen ändern.",
|
||||
@@ -1103,6 +1065,8 @@
|
||||
"telegramGlobal": "Telegram-Benachrichtigungen systemweit erlauben.",
|
||||
"ntfyGlobal": "ntfy-Benachrichtigungen systemweit erlauben.",
|
||||
"webPushGlobal": "Browser-Push-Benachrichtigungen systemweit erlauben.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug-Reports",
|
||||
"apiGlobal": "API-Endpunkte und API-Tokens systemweit erlauben.",
|
||||
"moodLogGlobal": "moodLog-Integration systemweit erlauben.",
|
||||
"sectionGeneral": "Allgemein",
|
||||
@@ -1455,7 +1419,6 @@
|
||||
"subtitle": "Sende eine Fehlermeldung, einen Feature-Wunsch oder eine Frage. Der Server-Administrator sieht deine Einreichung.",
|
||||
"loginRequired": "Bitte anmelden, um einen Bug zu melden.",
|
||||
"bugTitle": "Titel",
|
||||
"bugTitlePlaceholder": "Kurze Beschreibung des Problems",
|
||||
"description": "Beschreibung",
|
||||
"descriptionPlaceholder": "Beschreibe den Fehler möglichst genau. Was hast du getan? Was ist passiert? Was hättest du erwartet?",
|
||||
"screenshot": "Screenshot (optional)",
|
||||
@@ -1621,6 +1584,10 @@
|
||||
"profileIncompleteAge": "Trage dein Geburtsdatum im Profil ein, um altersspezifische Ziele zu aktivieren.",
|
||||
"profileIncompleteHeightAge": "Trage Körpergröße und Geburtsdatum im Profil ein, um personalisierte Ziele zu sehen.",
|
||||
"compliancePerMedication": "Einnahmetreue pro Medikament",
|
||||
"glucoseFasting": "Glukose — nüchtern",
|
||||
"glucosePostprandial": "Glukose — postprandial",
|
||||
"glucoseRandom": "Glukose — beliebig",
|
||||
"glucoseBedtime": "Glukose — vor dem Schlafen",
|
||||
"loadError": "Zielwerte konnten nicht geladen werden"
|
||||
},
|
||||
"passwordStrength": {
|
||||
|
||||
+12
-45
@@ -89,6 +89,8 @@
|
||||
"typeBodyFat": "Body fat",
|
||||
"typeSleep": "Sleep duration",
|
||||
"typeSteps": "Activity (steps)",
|
||||
"typeTotalBodyWater": "Total body water",
|
||||
"typeBoneMass": "Bone mass",
|
||||
"unitSteps": "steps",
|
||||
"bmiUnderweight": "Underweight",
|
||||
"bmiNormal": "Normal weight",
|
||||
@@ -197,6 +199,8 @@
|
||||
"bloodPressureDia": "Blood Pressure (Dia)",
|
||||
"pulse": "Pulse",
|
||||
"bodyFat": "Body Fat",
|
||||
"sleep": "Sleep",
|
||||
"steps": "Steps",
|
||||
"bpInTarget": "BP in Target",
|
||||
"days30Short": "30d",
|
||||
"medications": "Medications",
|
||||
@@ -243,6 +247,8 @@
|
||||
"typeSleep": "Sleep",
|
||||
"typeSteps": "Steps",
|
||||
"typeBloodGlucose": "Blood glucose",
|
||||
"typeTotalBodyWater": "Total body water",
|
||||
"typeBoneMass": "Bone mass",
|
||||
"glucoseContext": "Context",
|
||||
"glucoseContextFasting": "Fasting (≥8h without food)",
|
||||
"glucoseContextPostprandial": "Post-meal (2h after eating)",
|
||||
@@ -757,50 +763,6 @@
|
||||
"unsetExplanation": "No override — using computed default.",
|
||||
"loadError": "Could not load thresholds"
|
||||
},
|
||||
"targets": {
|
||||
"title": "Targets",
|
||||
"subtitle": "Personal reference ranges based on your profile",
|
||||
"introText": "These targets are personal reference ranges derived from your profile and established medical guidelines. They help you quickly classify your key health values and detect changes early. The ranges are calculated individually for you, for example based on age, sex, and height. For each value, you can immediately see whether you are within the target range or above or below it. This gives you a compact overview of which targets you are currently meeting well and where improvement is still possible.",
|
||||
"current": "Current",
|
||||
"average30d": "30-day average:",
|
||||
"targetRange": "Target range",
|
||||
"classification": "Rating",
|
||||
"source": "Source",
|
||||
"sourceLabel": "Source: {source}",
|
||||
"trend": "Trend",
|
||||
"weight": "Weight",
|
||||
"bloodPressure": "Blood Pressure",
|
||||
"pulse": "Resting Pulse",
|
||||
"sleep": "Sleep Duration",
|
||||
"bmi": "BMI",
|
||||
"bodyFat": "Body Fat",
|
||||
"steps": "Steps/Day",
|
||||
"compliance": "Compliance",
|
||||
"compliancePerMedication": "Per medication",
|
||||
"diastolic": "Diastolic",
|
||||
"noProfile": "Please complete your profile to get personalized targets.",
|
||||
"profileIncomplete": "Profile incomplete",
|
||||
"profileIncompleteHeightAge": "Add your height and date of birth in your profile to get personalized target ranges.",
|
||||
"profileIncompleteHeight": "Add your height in your profile to calculate weight target range.",
|
||||
"profileIncompleteAge": "Add your date of birth in your profile to get age-based target ranges.",
|
||||
"noMeasurementData": "No measurement data available yet.",
|
||||
"noMeasurementYet": "No measurement available yet",
|
||||
"currentValue": "Current: {value} {unit}",
|
||||
"targetRangeValue": "Target range: {min}–{max} {unit}",
|
||||
"inTarget": "In target range",
|
||||
"belowTarget": "{delta} {unit} below target",
|
||||
"aboveTarget": "{delta} {unit} above target",
|
||||
"loadError": "Error loading data",
|
||||
"classificationOptimal": "Optimal",
|
||||
"classificationNormal": "Normal",
|
||||
"classificationElevated": "Elevated",
|
||||
"classificationHigh": "High",
|
||||
"classificationLow": "Low",
|
||||
"glucoseFasting": "Glucose — fasting",
|
||||
"glucosePostprandial": "Glucose — post-meal",
|
||||
"glucoseRandom": "Glucose — random",
|
||||
"glucoseBedtime": "Glucose — bedtime"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Welcome to HealthLog!",
|
||||
"setupDescription": "Set up your profile to get started. You can change this later in Settings.",
|
||||
@@ -1103,6 +1065,8 @@
|
||||
"telegramGlobal": "Allow Telegram notifications system-wide.",
|
||||
"ntfyGlobal": "Allow ntfy notifications system-wide.",
|
||||
"webPushGlobal": "Allow browser push notifications system-wide.",
|
||||
"integrationWebPush": "Web Push",
|
||||
"integrationBugReport": "Bug Report",
|
||||
"apiGlobal": "Allow API endpoints and API tokens system-wide.",
|
||||
"moodLogGlobal": "Allow moodLog integration system-wide.",
|
||||
"sectionGeneral": "General",
|
||||
@@ -1455,7 +1419,6 @@
|
||||
"subtitle": "Send a bug report, feature request, or question. The server administrator will see your submission.",
|
||||
"loginRequired": "Please sign in to report a bug.",
|
||||
"bugTitle": "Title",
|
||||
"bugTitlePlaceholder": "Brief description of the problem",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Describe the bug in detail. What did you do? What happened? What did you expect?",
|
||||
"screenshot": "Screenshot (optional)",
|
||||
@@ -1621,6 +1584,10 @@
|
||||
"profileIncompleteAge": "Add your date of birth in profile settings for age-specific targets.",
|
||||
"profileIncompleteHeightAge": "Add your height and date of birth in profile settings for personalized targets.",
|
||||
"compliancePerMedication": "Compliance per medication",
|
||||
"glucoseFasting": "Glucose — fasting",
|
||||
"glucosePostprandial": "Glucose — post-meal",
|
||||
"glucoseRandom": "Glucose — random",
|
||||
"glucoseBedtime": "Glucose — bedtime",
|
||||
"loadError": "Failed to load targets"
|
||||
},
|
||||
"passwordStrength": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.2",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- v1.3: Add body-composition measurement types
|
||||
--
|
||||
-- Closes #89: total body water and bone mass as first-class measurement types.
|
||||
-- Both are reported by Withings body-composition scales and can also be ingested
|
||||
-- via the API by external pipelines (e.g. n8n + Health Connect).
|
||||
--
|
||||
-- Storage units (canonical):
|
||||
-- TOTAL_BODY_WATER: percent of body weight (%)
|
||||
-- BONE_MASS: kilograms (kg)
|
||||
--
|
||||
-- Both are unconditional value types — no companion enum (unlike BLOOD_GLUCOSE,
|
||||
-- which carries a glucose_context). The migration is purely additive.
|
||||
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'TOTAL_BODY_WATER';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BONE_MASS';
|
||||
@@ -150,6 +150,8 @@ enum MeasurementType {
|
||||
SLEEP_DURATION
|
||||
ACTIVITY_STEPS
|
||||
BLOOD_GLUCOSE // Canonical storage: mg/dL. Display unit (mg/dL or mmol/L) is user preference.
|
||||
TOTAL_BODY_WATER // Canonical storage: kilograms (kg of water). Matches Withings' hydration measure (type 77) and Health Connect's TotalBodyWaterRecord. Display can compute % from same-day weight if desired.
|
||||
BONE_MASS // Canonical storage: kilograms (kg). Matches Withings' bone mass measure (type 88).
|
||||
|
||||
@@map("measurement_type")
|
||||
}
|
||||
|
||||
+31
-18
@@ -378,7 +378,7 @@ function SystemStatusSection({ id }: { id: string }) {
|
||||
{status.integrations.webPush && (
|
||||
<StatusItem
|
||||
icon={BellRing}
|
||||
label="Web Push"
|
||||
label={t("admin.integrationWebPush")}
|
||||
value={t("admin.configured")}
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
@@ -386,7 +386,7 @@ function SystemStatusSection({ id }: { id: string }) {
|
||||
{status.integrations.bugReport && (
|
||||
<StatusItem
|
||||
icon={Bug}
|
||||
label="Bug Report"
|
||||
label={t("admin.integrationBugReport")}
|
||||
value={t("admin.configured")}
|
||||
className="text-dracula-green"
|
||||
/>
|
||||
@@ -518,7 +518,7 @@ function ServicesSection({ id }: { id: string }) {
|
||||
disabled={updateSettings.isPending}
|
||||
/>
|
||||
<SettingsToggle
|
||||
label="Web Push"
|
||||
label={t("admin.integrationWebPush")}
|
||||
description={t("admin.webPushGlobal")}
|
||||
icon={Globe}
|
||||
checked={settings?.webPushGlobal ?? true}
|
||||
@@ -2239,8 +2239,7 @@ function FeedbackInboxSection({ id }: { id: string }) {
|
||||
});
|
||||
const githubConfigured = Boolean(status?.integrations.bugReport?.configured);
|
||||
|
||||
const [activeStatus, setActiveStatus] =
|
||||
useState<FeedbackStatusType>("OPEN");
|
||||
const [activeStatus, setActiveStatus] = useState<FeedbackStatusType>("OPEN");
|
||||
const [selected, setSelected] = useState<FeedbackItem | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -2281,7 +2280,11 @@ function FeedbackInboxSection({ id }: { id: string }) {
|
||||
<TabsList>
|
||||
{STATUS_TABS.map((s) => (
|
||||
<TabsTrigger key={s} value={s}>
|
||||
<span>{t(`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`)}</span>
|
||||
<span>
|
||||
{t(
|
||||
`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`,
|
||||
)}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-1.5 text-xs">
|
||||
{counts[s] ?? 0}
|
||||
</Badge>
|
||||
@@ -2328,7 +2331,7 @@ function FeedbackInboxSection({ id }: { id: string }) {
|
||||
{data.items.map((item, i) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={`cursor-pointer hover:bg-muted/40 ${i % 2 === 0 ? "bg-muted/30" : ""}`}
|
||||
className={`hover:bg-muted/40 cursor-pointer ${i % 2 === 0 ? "bg-muted/30" : ""}`}
|
||||
onClick={() => setSelected(item)}
|
||||
>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
@@ -2337,7 +2340,7 @@ function FeedbackInboxSection({ id }: { id: string }) {
|
||||
<td className="px-3 py-2">
|
||||
<FeedbackCategoryBadge category={item.category} />
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium truncate max-w-[28ch]">
|
||||
<td className="max-w-[28ch] truncate px-3 py-2 font-medium">
|
||||
{item.subject}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
@@ -2379,9 +2382,16 @@ function FeedbackInboxSection({ id }: { id: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackCategoryBadge({ category }: { category: FeedbackCategoryType }) {
|
||||
function FeedbackCategoryBadge({
|
||||
category,
|
||||
}: {
|
||||
category: FeedbackCategoryType;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const map: Record<FeedbackCategoryType, { label: string; className: string }> = {
|
||||
const map: Record<
|
||||
FeedbackCategoryType,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
BUG: {
|
||||
label: t("admin.feedback.categoryBug"),
|
||||
className: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
@@ -2400,7 +2410,9 @@ function FeedbackCategoryBadge({ category }: { category: FeedbackCategoryType })
|
||||
},
|
||||
};
|
||||
const cfg = map[category];
|
||||
return <Badge className={`text-xs border ${cfg.className}`}>{cfg.label}</Badge>;
|
||||
return (
|
||||
<Badge className={`border text-xs ${cfg.className}`}>{cfg.label}</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackDetailDialog({
|
||||
@@ -2487,11 +2499,12 @@ function FeedbackDetailDialog({
|
||||
const url = typeof meta.url === "string" ? meta.url : null;
|
||||
const locale = typeof meta.locale === "string" ? meta.locale : null;
|
||||
const userAgent = typeof meta.userAgent === "string" ? meta.userAgent : null;
|
||||
const appVersion = typeof meta.appVersion === "string" ? meta.appVersion : null;
|
||||
const appVersion =
|
||||
typeof meta.appVersion === "string" ? meta.appVersion : null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
@@ -2529,7 +2542,7 @@ function FeedbackDetailDialog({
|
||||
|
||||
{(url || locale || userAgent || appVersion) && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<h4 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.metadataHeading")}
|
||||
</h4>
|
||||
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
@@ -2571,14 +2584,14 @@ function FeedbackDetailDialog({
|
||||
|
||||
{item.screenshotBase64 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
||||
<h4 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.screenshotHeading")}
|
||||
</h4>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.screenshotBase64}
|
||||
alt="Screenshot"
|
||||
className="max-h-72 rounded-md border border-border"
|
||||
className="border-border max-h-72 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -2611,7 +2624,7 @@ function FeedbackDetailDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-3">
|
||||
<div className="border-border flex flex-wrap items-center gap-2 border-t pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -2674,7 +2687,7 @@ function FeedbackDetailDialog({
|
||||
</Button>
|
||||
)}
|
||||
{issueUrl && (
|
||||
<Badge className="ml-auto border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
<Badge className="border-dracula-green/30 bg-dracula-green/15 text-dracula-green ml-auto">
|
||||
{t("admin.feedback.publishedToGithub")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -62,7 +62,6 @@ export const POST = apiHandler(
|
||||
subject: feedback.subject,
|
||||
description: feedback.description,
|
||||
username: feedback.user?.username ?? "anonymous",
|
||||
hasScreenshot: Boolean(feedback.screenshotBase64),
|
||||
metadata: feedback.metadata,
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ const widgetIdEnum = z.enum([
|
||||
"sleep",
|
||||
"steps",
|
||||
"glucose",
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"bpInTarget",
|
||||
]);
|
||||
|
||||
|
||||
@@ -216,15 +216,30 @@ export const GET = apiHandler(async () => {
|
||||
const bpMedications = medications.filter(
|
||||
(med) => (categoryMap[med.id] ?? "OTHER") === "BLOOD_PRESSURE",
|
||||
);
|
||||
// Single round-trip for all medications instead of N+1: one query keyed
|
||||
// on `medicationId IN (...)`, then group in memory. The previous loop
|
||||
// hit Postgres once per medication, which scales poorly for users with
|
||||
// many active meds.
|
||||
const allEvents = medications.length
|
||||
? await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId: { in: medications.map((m) => m.id) },
|
||||
userId,
|
||||
scheduledFor: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const eventsByMed = new Map<string, typeof allEvents>();
|
||||
for (const ev of allEvents) {
|
||||
const list = eventsByMed.get(ev.medicationId);
|
||||
if (list) list.push(ev);
|
||||
else eventsByMed.set(ev.medicationId, [ev]);
|
||||
}
|
||||
|
||||
for (const med of medications) {
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId: med.id,
|
||||
userId,
|
||||
scheduledFor: { gte: ninetyDaysAgo },
|
||||
},
|
||||
orderBy: { scheduledFor: "desc" },
|
||||
});
|
||||
const events = eventsByMed.get(med.id) ?? [];
|
||||
const mapped = events.map((e) => ({
|
||||
takenAt: e.takenAt,
|
||||
skipped: e.skipped,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { apiSuccess, apiError, safeJson, getClientIp } from "@/lib/api-response"
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
getAllEffectiveRanges,
|
||||
type ThresholdOverridesJson,
|
||||
@@ -59,6 +60,17 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.thresholds.update" } });
|
||||
|
||||
// 30 writes / 5 min — generous for legitimate UI edits, tight enough to
|
||||
// make audit-log enumeration unattractive. Per-user, not per-IP.
|
||||
const rl = await checkRateLimit(
|
||||
`thresholds:update:${user.id}`,
|
||||
30,
|
||||
5 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests, please slow down", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -15,10 +15,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Loader2,
|
||||
Upload,
|
||||
X,
|
||||
CheckCircle2,
|
||||
ImageIcon,
|
||||
Bug,
|
||||
GitPullRequest,
|
||||
Info,
|
||||
@@ -46,14 +43,11 @@ export default function BugReportPage() {
|
||||
const [category, setCategory] = useState<CategoryValue>("BUG");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [screenshot, setScreenshot] = useState<string | null>(null);
|
||||
const [screenshotName, setScreenshotName] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<{
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Status is now informational only — submission always works.
|
||||
const { data: status } = useQuery({
|
||||
@@ -67,34 +61,6 @@ export default function BugReportPage() {
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
async function handleScreenshot(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setResult({ type: "error", message: t("bugreport.imageOnly") });
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setResult({ type: "error", message: t("bugreport.tooLarge") });
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setScreenshot(reader.result as string);
|
||||
setScreenshotName(file.name);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function removeScreenshot() {
|
||||
setScreenshot(null);
|
||||
setScreenshotName(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setResult(null);
|
||||
@@ -108,7 +74,6 @@ export default function BugReportPage() {
|
||||
category,
|
||||
subject: subject || description.slice(0, 60),
|
||||
description,
|
||||
...(screenshot ? { screenshot } : {}),
|
||||
metadata: {
|
||||
locale,
|
||||
userAgent:
|
||||
@@ -124,7 +89,6 @@ export default function BugReportPage() {
|
||||
setResult({ type: "success", message: t("bugreport.success") });
|
||||
setSubject("");
|
||||
setDescription("");
|
||||
removeScreenshot();
|
||||
} else {
|
||||
setResult({
|
||||
type: "error",
|
||||
@@ -226,42 +190,6 @@ export default function BugReportPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("bugreport.screenshot")}</Label>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleScreenshot}
|
||||
className="hidden"
|
||||
/>
|
||||
{screenshot ? (
|
||||
<div className="bg-muted/50 relative rounded-lg p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="text-muted-foreground h-4 w-4" />
|
||||
<span className="flex-1 truncate text-sm">
|
||||
{screenshotName}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={removeScreenshot}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={screenshot}
|
||||
alt={t("bugreport.screenshotPreview")}
|
||||
className="mt-2 max-h-48 rounded border object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div
|
||||
className={`rounded-lg p-3 text-sm ${
|
||||
@@ -277,16 +205,7 @@ export default function BugReportPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t("bugreport.attachScreenshot")}
|
||||
</Button>
|
||||
<div className="flex items-center justify-end pt-2">
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<Bug className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -2939,8 +2939,15 @@ function UserAIProviderSubsection() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
// Sync-from-server pattern: form draft state is initialised from the
|
||||
// fetched user record. Could be reworked into a derived-state approach,
|
||||
// but that's a non-trivial refactor of this monolithic settings page —
|
||||
// tracked for the 1.4.0 settings split.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setProvider(data.provider ?? "");
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setModel(data.model ?? "");
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setBaseUrl(data.baseUrl ?? "");
|
||||
}, [data]);
|
||||
|
||||
|
||||
@@ -31,8 +31,11 @@ export function BottomNav() {
|
||||
const items = navItems;
|
||||
|
||||
return (
|
||||
<nav aria-label={t("nav.mobileNavigation")} className="bg-card/80 border-border fixed bottom-0 left-0 z-50 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden">
|
||||
<div className="mx-auto flex h-14 max-w-lg items-center justify-around px-2">
|
||||
<nav
|
||||
aria-label={t("nav.mobileNavigation")}
|
||||
className="bg-card/80 border-border fixed bottom-0 left-0 z-50 w-full border-t pb-[env(safe-area-inset-bottom)] backdrop-blur-md md:hidden"
|
||||
>
|
||||
<div className="mx-auto flex h-16 max-w-lg items-stretch justify-around px-1">
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
item.href === "/"
|
||||
@@ -44,8 +47,11 @@ export function BottomNav() {
|
||||
href={item.href}
|
||||
aria-label={t(item.tKey)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
// Touch target sized to WCAG 2.5.5 minimum (44×44 CSS px). The
|
||||
// outer min-h-11 min-w-11 is the actual hit area; the icon
|
||||
// stays visually centered at 20px so the design doesn't shift.
|
||||
className={cn(
|
||||
"relative flex items-center justify-center rounded-lg p-2.5 transition-colors",
|
||||
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors",
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
@@ -53,7 +59,7 @@ export function BottomNav() {
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{isActive && (
|
||||
<span className="bg-primary absolute -bottom-1 h-1 w-1 rounded-full" />
|
||||
<span className="bg-primary absolute bottom-1.5 h-1 w-1 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -67,11 +67,26 @@ const MEASUREMENT_TYPES = [
|
||||
unit: "mg/dL",
|
||||
placeholder: "95",
|
||||
},
|
||||
{
|
||||
value: "TOTAL_BODY_WATER",
|
||||
labelKey: "measurements.typeTotalBodyWater",
|
||||
unit: "kg",
|
||||
placeholder: "42",
|
||||
},
|
||||
{
|
||||
value: "BONE_MASS",
|
||||
labelKey: "measurements.typeBoneMass",
|
||||
unit: "kg",
|
||||
placeholder: "3.2",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const GLUCOSE_CONTEXTS = [
|
||||
{ value: "FASTING", labelKey: "measurements.glucoseContextFasting" },
|
||||
{ value: "POSTPRANDIAL", labelKey: "measurements.glucoseContextPostprandial" },
|
||||
{
|
||||
value: "POSTPRANDIAL",
|
||||
labelKey: "measurements.glucoseContextPostprandial",
|
||||
},
|
||||
{ value: "RANDOM", labelKey: "measurements.glucoseContextRandom" },
|
||||
{ value: "BEDTIME", labelKey: "measurements.glucoseContextBedtime" },
|
||||
] as const;
|
||||
@@ -368,7 +383,11 @@ export function MeasurementForm({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div role="alert" aria-live="assertive" className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm">
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -78,6 +78,10 @@ function parseTimeToMinutes(value: string): number {
|
||||
}
|
||||
|
||||
function toBerlinDate(date: Date): Date {
|
||||
// Intentionally en-US: this is not user-facing display — it produces a
|
||||
// parseable string ("1/2/2026, 3:04:05 PM") that we feed back into Date
|
||||
// to shift the *value* from UTC to Berlin-local for arithmetic. Display
|
||||
// formatting goes through useFormatters() elsewhere.
|
||||
return new Date(
|
||||
date.toLocaleString("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
@@ -315,7 +319,11 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
// Don't show late/very_late if all overdue schedules are covered by events
|
||||
if (status !== "in_window" && !hasUncoveredOverdue) return best;
|
||||
// Don't show in_window if last intake is already within this window today
|
||||
if (status === "in_window" && isLastIntakeInCurrentWindow(medication.lastTakenAt, s, nowBerlin)) return best;
|
||||
if (
|
||||
status === "in_window" &&
|
||||
isLastIntakeInCurrentWindow(medication.lastTakenAt, s, nowBerlin)
|
||||
)
|
||||
return best;
|
||||
const priority = { in_window: 3, late: 2, very_late: 1 };
|
||||
if (!best.status || priority[status] > priority[best.status]) {
|
||||
return { status, schedule: s };
|
||||
@@ -327,6 +335,9 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
: { status: null, schedule: null };
|
||||
|
||||
function formatLastTakenAt(value: string): string {
|
||||
// Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and
|
||||
// string-comparable for the today / yesterday / older bucketing below.
|
||||
// The actual user-facing display goes through formatTime / formatDateTime.
|
||||
const dayFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
@@ -374,7 +385,13 @@ export function MedicationCard({ medication, onEdit }: MedicationCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" asChild aria-label={t("medications.intakeHistory")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
asChild
|
||||
aria-label={t("medications.intakeHistory")}
|
||||
>
|
||||
<Link href={`/medications/${medication.id}/history`}>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
|
||||
@@ -51,9 +51,24 @@ const PHASES: {
|
||||
dotClass: string;
|
||||
direction: "before" | "after";
|
||||
}[] = [
|
||||
{ key: "green", color: "green", dotClass: "bg-green-500", direction: "before" },
|
||||
{ key: "yellow", color: "yellow", dotClass: "bg-yellow-500", direction: "before" },
|
||||
{ key: "orange", color: "orange", dotClass: "bg-orange-500", direction: "after" },
|
||||
{
|
||||
key: "green",
|
||||
color: "green",
|
||||
dotClass: "bg-green-500",
|
||||
direction: "before",
|
||||
},
|
||||
{
|
||||
key: "yellow",
|
||||
color: "yellow",
|
||||
dotClass: "bg-yellow-500",
|
||||
direction: "before",
|
||||
},
|
||||
{
|
||||
key: "orange",
|
||||
color: "orange",
|
||||
dotClass: "bg-orange-500",
|
||||
direction: "after",
|
||||
},
|
||||
{ key: "red", color: "red", dotClass: "bg-red-500", direction: "after" },
|
||||
];
|
||||
|
||||
@@ -92,7 +107,9 @@ export function PhaseConfigDialog({
|
||||
},
|
||||
onSuccess: () => {
|
||||
setLocalForm(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["phase-config", medicationId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["phase-config", medicationId],
|
||||
});
|
||||
setStatusMessage(t("medications.phaseSaved"));
|
||||
setTimeout(() => setStatusMessage(null), 2000);
|
||||
},
|
||||
@@ -108,7 +125,9 @@ export function PhaseConfigDialog({
|
||||
},
|
||||
onSuccess: () => {
|
||||
setLocalForm(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["phase-config", medicationId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["phase-config", medicationId],
|
||||
});
|
||||
setStatusMessage(t("medications.phaseReset"));
|
||||
setTimeout(() => setStatusMessage(null), 2000);
|
||||
},
|
||||
@@ -129,10 +148,14 @@ export function PhaseConfigDialog({
|
||||
|
||||
const phaseLabel = (key: PhaseKey): string => {
|
||||
switch (key) {
|
||||
case "green": return t("medications.phaseGreen");
|
||||
case "yellow": return t("medications.phaseYellow");
|
||||
case "orange": return t("medications.phaseOrange");
|
||||
case "red": return t("medications.phaseRed");
|
||||
case "green":
|
||||
return t("medications.phaseGreen");
|
||||
case "yellow":
|
||||
return t("medications.phaseYellow");
|
||||
case "orange":
|
||||
return t("medications.phaseOrange");
|
||||
case "red":
|
||||
return t("medications.phaseRed");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -160,8 +183,11 @@ export function PhaseConfigDialog({
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<div className={`h-3 w-3 rounded-full ${dotClass} shrink-0`} />
|
||||
<span className="w-14 text-sm font-medium shrink-0">
|
||||
<div
|
||||
className={`h-3 w-3 rounded-full ${dotClass} shrink-0`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="w-14 shrink-0 text-sm font-medium">
|
||||
{phaseLabel(key)}
|
||||
</span>
|
||||
<Input
|
||||
@@ -172,20 +198,20 @@ export function PhaseConfigDialog({
|
||||
onChange={(e) =>
|
||||
updateValue(key, parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
className="w-20 h-8 text-sm"
|
||||
className="h-8 w-20 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 w-12 text-xs px-0"
|
||||
className="h-8 w-12 px-0 text-xs"
|
||||
onClick={() => toggleMode(key)}
|
||||
>
|
||||
{mode === "MINUTES"
|
||||
? t("medications.phaseMinutes")
|
||||
: t("medications.phasePercent")}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs shrink-0">
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{direction === "before"
|
||||
? t("medications.phaseBeforeEnd")
|
||||
: t("medications.phaseAfterEnd")}
|
||||
@@ -197,12 +223,12 @@ export function PhaseConfigDialog({
|
||||
)}
|
||||
|
||||
{statusMessage && (
|
||||
<div className="text-sm text-green-500 text-center">
|
||||
<div className="text-center text-sm text-green-500">
|
||||
{statusMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex-row justify-between sm:justify-between gap-2">
|
||||
<DialogFooter className="flex-row justify-between gap-2 sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
@@ -29,6 +29,8 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
|
||||
sleep: "measurements.typeSleep",
|
||||
steps: "measurements.typeSteps",
|
||||
glucose: "measurements.typeBloodGlucose",
|
||||
totalBodyWater: "measurements.typeTotalBodyWater",
|
||||
boneMass: "measurements.typeBoneMass",
|
||||
bpInTarget: "dashboard.bpInTarget",
|
||||
};
|
||||
|
||||
@@ -113,7 +115,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6 space-y-5"
|
||||
className="bg-card border-border scroll-mt-28 space-y-5 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -137,7 +139,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
</p>
|
||||
|
||||
{isLoading || !layout ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
@@ -148,44 +150,44 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
.map((widget, index, arr) => {
|
||||
const labelKey = WIDGET_LABEL_KEYS[widget.id] ?? widget.id;
|
||||
return (
|
||||
<div
|
||||
key={widget.id}
|
||||
className="flex items-center gap-3 rounded-md border border-border bg-background/30 p-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => move(widget.id, -1)}
|
||||
disabled={index === 0 || saveMutation.isPending}
|
||||
aria-label={t("dashboard.moveUp")}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => move(widget.id, 1)}
|
||||
disabled={
|
||||
index === arr.length - 1 || saveMutation.isPending
|
||||
}
|
||||
aria-label={t("dashboard.moveDown")}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</Button>
|
||||
<div
|
||||
key={widget.id}
|
||||
className="border-border bg-background/30 flex items-center gap-3 rounded-md border p-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => move(widget.id, -1)}
|
||||
disabled={index === 0 || saveMutation.isPending}
|
||||
aria-label={t("dashboard.moveUp")}
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => move(widget.id, 1)}
|
||||
disabled={
|
||||
index === arr.length - 1 || saveMutation.isPending
|
||||
}
|
||||
aria-label={t("dashboard.moveDown")}
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="flex-1 text-sm">{t(labelKey)}</span>
|
||||
<Switch
|
||||
checked={widget.visible}
|
||||
onCheckedChange={(v) => toggle(widget.id, v)}
|
||||
aria-label={t(labelKey)}
|
||||
disabled={saveMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<span className="flex-1 text-sm">{t(labelKey)}</span>
|
||||
<Switch
|
||||
checked={widget.visible}
|
||||
onCheckedChange={(v) => toggle(widget.id, v)}
|
||||
aria-label={t(labelKey)}
|
||||
disabled={saveMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -37,6 +37,7 @@ export function PasswordStrength({
|
||||
|
||||
useEffect(() => {
|
||||
if (!password) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setResult(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = join(__dirname, "../../..");
|
||||
const EN_PATH = join(ROOT, "messages/en.json");
|
||||
const DE_PATH = join(ROOT, "messages/de.json");
|
||||
|
||||
// JSON.parse silently keeps the last value when an object has duplicate keys,
|
||||
// which lets shadowed keys hide bugs. Walk the source manually to surface them.
|
||||
function findDuplicateKeys(src: string): { path: string; line: number; firstLine: number }[] {
|
||||
const dups: { path: string; line: number; firstLine: number }[] = [];
|
||||
const pathStack: string[] = [];
|
||||
let i = 0;
|
||||
let line = 1;
|
||||
|
||||
function err(msg: string): never {
|
||||
throw new Error(`${msg} at line ${line}`);
|
||||
}
|
||||
|
||||
function skipWs() {
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
if (c === " " || c === "\t" || c === "\r") i++;
|
||||
else if (c === "\n") { i++; line++; }
|
||||
else break;
|
||||
}
|
||||
}
|
||||
|
||||
function readString(): { value: string; line: number } {
|
||||
if (src[i] !== '"') err("expected string");
|
||||
const startLine = line;
|
||||
i++;
|
||||
let raw = "";
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
if (c === "\\") {
|
||||
raw += c + src[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') {
|
||||
i++;
|
||||
return { value: JSON.parse(`"${raw}"`), line: startLine };
|
||||
}
|
||||
if (c === "\n") line++;
|
||||
raw += c;
|
||||
i++;
|
||||
}
|
||||
err("unterminated string");
|
||||
}
|
||||
|
||||
function parseValue() {
|
||||
skipWs();
|
||||
const c = src[i];
|
||||
if (c === "{") return parseObject();
|
||||
if (c === "[") return parseArray();
|
||||
if (c === '"') { readString(); return; }
|
||||
while (i < src.length) {
|
||||
const ch = src[i];
|
||||
if (ch === "," || ch === "}" || ch === "]" || ch === " " || ch === "\n" || ch === "\t" || ch === "\r") break;
|
||||
if (ch === "\n") line++;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArray() {
|
||||
i++; skipWs();
|
||||
if (src[i] === "]") { i++; return; }
|
||||
while (true) {
|
||||
parseValue();
|
||||
skipWs();
|
||||
if (src[i] === ",") { i++; skipWs(); continue; }
|
||||
if (src[i] === "]") { i++; return; }
|
||||
err("expected , or ] in array");
|
||||
}
|
||||
}
|
||||
|
||||
function parseObject() {
|
||||
i++;
|
||||
const seen = new Map<string, number>();
|
||||
skipWs();
|
||||
if (src[i] === "}") { i++; return; }
|
||||
while (true) {
|
||||
skipWs();
|
||||
const { value: key, line: keyLine } = readString();
|
||||
const seenAt = seen.get(key);
|
||||
if (seenAt != null) {
|
||||
dups.push({ path: [...pathStack, key].join("."), line: keyLine, firstLine: seenAt });
|
||||
} else {
|
||||
seen.set(key, keyLine);
|
||||
}
|
||||
skipWs();
|
||||
if (src[i] !== ":") err("expected : after key");
|
||||
i++;
|
||||
pathStack.push(key);
|
||||
parseValue();
|
||||
pathStack.pop();
|
||||
skipWs();
|
||||
if (src[i] === ",") { i++; continue; }
|
||||
if (src[i] === "}") { i++; return; }
|
||||
err("expected , or } in object");
|
||||
}
|
||||
}
|
||||
|
||||
parseValue();
|
||||
return dups;
|
||||
}
|
||||
|
||||
describe("i18n locale file integrity", () => {
|
||||
it("messages/en.json has no duplicate keys", () => {
|
||||
const dups = findDuplicateKeys(readFileSync(EN_PATH, "utf8"));
|
||||
expect(
|
||||
dups,
|
||||
`Duplicate keys silently shadow earlier definitions:\n` +
|
||||
dups
|
||||
.map((d) => ` ${d.path} at line ${d.line} (first defined at line ${d.firstLine})`)
|
||||
.join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("messages/de.json has no duplicate keys", () => {
|
||||
const dups = findDuplicateKeys(readFileSync(DE_PATH, "utf8"));
|
||||
expect(
|
||||
dups,
|
||||
`Duplicate keys silently shadow earlier definitions:\n` +
|
||||
dups
|
||||
.map((d) => ` ${d.path} at line ${d.line} (first defined at line ${d.firstLine})`)
|
||||
.join("\n"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("en and de share the same key shape (locale parity)", () => {
|
||||
const en = JSON.parse(readFileSync(EN_PATH, "utf8")) as Record<string, unknown>;
|
||||
const de = JSON.parse(readFileSync(DE_PATH, "utf8")) as Record<string, unknown>;
|
||||
|
||||
function flatten(obj: unknown, prefix: string, out: string[]) {
|
||||
if (obj == null || typeof obj !== "object") return;
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (typeof v === "string") out.push(key);
|
||||
else if (typeof v === "object") flatten(v, key, out);
|
||||
}
|
||||
}
|
||||
|
||||
const enKeys = new Set<string>();
|
||||
const deKeys = new Set<string>();
|
||||
const enArr: string[] = [];
|
||||
const deArr: string[] = [];
|
||||
flatten(en, "", enArr);
|
||||
flatten(de, "", deArr);
|
||||
enArr.forEach((k) => enKeys.add(k));
|
||||
deArr.forEach((k) => deKeys.add(k));
|
||||
|
||||
const onlyInEn = [...enKeys].filter((k) => !deKeys.has(k)).sort();
|
||||
const onlyInDe = [...deKeys].filter((k) => !enKeys.has(k)).sort();
|
||||
|
||||
expect(
|
||||
{ onlyInEn, onlyInDe },
|
||||
"Locale files drifted apart — every key must exist in both locales.",
|
||||
).toEqual({ onlyInEn: [], onlyInDe: [] });
|
||||
});
|
||||
});
|
||||
@@ -26,9 +26,16 @@ export class HttpError extends Error {
|
||||
* No CSRF check — HealthLog does not use CSRF tokens.
|
||||
* Auth annotation happens in routes via requireAuth().
|
||||
*/
|
||||
// Next.js route handlers come in two shapes — `(request)` for static routes
|
||||
// and `(request, { params })` for dynamic ones. The variadic generic is the
|
||||
// only signature TS accepts that covers both at the call site. The `any[]`
|
||||
// here is constrained by the bound (T must return Promise<Response>) so it
|
||||
// does not loosen handler bodies — only their parameter list.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
|
||||
handler: T,
|
||||
): T {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const wrapped = async (...args: any[]): Promise<Response> => {
|
||||
const request = args[0] as NextRequest;
|
||||
const url = new URL(request.url);
|
||||
|
||||
@@ -15,6 +15,8 @@ export type DashboardWidgetId =
|
||||
| "sleep"
|
||||
| "steps"
|
||||
| "glucose"
|
||||
| "totalBodyWater"
|
||||
| "boneMass"
|
||||
| "bpInTarget";
|
||||
|
||||
export interface DashboardWidgetConfig {
|
||||
@@ -48,12 +50,12 @@ export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
|
||||
{ id: "sleep", visible: false, order: 7 },
|
||||
{ id: "steps", visible: false, order: 8 },
|
||||
{ id: "glucose", visible: false, order: 9 },
|
||||
{ id: "totalBodyWater", visible: false, order: 10 },
|
||||
{ id: "boneMass", visible: false, order: 11 },
|
||||
],
|
||||
};
|
||||
|
||||
export function resolveDashboardLayout(
|
||||
raw: unknown,
|
||||
): DashboardLayout {
|
||||
export function resolveDashboardLayout(raw: unknown): DashboardLayout {
|
||||
if (!raw || typeof raw !== "object") return DEFAULT_DASHBOARD_LAYOUT;
|
||||
const candidate = raw as Partial<DashboardLayout>;
|
||||
if (
|
||||
|
||||
@@ -27,7 +27,8 @@ interface ReportData {
|
||||
/** Per-context glucose stats (canonical mg/dL). */
|
||||
glucoseStats?: Record<
|
||||
"FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME",
|
||||
{ avg: number; min: number; max: number; count: number; latest: number } | undefined
|
||||
| { avg: number; min: number; max: number; count: number; latest: number }
|
||||
| undefined
|
||||
>;
|
||||
/** Display-unit preference: "mg/dL" (default) or "mmol/L". */
|
||||
glucoseUnit?: "mg/dL" | "mmol/L";
|
||||
@@ -74,6 +75,8 @@ const TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
BODY_FAT: "doctorReport.typeBodyFat",
|
||||
SLEEP_DURATION: "doctorReport.typeSleep",
|
||||
ACTIVITY_STEPS: "doctorReport.typeSteps",
|
||||
TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater",
|
||||
BONE_MASS: "doctorReport.typeBoneMass",
|
||||
};
|
||||
|
||||
const TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||||
@@ -84,6 +87,8 @@ const TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||||
BODY_FAT: "%",
|
||||
SLEEP_DURATION: "h",
|
||||
ACTIVITY_STEPS: null, // translated unit
|
||||
TOTAL_BODY_WATER: "kg",
|
||||
BONE_MASS: "kg",
|
||||
};
|
||||
|
||||
const MOOD_LABEL_KEYS: Record<number, string> = {
|
||||
@@ -169,7 +174,8 @@ export function generateDoctorReportPDF(
|
||||
MALE: "doctorReport.genderMale",
|
||||
FEMALE: "doctorReport.genderFemale",
|
||||
};
|
||||
const genderKey = genderKeys[data.patient.gender] ?? "doctorReport.genderOther";
|
||||
const genderKey =
|
||||
genderKeys[data.patient.gender] ?? "doctorReport.genderOther";
|
||||
patientInfo.push(`${t("doctorReport.gender")}: ${t(genderKey)}`);
|
||||
}
|
||||
if (data.patient.heightCm) {
|
||||
@@ -203,6 +209,8 @@ export function generateDoctorReportPDF(
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"TOTAL_BODY_WATER",
|
||||
"BONE_MASS",
|
||||
];
|
||||
|
||||
for (const type of vitalTypes) {
|
||||
@@ -348,8 +356,7 @@ export function generateDoctorReportPDF(
|
||||
doc.setFont("helvetica", "normal");
|
||||
for (const ctx of loggedGlucose) {
|
||||
const s = data.glucoseStats![ctx]!;
|
||||
const range =
|
||||
data.glucoseRanges?.[ctx] ?? defaultGlucoseRanges[ctx];
|
||||
const range = data.glucoseRanges?.[ctx] ?? defaultGlucoseRanges[ctx];
|
||||
const conv = (v: number) => convertGlucose(v, glucoseUnit);
|
||||
const inRange = s.avg >= range.min && s.avg <= range.max;
|
||||
const classKey = inRange
|
||||
@@ -387,8 +394,7 @@ export function generateDoctorReportPDF(
|
||||
y += 6;
|
||||
|
||||
const compRows = complianceEntries.map(([name, c]) => {
|
||||
const rate =
|
||||
c.total > 0 ? `${num((c.taken / c.total) * 100, 1)}%` : "—";
|
||||
const rate = c.total > 0 ? `${num((c.taken / c.total) * 100, 1)}%` : "—";
|
||||
return [
|
||||
name,
|
||||
String(c.taken),
|
||||
@@ -503,16 +509,8 @@ export function generateDoctorReportPDF(
|
||||
pageDoc.setFontSize(7);
|
||||
pageDoc.setFont("helvetica", "italic");
|
||||
pageDoc.setTextColor(140, 140, 140);
|
||||
pageDoc.text(
|
||||
t("doctorReport.footerDisclaimer1"),
|
||||
margin,
|
||||
pageHeight - 14,
|
||||
);
|
||||
pageDoc.text(
|
||||
t("doctorReport.footerDisclaimer2"),
|
||||
margin,
|
||||
pageHeight - 10,
|
||||
);
|
||||
pageDoc.text(t("doctorReport.footerDisclaimer1"), margin, pageHeight - 14);
|
||||
pageDoc.text(t("doctorReport.footerDisclaimer2"), margin, pageHeight - 10);
|
||||
pageDoc.text(
|
||||
t("doctorReport.footerSource", {
|
||||
timestamp: formatters.dateTime(new Date()),
|
||||
|
||||
@@ -15,7 +15,6 @@ export interface FeedbackInput {
|
||||
subject: string;
|
||||
description: string;
|
||||
username: string;
|
||||
hasScreenshot: boolean;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
@@ -74,6 +73,15 @@ function sanitize(text: string, max = 5000): string {
|
||||
return text.replace(/```/g, "\\`\\`\\`").slice(0, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a string for safe inclusion in a `RegExp(..)` constructor — the
|
||||
* standard "don't let an attacker turn input into regex metacharacters"
|
||||
* helper. Used to redact secrets from logged error bodies.
|
||||
*/
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a feedback item as a GitHub Issue. Returns issue number + URL.
|
||||
* Throws GithubPublishError on API failure.
|
||||
@@ -122,10 +130,6 @@ export async function publishFeedbackToGithub(
|
||||
}
|
||||
}
|
||||
|
||||
if (input.hasScreenshot) {
|
||||
body += `\n**Screenshot:** A screenshot was attached in the original report.`;
|
||||
}
|
||||
|
||||
body += `\n\n---\n*Created via HealthLog feedback*`;
|
||||
|
||||
const title = `[${categoryLabel}] ${safeSubject || `Feedback – ${dateStr}`}`;
|
||||
@@ -146,7 +150,14 @@ export async function publishFeedbackToGithub(
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text();
|
||||
getEvent()?.addWarning("GitHub issue creation failed: " + errBody.slice(0, 500));
|
||||
// Strip the GitHub PAT before logging — GitHub error responses can echo
|
||||
// request headers in some failure modes, and Wide Events flow to Loki
|
||||
// unredacted. Keep the response shape useful for debugging without
|
||||
// leaking the credential.
|
||||
const sanitisedErr = errBody
|
||||
.replace(new RegExp(escapeRegex(config.token), "g"), "[REDACTED]")
|
||||
.slice(0, 500);
|
||||
getEvent()?.addWarning("GitHub issue creation failed: " + sanitisedErr);
|
||||
throw new GithubPublishError(
|
||||
`Failed to create GitHub issue (HTTP ${res.status})`,
|
||||
res.status === 401 || res.status === 403 ? res.status : 502,
|
||||
|
||||
@@ -925,25 +925,64 @@ async function handleDataBackup(jobs: Job<DataBackupPayload>[]) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal logger that prefers structured Wide-Event annotations when a
|
||||
* worker context is active, and falls back to stderr only for true
|
||||
* lifecycle events that fire outside any handler (init, fatal startup
|
||||
* errors, shutdown). Avoids the historical pattern of `console.log`
|
||||
* everywhere in this file, which polluted production stdout and was
|
||||
* never queryable in Loki.
|
||||
*/
|
||||
function workerLog(level: "info" | "error", msg: string, err?: unknown): void {
|
||||
if (level === "error") {
|
||||
// Errors during worker init or shutdown happen outside any request
|
||||
// context, so stderr is the only audience the operator has.
|
||||
if (err !== undefined) console.error(`[pg-boss] ${msg}`, err);
|
||||
else console.error(`[pg-boss] ${msg}`);
|
||||
}
|
||||
// info-level lifecycle messages are intentionally silent — pg-boss own
|
||||
// events surface state, and Wide Events from handlers carry the work.
|
||||
}
|
||||
|
||||
export async function startReminderWorker() {
|
||||
console.log("[pg-boss] Initializing pg-boss with DATABASE_URL...");
|
||||
if (!DATABASE_URL) {
|
||||
console.error("[pg-boss] CRITICAL: DATABASE_URL is not set!");
|
||||
workerLog("error", "CRITICAL: DATABASE_URL is not set, refusing to start");
|
||||
return;
|
||||
}
|
||||
|
||||
const boss = new PgBoss(DATABASE_URL);
|
||||
|
||||
boss.on("error", (error: unknown) => {
|
||||
console.error("[pg-boss] Error:", error);
|
||||
workerLog("error", "boss emitted error", error);
|
||||
recordError();
|
||||
});
|
||||
|
||||
console.log("[pg-boss] Connecting to database...");
|
||||
await boss.start();
|
||||
setGlobalBoss(boss);
|
||||
markWorkerStarted();
|
||||
console.log("[pg-boss] Connected and started");
|
||||
|
||||
// Graceful shutdown: drain in-flight jobs on SIGTERM/SIGINT (sent by
|
||||
// Docker Compose `docker stop`, Kubernetes pod termination, Coolify
|
||||
// redeploys). Without this, pending handlers were force-killed and could
|
||||
// either be lost or replayed on next start. We only register the listeners
|
||||
// once — re-entering startReminderWorker (e.g. on hot-reload in dev) is
|
||||
// a no-op for the handlers because they capture `boss` by closure and the
|
||||
// first signal stops everything.
|
||||
let shutdownInProgress = false;
|
||||
const onSignal = async (signal: "SIGTERM" | "SIGINT") => {
|
||||
if (shutdownInProgress) return;
|
||||
shutdownInProgress = true;
|
||||
workerLog("error", `received ${signal}, draining boss`);
|
||||
try {
|
||||
// graceful=true waits for active handlers to finish, then closes the
|
||||
// pg connection pool. timeout cap so a stuck handler can't block deploys.
|
||||
await boss.stop({ graceful: true, timeout: 30_000 });
|
||||
} catch (err) {
|
||||
workerLog("error", "boss.stop failed during shutdown", err);
|
||||
}
|
||||
};
|
||||
process.once("SIGTERM", () => void onSignal("SIGTERM"));
|
||||
process.once("SIGINT", () => void onSignal("SIGINT"));
|
||||
|
||||
// pg-boss v12 requires explicit queue creation before scheduling
|
||||
const allQueues = [
|
||||
@@ -964,7 +1003,6 @@ export async function startReminderWorker() {
|
||||
for (const q of allQueues) {
|
||||
await boss.createQueue(q);
|
||||
}
|
||||
console.log(`[pg-boss] Created ${allQueues.length} queues`);
|
||||
|
||||
// Schedule recurring cron jobs
|
||||
const schedules: [string, string][] = [
|
||||
@@ -984,7 +1022,6 @@ export async function startReminderWorker() {
|
||||
for (const [name, cron] of schedules) {
|
||||
await boss.schedule(name, cron, {}, { tz: "Europe/Berlin" });
|
||||
}
|
||||
console.log(`[pg-boss] Scheduled ${schedules.length} cron jobs`);
|
||||
|
||||
// Register the handler
|
||||
await boss.work<ReminderCheckPayload>(
|
||||
@@ -1057,7 +1094,7 @@ if (
|
||||
process.argv[1]?.endsWith("reminder-worker.js")
|
||||
) {
|
||||
startReminderWorker().catch((err) => {
|
||||
console.error("Failed to start reminder worker:", err);
|
||||
workerLog("error", "Failed to start reminder worker", err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMeasurementSchema,
|
||||
getUnitForType,
|
||||
validateMeasurementRange,
|
||||
} from "../measurement";
|
||||
|
||||
describe("measurement validation", () => {
|
||||
describe("getUnitForType", () => {
|
||||
it("returns canonical unit for each measurement type", () => {
|
||||
expect(getUnitForType("WEIGHT")).toBe("kg");
|
||||
expect(getUnitForType("BLOOD_PRESSURE_SYS")).toBe("mmHg");
|
||||
expect(getUnitForType("BLOOD_PRESSURE_DIA")).toBe("mmHg");
|
||||
expect(getUnitForType("PULSE")).toBe("bpm");
|
||||
expect(getUnitForType("BODY_FAT")).toBe("%");
|
||||
expect(getUnitForType("SLEEP_DURATION")).toBe("hours");
|
||||
expect(getUnitForType("ACTIVITY_STEPS")).toBe("steps");
|
||||
expect(getUnitForType("BLOOD_GLUCOSE")).toBe("mg/dL");
|
||||
expect(getUnitForType("TOTAL_BODY_WATER")).toBe("kg");
|
||||
expect(getUnitForType("BONE_MASS")).toBe("kg");
|
||||
});
|
||||
|
||||
it("returns 'unknown' for unrecognised types", () => {
|
||||
expect(getUnitForType("MADE_UP_TYPE")).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateMeasurementRange", () => {
|
||||
it("rejects values below the plausible minimum", () => {
|
||||
expect(validateMeasurementRange("WEIGHT", 0.5)).toMatch(/zwischen/);
|
||||
expect(validateMeasurementRange("BONE_MASS", 0.1)).toMatch(/zwischen/);
|
||||
expect(validateMeasurementRange("TOTAL_BODY_WATER", 1)).toMatch(
|
||||
/zwischen/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects values above the plausible maximum", () => {
|
||||
expect(validateMeasurementRange("WEIGHT", 600)).toMatch(/zwischen/);
|
||||
expect(validateMeasurementRange("BONE_MASS", 12)).toMatch(/zwischen/);
|
||||
expect(validateMeasurementRange("TOTAL_BODY_WATER", 200)).toMatch(
|
||||
/zwischen/,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts values inside the plausible range", () => {
|
||||
expect(validateMeasurementRange("WEIGHT", 75)).toBeNull();
|
||||
expect(validateMeasurementRange("BONE_MASS", 3.0)).toBeNull();
|
||||
expect(validateMeasurementRange("TOTAL_BODY_WATER", 42)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unknown types (no range = no opinion)", () => {
|
||||
expect(validateMeasurementRange("MADE_UP_TYPE", 9999)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMeasurementSchema", () => {
|
||||
const validBase = {
|
||||
value: 75,
|
||||
measuredAt: "2026-04-27T08:00:00.000Z",
|
||||
};
|
||||
|
||||
it("accepts a TOTAL_BODY_WATER measurement without glucoseContext", () => {
|
||||
const parsed = createMeasurementSchema.safeParse({
|
||||
...validBase,
|
||||
type: "TOTAL_BODY_WATER",
|
||||
value: 42,
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a BONE_MASS measurement without glucoseContext", () => {
|
||||
const parsed = createMeasurementSchema.safeParse({
|
||||
...validBase,
|
||||
type: "BONE_MASS",
|
||||
value: 3.2,
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects out-of-range values for body composition types", () => {
|
||||
const tooMuchWater = createMeasurementSchema.safeParse({
|
||||
...validBase,
|
||||
type: "TOTAL_BODY_WATER",
|
||||
value: 500,
|
||||
});
|
||||
expect(tooMuchWater.success).toBe(false);
|
||||
|
||||
const tooLittleBone = createMeasurementSchema.safeParse({
|
||||
...validBase,
|
||||
type: "BONE_MASS",
|
||||
value: 0.1,
|
||||
});
|
||||
expect(tooLittleBone.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects glucoseContext on non-glucose measurements", () => {
|
||||
const parsed = createMeasurementSchema.safeParse({
|
||||
...validBase,
|
||||
type: "TOTAL_BODY_WATER",
|
||||
value: 42,
|
||||
glucoseContext: "FASTING",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("requires glucoseContext on BLOOD_GLUCOSE measurements", () => {
|
||||
const parsed = createMeasurementSchema.safeParse({
|
||||
...validBase,
|
||||
type: "BLOOD_GLUCOSE",
|
||||
value: 95,
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unrecognised measurement types", () => {
|
||||
const parsed = createMeasurementSchema.safeParse({
|
||||
...validBase,
|
||||
type: "TBD_NEW_TYPE",
|
||||
value: 1,
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isPublicUrl } from "../notifications";
|
||||
|
||||
describe("isPublicUrl SSRF guard", () => {
|
||||
describe("allows public addresses", () => {
|
||||
it("real public hostnames", () => {
|
||||
expect(isPublicUrl("https://api.openai.com/v1")).toBe(true);
|
||||
expect(isPublicUrl("https://wbsapi.withings.net/v2/oauth2")).toBe(true);
|
||||
expect(isPublicUrl("http://example.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("public IPv4 addresses", () => {
|
||||
expect(isPublicUrl("https://1.1.1.1")).toBe(true);
|
||||
expect(isPublicUrl("https://8.8.8.8")).toBe(true);
|
||||
expect(isPublicUrl("https://93.184.216.34")).toBe(true); // example.com
|
||||
});
|
||||
});
|
||||
|
||||
describe("blocks loopback and link-local", () => {
|
||||
it("textual loopback hosts", () => {
|
||||
expect(isPublicUrl("http://localhost")).toBe(false);
|
||||
expect(isPublicUrl("http://localhost:8080")).toBe(false);
|
||||
expect(isPublicUrl("http://thing.localhost")).toBe(false);
|
||||
expect(isPublicUrl("https://service.internal")).toBe(false);
|
||||
expect(isPublicUrl("https://router.local")).toBe(false);
|
||||
});
|
||||
|
||||
it("IPv4 loopback / 0.0.0.0", () => {
|
||||
expect(isPublicUrl("http://127.0.0.1")).toBe(false);
|
||||
expect(isPublicUrl("http://127.255.255.254")).toBe(false);
|
||||
expect(isPublicUrl("http://0.0.0.0")).toBe(false);
|
||||
});
|
||||
|
||||
it("AWS metadata service + link-local 169.254/16", () => {
|
||||
expect(isPublicUrl("http://169.254.169.254/latest/meta-data/")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isPublicUrl("http://169.254.0.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("IPv6 loopback / unspecified / link-local / unique-local", () => {
|
||||
expect(isPublicUrl("http://[::1]")).toBe(false);
|
||||
expect(isPublicUrl("http://[::]")).toBe(false);
|
||||
expect(isPublicUrl("http://[fe80::1]")).toBe(false);
|
||||
expect(isPublicUrl("http://[fc00::1]")).toBe(false);
|
||||
expect(isPublicUrl("http://[fd12:3456::1]")).toBe(false);
|
||||
});
|
||||
|
||||
it("IPv4-mapped IPv6 (::ffff:...) embedding private IPv4", () => {
|
||||
// Both notations parsers might emit:
|
||||
expect(isPublicUrl("http://[::ffff:127.0.0.1]")).toBe(false);
|
||||
expect(isPublicUrl("http://[::ffff:10.0.0.1]")).toBe(false);
|
||||
expect(isPublicUrl("http://[::ffff:192.168.1.1]")).toBe(false);
|
||||
expect(isPublicUrl("http://[::ffff:7f00:1]")).toBe(false); // hex form of 127.0.0.1
|
||||
expect(isPublicUrl("http://[::ffff:c0a8:0101]")).toBe(false); // hex form of 192.168.1.1
|
||||
});
|
||||
});
|
||||
|
||||
describe("blocks RFC1918 + CGNAT", () => {
|
||||
it("10.0.0.0/8", () => {
|
||||
expect(isPublicUrl("http://10.0.0.1")).toBe(false);
|
||||
expect(isPublicUrl("http://10.255.255.254")).toBe(false);
|
||||
});
|
||||
|
||||
it("172.16.0.0/12", () => {
|
||||
expect(isPublicUrl("http://172.16.0.1")).toBe(false);
|
||||
expect(isPublicUrl("http://172.31.255.254")).toBe(false);
|
||||
// 172.32 is outside the /12 — should still be public
|
||||
expect(isPublicUrl("http://172.32.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("192.168.0.0/16", () => {
|
||||
expect(isPublicUrl("http://192.168.1.1")).toBe(false);
|
||||
expect(isPublicUrl("http://192.168.255.254")).toBe(false);
|
||||
});
|
||||
|
||||
it("100.64.0.0/10 (CGNAT)", () => {
|
||||
expect(isPublicUrl("http://100.64.0.1")).toBe(false);
|
||||
expect(isPublicUrl("http://100.127.255.254")).toBe(false);
|
||||
// 100.63 is below the range — public
|
||||
expect(isPublicUrl("http://100.63.0.1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("regression: leading-zero IPv4 cannot bypass private checks", () => {
|
||||
// The old implementation used `h.startsWith("10.")` which treats
|
||||
// "010.0.0.1" as not-starting-with-"10." and waved it through.
|
||||
// The strict parser rejects octets with leading zeros entirely.
|
||||
it("rejects '010.0.0.1' (was a bypass)", () => {
|
||||
expect(isPublicUrl("http://010.0.0.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects '0192.168.1.1'", () => {
|
||||
expect(isPublicUrl("http://0192.168.1.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects '0010.10.10.10'", () => {
|
||||
expect(isPublicUrl("http://0010.10.10.10")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects out-of-range octets — better safe than sorry", () => {
|
||||
// 256.0.0.1 looks like an IPv4 quad but is not valid. Rather than
|
||||
// falling through to "public" we deny — refusing a malformed URL is
|
||||
// better than risking misinterpretation as an internal address.
|
||||
expect(isPublicUrl("http://256.0.0.1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("regression: alternate IPv4 notations cannot bypass private checks", () => {
|
||||
it("hex IPv4 (0x7f.0.0.1 = 127.0.0.1)", () => {
|
||||
expect(isPublicUrl("http://0x7f.0.0.1")).toBe(false);
|
||||
expect(isPublicUrl("http://0x7f000001")).toBe(false);
|
||||
expect(isPublicUrl("http://0xa.0.0.1")).toBe(false); // 10.0.0.1
|
||||
});
|
||||
|
||||
it("decimal IPv4 (2130706433 = 127.0.0.1)", () => {
|
||||
expect(isPublicUrl("http://2130706433")).toBe(false);
|
||||
expect(isPublicUrl("http://167772161")).toBe(false); // 10.0.0.1
|
||||
expect(isPublicUrl("http://3232235777")).toBe(false); // 192.168.1.1
|
||||
});
|
||||
});
|
||||
|
||||
describe("rejects bad protocols", () => {
|
||||
it("file://, ftp://, javascript:", () => {
|
||||
expect(isPublicUrl("file:///etc/passwd")).toBe(false);
|
||||
expect(isPublicUrl("ftp://example.com")).toBe(false);
|
||||
expect(isPublicUrl("javascript:alert(1)")).toBe(false);
|
||||
});
|
||||
|
||||
it("invalid URLs", () => {
|
||||
expect(isPublicUrl("not a url")).toBe(false);
|
||||
expect(isPublicUrl("")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ export const measurementTypeEnum = z.enum([
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"BLOOD_GLUCOSE",
|
||||
"TOTAL_BODY_WATER",
|
||||
"BONE_MASS",
|
||||
]);
|
||||
|
||||
export const glucoseContextEnum = z.enum([
|
||||
@@ -29,6 +31,8 @@ const unitMap: Record<string, string> = {
|
||||
SLEEP_DURATION: "hours",
|
||||
ACTIVITY_STEPS: "steps",
|
||||
BLOOD_GLUCOSE: "mg/dL",
|
||||
TOTAL_BODY_WATER: "kg",
|
||||
BONE_MASS: "kg",
|
||||
};
|
||||
|
||||
export function getUnitForType(type: string): string {
|
||||
@@ -45,6 +49,8 @@ const VALUE_RANGES: Record<string, { min: number; max: number }> = {
|
||||
SLEEP_DURATION: { min: 0, max: 24 },
|
||||
ACTIVITY_STEPS: { min: 0, max: 200000 },
|
||||
BLOOD_GLUCOSE: { min: 20, max: 800 }, // mg/dL — covers severe hypo to severe hyperglycemia
|
||||
TOTAL_BODY_WATER: { min: 5, max: 100 }, // kg of water — adults typically 30–55 kg
|
||||
BONE_MASS: { min: 0.5, max: 8 }, // kg — adult plausibility (typical 2.5–4.5 kg)
|
||||
};
|
||||
|
||||
export function validateMeasurementRange(
|
||||
@@ -69,10 +75,9 @@ export const createMeasurementSchema = z
|
||||
// constraint in Postgres (see migration 0021).
|
||||
glucoseContext: glucoseContextEnum.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => validateMeasurementRange(data.type, data.value) === null,
|
||||
{ message: "Value out of plausible range" },
|
||||
)
|
||||
.refine((data) => validateMeasurementRange(data.type, data.value) === null, {
|
||||
message: "Value out of plausible range",
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
(data.type === "BLOOD_GLUCOSE" && data.glucoseContext !== undefined) ||
|
||||
|
||||
@@ -7,31 +7,148 @@ export const notificationPreferenceSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
// Block private/internal network URLs to prevent SSRF. Exported so other
|
||||
// server-initiated egress (AI providers, webhooks) can reuse it.
|
||||
/**
|
||||
* Parse a dotted-quad IPv4 string into normalized octets.
|
||||
*
|
||||
* Returns null if the input is not exactly four octets, contains non-digit
|
||||
* characters, has any octet outside 0–255, or contains leading zeros (which
|
||||
* some `parseInt` paths historically allowed — bypassing prefix checks like
|
||||
* `h.startsWith("10.")` because "010.x.x.x" still parses to 10).
|
||||
*/
|
||||
function parseIpv4Strict(
|
||||
host: string,
|
||||
): [number, number, number, number] | null {
|
||||
const parts = host.split(".");
|
||||
if (parts.length !== 4) return null;
|
||||
const octets: number[] = [];
|
||||
for (const part of parts) {
|
||||
if (!/^(0|[1-9][0-9]?|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/.test(part)) {
|
||||
return null;
|
||||
}
|
||||
octets.push(Number(part));
|
||||
}
|
||||
return [octets[0], octets[1], octets[2], octets[3]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Block private/internal network URLs to prevent SSRF. Exported so other
|
||||
* server-initiated egress (AI providers, webhooks) can reuse it.
|
||||
*
|
||||
* Defense layers:
|
||||
* 1. Protocol allowlist: only http(s).
|
||||
* 2. Pre-URL leading-zero IPv4 check on the *raw* input, because the
|
||||
* `URL` constructor silently normalises "010.0.0.1" to "8.0.0.1"
|
||||
* (octal interpretation) — a real SSRF bypass on naive checks.
|
||||
* 3. Hostname denylist for unicast loopback / link-local / metadata services.
|
||||
* 4. RFC1918 + CGNAT + 169.254/16 + 127.0.0.0/8 + 0.0.0.0/8 block via
|
||||
* strict IPv4 parser.
|
||||
* 5. IPv6 loopback / link-local / unique-local block (brackets-aware).
|
||||
*
|
||||
* NOTE: This is an *input-time* check on user-supplied URLs. It does not
|
||||
* defeat DNS rebinding (where a public hostname later resolves to a private
|
||||
* IP). For that, pin the resolved IP at fetch time or use an HTTP client
|
||||
* that blocks redirects to private ranges.
|
||||
*/
|
||||
function isPrivateIpv4(ip: [number, number, number, number]): boolean {
|
||||
const [a, b] = ip;
|
||||
if (a === 127) return true; // 127.0.0.0/8 loopback
|
||||
if (a === 0) return true; // 0.0.0.0/8 reserved (also covers literal 0.0.0.0)
|
||||
if (a === 10) return true; // 10.0.0.0/8 RFC1918
|
||||
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local + AWS metadata
|
||||
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 RFC1918
|
||||
if (a === 192 && b === 168) return true; // 192.168.0.0/16 RFC1918
|
||||
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isPublicUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
||||
return false;
|
||||
const h = parsed.hostname;
|
||||
if (
|
||||
h === "169.254.169.254" ||
|
||||
h === "127.0.0.1" ||
|
||||
h === "[::1]" ||
|
||||
h === "0.0.0.0" ||
|
||||
h === "localhost" ||
|
||||
h.startsWith("10.") ||
|
||||
h.startsWith("192.168.") ||
|
||||
h.endsWith(".internal") ||
|
||||
h.endsWith(".local")
|
||||
)
|
||||
return false;
|
||||
// 172.16.0.0/12
|
||||
if (h.startsWith("172.")) {
|
||||
const second = parseInt(h.split(".")[1] ?? "0", 10);
|
||||
if (second >= 16 && second <= 31) return false;
|
||||
// Pre-URL guard #1: the WHATWG URL parser interprets leading-zero IPv4
|
||||
// octets as octal. "010.0.0.1" silently becomes "8.0.0.1" (public),
|
||||
// bypassing any post-parse "starts with 10." check.
|
||||
const rawHostMatch = url.match(/^[a-z]+:\/\/(?:[^@/]*@)?([^:/?#]+)/i);
|
||||
const rawHost = rawHostMatch?.[1] ?? "";
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(rawHost)) {
|
||||
// Reject any segment with a leading zero (octal-bypass surface).
|
||||
if (/(?:^|\.)0\d/.test(rawHost)) return false;
|
||||
}
|
||||
|
||||
// Pre-URL guard #2: hex-notation IPv4 ("http://0x7f.0.0.1" or
|
||||
// "http://0x7f000001") and decimal-notation IPv4 ("http://2130706433"
|
||||
// = 127.0.0.1). The URL parser normalises both into a real IPv4 string
|
||||
// — the post-parse path *would* catch them, but only via Node's parser
|
||||
// behaviour which has historically been inconsistent. Reject the raw
|
||||
// alternate notations outright.
|
||||
if (/^0x[0-9a-f]+(?:\.|$)/i.test(rawHost)) return false;
|
||||
if (/^\d+$/.test(rawHost) && rawHost.length >= 8) {
|
||||
// Pure-decimal IPv4 ("4294967295" max). Only treat as suspicious when
|
||||
// the value could plausibly be an IP — short numeric "hostnames"
|
||||
// would not parse as URLs anyway.
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return false;
|
||||
}
|
||||
let h = parsed.hostname.toLowerCase();
|
||||
if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
|
||||
|
||||
// Hostname denylist (literal strings).
|
||||
if (
|
||||
h === "localhost" ||
|
||||
h.endsWith(".internal") ||
|
||||
h.endsWith(".local") ||
|
||||
h.endsWith(".localhost")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv6 loopback / unspecified / link-local / unique-local.
|
||||
if (
|
||||
h === "::1" ||
|
||||
h === "::" ||
|
||||
h.startsWith("fe80:") ||
|
||||
h.startsWith("fc") ||
|
||||
h.startsWith("fd")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// IPv4-mapped IPv6 ("::ffff:127.0.0.1" or "::ffff:7f00:1") and
|
||||
// 6to4 / NAT64 with embedded private IPv4. Extract the trailing
|
||||
// dotted-quad if present, otherwise extract the last 32 bits as
|
||||
// hex pairs and reconstruct.
|
||||
if (h.includes(":")) {
|
||||
const ipv4MappedDotted = h.match(/^(?:::ffff:)(\d+\.\d+\.\d+\.\d+)$/);
|
||||
if (ipv4MappedDotted) {
|
||||
const ip = parseIpv4Strict(ipv4MappedDotted[1]);
|
||||
if (!ip || isPrivateIpv4(ip)) return false;
|
||||
}
|
||||
const ipv4MappedHex = h.match(
|
||||
/^(?:::ffff:)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/,
|
||||
);
|
||||
if (ipv4MappedHex) {
|
||||
const high = parseInt(ipv4MappedHex[1], 16);
|
||||
const low = parseInt(ipv4MappedHex[2], 16);
|
||||
const ip: [number, number, number, number] = [
|
||||
(high >> 8) & 0xff,
|
||||
high & 0xff,
|
||||
(low >> 8) & 0xff,
|
||||
low & 0xff,
|
||||
];
|
||||
if (isPrivateIpv4(ip)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Strict IPv4 parsing: reject malformed forms outright (better a false
|
||||
// positive than an SSRF) and apply RFC1918 / CGNAT / loopback bans.
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(h)) {
|
||||
const ip = parseIpv4Strict(h);
|
||||
if (!ip) return false;
|
||||
if (isPrivateIpv4(ip)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -42,7 +159,10 @@ export const ntfySettingsSchema = z.object({
|
||||
serverUrl: z
|
||||
.url("Ungültige Server-URL")
|
||||
.max(200)
|
||||
.refine((url) => isPublicUrl(url), "Server-URL darf nicht auf interne Netzwerke zeigen")
|
||||
.refine(
|
||||
(url) => isPublicUrl(url),
|
||||
"Server-URL darf nicht auf interne Netzwerke zeigen",
|
||||
)
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
topic: z.string().max(100).optional().or(z.literal("")),
|
||||
|
||||
@@ -127,6 +127,8 @@ const MEASURE_TYPE_MAP: Record<number, { type: string; factor?: number }> = {
|
||||
10: { type: "BLOOD_PRESSURE_SYS" }, // Systolic BP
|
||||
11: { type: "PULSE" }, // Heart rate
|
||||
6: { type: "BODY_FAT" }, // Body fat %
|
||||
77: { type: "TOTAL_BODY_WATER" }, // Hydration / water mass (kg)
|
||||
88: { type: "BONE_MASS" }, // Bone mass (kg)
|
||||
};
|
||||
|
||||
export interface WithingsMeasure {
|
||||
|
||||
Reference in new issue
Block a user