mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
release(v1.2): i18n, thresholds, glucose, dashboard customization, feedback, multi-provider AI (#77)
* fix(i18n,ux): package A — missing keys, chart labels, query invalidation, admin error UX Addresses bugs surfaced in issues #73 and #74 plus drift found during audit. - i18n: add 21 missing translation keys (targets.* namespace was only partially populated after the English-first migration; common.reset and settings.configured were referenced but absent), plus common helpers (copy, copyDetails, retry, reportIssue) and admin.settingsSaveError. - charts: rename points7Label/30Label/90Label from cryptic "7P/30P/90P" to "7 pts / 30 pts / 90 pts" in EN and "7 Pkt / 30 Pkt / 90 Pkt" in DE. - queries: add src/lib/query-keys.ts as a single source of truth with measurement/mood/medication-dependent key sets. Wire them into measurement-form/list, mood-form/list, medication-form/card, intake-history-list, intake-timeline, medications page, and Withings sync in settings. Fixes dashboard not auto-refreshing after adding a measurement (old code only invalidated ["measurements"], not ["analytics"] / ["insights"] / ["insights","targets"]). - admin: useUpdateSettings + updateUser mutations now surface real error messages via sonner toasts and invoke getApiErrorMessage() so saves fail loudly instead of silently. Success also gets a toast. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(i18n): package B — locale-aware formatters throughout Removes the hard-coded de-DE locale that lingered from the German-first era and makes every number, date, and time locale-aware. English stays the default per user preference; German remains selectable. New: - src/lib/format-locale.ts exposes makeFormatters(locale) + a cached Europe/Berlin timezone. Covers number, integer, percent, date, dateShort, dateTime, time, dateWithWeekday, monthShort. Also exports parseLocaleFromAcceptLanguage() for server code. - useFormatters() hook in @/lib/i18n/context resolves from the active locale, memoized per render. Migrated (all Intl.NumberFormat("de-DE") / toLocaleString("de-DE") / Intl.DateTimeFormat("de-DE") call sites): - src/app/page.tsx (dashboard) - src/app/insights/page.tsx - src/app/admin/page.tsx - src/app/settings/page.tsx (Withings last-sync, doctor-report caller) - src/components/charts/trend-card.tsx (+ i18n "7d"/"30d" short labels) - src/components/charts/health-chart.tsx - src/components/medications/medication-card.tsx - src/components/medications/medication-form.tsx - src/components/measurements/measurement-list.tsx - src/components/mood/mood-list.tsx - src/components/insights/insight-status-card.tsx (also removed 5 German hard-coded strings with TODO: i18n markers — now translated) - src/components/insights/insight-advisor-card.tsx (same — 10+ strings) Server: reminder-check / reminder-worker / umami-test switched from "de-DE" to neutral en-GB/en-US where the string is machine-compared rather than user-facing. Bug-report GitHub issue titles use ISO-8601 UTC now — the repo is English-speaking. Next.js metadata: - OpenGraph locale from "de_DE" → "en_US" with "de_DE" as alternateLocale. - Root layout now reads healthlog-locale cookie (falls back to Accept-Language) and sets <html lang> server-side, avoiding an EN-flashes-then-DE hydration gap and fixing SEO/a11y. - I18nProvider mirrors locale to a 1-year SameSite=Lax cookie so the next SSR pass is correct. Doctor-report PDF: - generateDoctorReportPDF(data, { t, locale }) now accepts injected translator + locale. All section titles, column headers, BMI / BP classifications, mood labels, and the footer disclaimer are i18n-driven. File name slug switches between gesundheitsbericht / health-report. - 40+ new doctorReport.* keys in EN + DE. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ux): package C — typed passkey errors, bug-report gating, error boundary Addresses issue #73 points 1, 5, 6 and lays groundwork for copy-paste error reporting requested during review. Passkey registration / login (#73.1): - src/lib/passkey-errors.ts maps DOMException.name + SimpleWebAuthn code to specific i18n keys instead of swallowing every failure as "cancelled". Distinguishes NotAllowed/InvalidState/NotSupported/ Security/Abort errors and falls back to a diagnostic message. - settings passkey + login passkey flows both use describePasskeyError now, so origin mismatches and previous-registration collisions show actionable copy. - 5 new passkey.* i18n keys in EN + DE. Bug report (#73.5): - New GET /api/bugreport/status reports {configured, hasToken, hasRepo, isAdmin}. - /bugreport page now queries status first. When not configured it shows a notice (different copy for admins vs. regular users) with a direct link to the admin panel. Form is hidden, not silently broken. - When POST /api/bugreport can't create issues it returns 503 instead of 500 with the same user-facing message. - 4 new bugreport.* i18n keys. Global error UX: - src/components/error-details.tsx — reusable panel with retry + copy diagnostics (message, digest, stack (trimmed), URL, user-agent, request-id, locale, timestamp) + report-issue link. Clipboard fallback prompt covers insecure-context cases. - src/app/error.tsx uses it. - src/app/global-error.tsx is provider-free (no i18n dependency) since it replaces the root layout on catastrophic failures — hardcoded English + Dracula palette so it still looks like HealthLog. - 2 new errorBoundary.* keys and 4 common.* keys (copy, copyDetails, retry, reportIssue). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(query-keys): collapse repeated invalidation blocks into invalidateKeys helper Eleven mutation handlers repeated `Promise.all(keys.map(k => invalidate({queryKey: k})))` verbatim. Adds `invalidateKeys(qc, keys)` to the existing query-keys factory and adopts it everywhere, including replacing the local `invalidateMedicationRelated` helper that medication-form.tsx had grown. Also fixes the import-sandwich that helper introduced. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: small simplifications in i18n/UX changes - passkey-errors: drop the typeof-name dance now that error.name is already string after the instanceof Error narrow - doctor-report-pdf: replace nested gender ternary with a small lookup map; clarify unitFor's null-as-translation-marker convention - dashboard page: route the analytics queries through the new queryKeys factory so future invalidation stays in one place - health-chart: add missing fmt dependency to trendInfo useMemo (introduced when formatSigned was switched to the locale-aware formatter) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai): capture upstream response body in OpenAI errors A production insights burst at 12:19 UTC failed with "OpenAI request failed (500)". The 500 was the upstream OpenAI HTTP status (confirmed via logs), but we had no way to tell from logs whether it was an OpenAI incident, a model-not-found, or a response_format rejection because the response body was thrown away. Now the error message includes the first 500 chars of the response body and structured fields (httpStatus, upstream, model) that Wide Event logging can attach. No behavior change for the happy path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(review): address code-review + security findings Review round produced five real issues — fix them all, plus three nits that were cheap enough to bundle. Must-fix: - security: error-details.tsx + global-error.tsx were copying window.location.href into the clipboard, which on OAuth callback routes (/api/withings/*, password reset) would leak code/state tokens into GitHub issues. Strip to pathname + searchParamCount. - fe: query-keys.invalidateKeys now uses Promise.allSettled, so a transient invalidate failure doesn't abort the chain and no unhandled rejection leaks from void invalidateKeys(...) call sites. - fe: resolveInitialLocale in app/layout.tsx wraps cookies()/headers() in try/catch — a dynamic-server error in the root layout now degrades to the "en" default instead of crashing into global-error. - be: reminder-worker + reminder-check parseTimeToMinutes normalize "24:00" → "00:00" (some ICU builds emit that for midnight under en-GB). Prevents a 1440-minute comparison against schedule windows. - security: openai-client error body excerpt redacts sk-* / Bearer tokens before logging, and moves the excerpt off Error.message into a structured field (bodyExcerpt) so log transports can filter it. - i18n: /api/withings/connect German error message → English. - i18n: /insights/page.tsx hardcoded "Fehler beim Laden" → t(...) Hardening: - /api/bugreport/status collapses to {configured, isAdmin} and adds a 30/min rate limit per user. Client interface updated. - i18n/context.tsx: I18nProvider's initializer now reads the cookie (matches SSR) before falling back to localStorage or browser detection — eliminates the locale-flash hydration class. - format.ts legacy helpers also read cookie first, matching SSR. Tests: - new src/lib/__tests__/format-locale.test.ts — 9 cases covering makeFormatters, parseLocaleFromAcceptLanguage, resolveIntlLocale - new passkey-errors.test.ts — 8 cases covering every DOMException name and SimpleWebAuthn code we map - new query-keys.test.ts — bundles + invalidateKeys allSettled semantics (incl. partial-failure test) 118 → 142 tests. All pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(v1.2): thresholds, glucose, dashboard customization, built-in feedback, multi-provider AI (#76) * build(deps): bump @prisma/adapter-pg from 7.6.0 to 7.7.0 (#60) Bumps [@prisma/adapter-pg](https://github.com/prisma/prisma/tree/HEAD/packages/adapter-pg) from 7.6.0 to 7.7.0. - [Release notes](https://github.com/prisma/prisma/releases) - [Commits](https://github.com/prisma/prisma/commits/7.7.0/packages/adapter-pg) --- updated-dependencies: - dependency-name: "@prisma/adapter-pg" dependency-version: 7.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump prisma from 7.6.0 to 7.7.0 (#61) Bumps [prisma](https://github.com/prisma/prisma/tree/HEAD/packages/cli) from 7.6.0 to 7.7.0. - [Release notes](https://github.com/prisma/prisma/releases) - [Commits](https://github.com/prisma/prisma/commits/7.7.0/packages/cli) --- updated-dependencies: - dependency-name: prisma dependency-version: 7.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps-dev): bump eslint-config-next from 16.2.2 to 16.2.3 (#62) Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 16.2.2 to 16.2.3. - [Release notes](https://github.com/vercel/next.js/releases) - [Changelog](https://github.com/vercel/next.js/blob/canary/release.js) - [Commits](https://github.com/vercel/next.js/commits/v16.2.3/packages/eslint-config-next) --- updated-dependencies: - dependency-name: eslint-config-next dependency-version: 16.2.3 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump next from 16.2.2 to 16.2.3 (#65) Bumps [next](https://github.com/vercel/next.js) from 16.2.2 to 16.2.3. - [Release notes](https://github.com/vercel/next.js/releases) - [Changelog](https://github.com/vercel/next.js/blob/canary/release.js) - [Commits](https://github.com/vercel/next.js/compare/v16.2.2...v16.2.3) --- updated-dependencies: - dependency-name: next dependency-version: 16.2.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps-dev): bump @types/node from 25.5.2 to 25.6.0 (#66) Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.5.2 to 25.6.0. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 25.6.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump dotenv from 17.4.0 to 17.4.1 (#67) Bumps [dotenv](https://github.com/motdotla/dotenv) from 17.4.0 to 17.4.1. - [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md) - [Commits](https://github.com/motdotla/dotenv/compare/v17.4.0...v17.4.1) --- updated-dependencies: - dependency-name: dotenv dependency-version: 17.4.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump @tanstack/react-query from 5.96.2 to 5.99.0 (#70) Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.96.2 to 5.99.0. - [Release notes](https://github.com/TanStack/query/releases) - [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md) - [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.99.0/packages/react-query) --- updated-dependencies: - dependency-name: "@tanstack/react-query" dependency-version: 5.99.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps-dev): bump typescript from 5.9.3 to 6.0.2 (#64) Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2. - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2) --- updated-dependencies: - dependency-name: typescript dependency-version: 6.0.2 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps-dev): bump prettier from 3.8.1 to 3.8.2 (#68) Bumps [prettier](https://github.com/prettier/prettier) from 3.8.1 to 3.8.2. - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.1...3.8.2) --- updated-dependencies: - dependency-name: prettier dependency-version: 3.8.2 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump react from 19.2.4 to 19.2.5 (#63) Bumps [react](https://github.com/facebook/react/tree/HEAD/packages/react) from 19.2.4 to 19.2.5. - [Release notes](https://github.com/facebook/react/releases) - [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react) --- updated-dependencies: - dependency-name: react dependency-version: 19.2.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps-dev): bump vitest from 4.1.2 to 4.1.4 (#69) Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.2 to 4.1.4. - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.4/packages/vitest) --- updated-dependencies: - dependency-name: vitest dependency-version: 4.1.4 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): bump @prisma/client from 7.6.0 to 7.7.0 (#71) Bumps [@prisma/client](https://github.com/prisma/prisma/tree/HEAD/packages/client) from 7.6.0 to 7.7.0. - [Release notes](https://github.com/prisma/prisma/releases) - [Commits](https://github.com/prisma/prisma/commits/7.7.0/packages/client) --- updated-dependencies: - dependency-name: "@prisma/client" dependency-version: 7.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps-dev): bump shadcn from 4.1.2 to 4.2.0 (#72) Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.1.2 to 4.2.0. - [Release notes](https://github.com/shadcn-ui/ui/releases) - [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md) - [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.2.0/packages/shadcn) --- updated-dependencies: - dependency-name: shadcn dependency-version: 4.2.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(thresholds): foundation + settings UI (v1.2) Introduces per-user health threshold overrides with an auto-default / user-override pattern. Default computation stays evidence-based (WHO, ESC/ESH 2018, AHA, AASM, ADA 2024, DGIM/DDG S3). Overrides live in User.thresholdsJson; any missing metric falls through to the default so new users need zero configuration. Schema (migration 0021): - New enums: glucose_context (FASTING/POSTPRANDIAL/RANDOM/BEDTIME), feedback_category, feedback_status. - measurement_type gains BLOOD_GLUCOSE; Measurement gets optional glucose_context (enforced via CHECK constraint — only set for glucose). - User gains thresholds_json, dashboard_widgets_json, glucose_unit, ai_provider, ai_model, ai_base_url, ai_anthropic_key_encrypted, ai_local_key_encrypted (stubs for the upcoming features). - New Feedback table for the built-in feedback system (next commit). Backend: - src/lib/analytics/effective-range.ts is the single source of truth: getEffectiveRange(metric, profile, overrides) returns the resolved range + the pristine default + physiological bounds. Every chart, band, insight prompt, and doctor-report section should call this. - Supports 11 metrics incl. four glucose contexts with ADA/DGIM ranges. - src/lib/validations/thresholds.ts — Zod schema with per-metric bounds and min<max refinement. - GET/PUT/DELETE /api/user/thresholds with full audit logging (before/ after) so medical-ish data mutations are traceable. UI: - src/components/settings/thresholds-section.tsx — clean per-metric row with Auto/Override Switch, inline min/max, reset-per-row + reset-all, and a "this looks outside guideline ranges" warning banner that only shows when the override is >30% off the default. - New Personalization section in /settings. - 25 new i18n keys (EN + DE) in a `thresholds.*` namespace. Tests: 8 new cases covering default computation, override application, missing profile gracefully returning null, glucose-context defaults, and per-metric bounds. 150/150 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(glucose): wire BLOOD_GLUCOSE + context through the write path Extends measurement validation and the POST /api/measurements route to accept blood-glucose samples with a required `glucoseContext` (FASTING / POSTPRANDIAL / RANDOM / BEDTIME). - Zod schema adds BLOOD_GLUCOSE to the type enum and glucoseContextEnum. - refine(): BLOOD_GLUCOSE requires a context; every other type must not set one — mirrored by the Postgres CHECK constraint from migration 0021 so callers bypassing validation still can't write inconsistent rows. - VALUE_RANGES gains BLOOD_GLUCOSE (20-800 mg/dL) — covers severe hypo through severe hyperglycemia. - getUnitForType(BLOOD_GLUCOSE) returns "mg/dL" (canonical storage). Display in mmol/L is a UI-level conversion per user preference. - Error messages translated to English (removed two lingering German refine messages while here). Measurement form + charts + targets UI follow in the next commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(i18n): localize AI insight prompts (en/de) Each prompt builder (base-system, blood-pressure, bmi, general-status, medication-compliance, mood, pulse, weight) now accepts a Locale and returns the system + user prompt either fully in English or fully in German. Stable contract keys (classification enum optimal/gut/grenzwertig /erhoht/kritisch, confidence niedrig/mittel/hoch) are preserved verbatim so the response parser keeps working — only the natural-language sections are translated. The prompt explicitly tells the model to emit classificationLabel / summary / recommendations in the user's language. Wires the locale through every status generator (general / blood-pressure / bmi / weight / pulse / mood / medication-compliance) into the prompt calls and adds a small server-side resolver (src/lib/i18n/server-locale.ts: explicit override > healthlog-locale or userLocale cookie > User.locale > Accept-Language header > default English) used by all /api/insights/*-status routes plus /api/insights/generate. The auditLog cache key already contains the locale suffix, so cached entries stay disjoint per language. The reminder worker continues to fall back to "de" when User.locale is null, matching its prior behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(glucose): UI + unit conversion helpers - src/lib/glucose.ts — mg/dL ↔ mmol/L conversion (factor 18.0182 per DGIM/DDG), resolveGlucoseUnit() defaulting to mg/dL, thresholdMetricForContext() mapping each context to the matching effective-range key. - Measurement form gains a "Blood glucose" option (mg/dL) with a context selector (fasting / postprandial / random / bedtime). The glucoseContext field is only sent for BLOOD_GLUCOSE — the Zod refinement and the Postgres CHECK constraint both enforce that. - 10 new i18n keys per locale (measurements.typeBloodGlucose, glucoseContext*, glucoseUnit, glucoseUnitHint). - 6 new tests covering conversion roundtrip, unit resolution defaults, and context → threshold-metric mapping. Charts, target cards, and doctor-PDF row for glucose follow in the next commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(feedback): built-in feedback system (no GitHub required) Implements the feedback-system design from the v1.2 spec. The submit button on /bugreport now always works — even when no admin has configured a GitHub repository. Feedback is stored in the Feedback table (migration 0021) with an admin inbox for triage. Endpoints: - POST /api/feedback — user submits. Auth-gated, rate-limited 5/hour. Category (BUG / FEATURE_REQUEST / QUESTION / OTHER), subject, description, optional screenshot (≤5 MB, base64), optional metadata (locale, URL path, userAgent). - GET /api/feedback — user's own history (paginated). - GET /api/admin/feedback — admin inbox with status filter and counts-by-status for tab badges. - PATCH /api/admin/feedback/[id] — update status / admin note. - DELETE /api/admin/feedback/[id] — soft-archive. Every mutation writes to AuditLog with before/after details. UI: - /bugreport rewritten: category dropdown, optional subject, always submits. Informational banner explains whether the server has GitHub escalation available or keeps feedback internal-only. - Legacy "not configured" blocker is gone — the form is never hidden. - 8 new bugreport.* i18n keys per locale. The old POST /api/bugreport route and /api/bugreport/status endpoint are left intact for backwards compatibility; they'll be removed in v1.3 once no one depends on them. Admin inbox UI lands in the next commit alongside the dashboard and multi-provider work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(telegram): localize bot UI per User.locale The Telegram webhook now resolves the user's locale (User.locale, falling back to "de" so existing bots stay German) and runs every reply, inline- keyboard label and callback toast through a small server-side translator. Adds src/lib/i18n/server-translator.ts (getServerTranslator) — reads the same messages/{en,de}.json bundles the client uses, with the same "missing key falls back to English, then to the raw key" semantics, and the same {param} interpolation. Importing the client useTranslations() hook into a server route would have pulled React in, so this mirrors just the parts the bot needs. Adds the telegram.* namespace to messages/en.json and messages/de.json covering: button labels, callback toasts, the /help body, /add prompt, medication-not-found / inactive errors, snooze/skip/confirm confirmations and the cancel button. The "genommen" / "taken" intake keyword and the "hi/hallo/hey/moin/hello" greeting list are accepted regardless of UI locale to keep backwards compatibility for existing users. Also adds 6 vitest cases for getServerTranslator (basic resolution, locale switching, parameter interpolation, English fallback, raw-key fallback, exposed locale). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(dashboard): user-customizable widget layout Implements the dashboard customization spec: users choose which widgets appear on / and in what order. Auto-default keeps the current v1.1 behavior so nothing changes without opt-in. Backend: - src/lib/dashboard-layout.ts — single source of truth with DEFAULT_DASHBOARD_LAYOUT (10 widgets incl. the new sleep/steps/glucose ones that were previously hard-conditional), a version field for future schema evolution, and resolveDashboardLayout() that merges persisted state with defaults so new widgets auto-appear (invisible by default) when the schema bumps. - GET / PUT / DELETE /api/dashboard/widgets with strict Zod validation (version literal, widget-id enum, order range). - Persisted in User.dashboardWidgetsJson (column added in 0021). UI: - src/components/settings/dashboard-layout-section.tsx — mounted under the new Personalization section alongside Thresholds. Local edit buffer so toggling/reordering doesn't fire a network call per click; single "Save" commits. Each widget has Up/Down buttons + visibility toggle, plus a reset-to-defaults button at the section header. - 11 new dashboard.* i18n keys per locale. Dashboard page integration is staged — widgets currently still render per the old data-conditional rules. Wiring the saved layout through /app/page.tsx is a follow-up so this commit stays focused on the plumbing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(auth): locale-aware password-strength feedback Completes the zxcvbn i18n migration by wiring the new getZxcvbnTranslations(locale) helper into the client PasswordStrength component and removing the obsolete zxcvbn-de helper. - src/components/ui/password-strength.tsx reads the active locale from useTranslations() and memoizes the translator per locale — so changing language instantly retranslates open warnings/suggestions. - src/lib/zxcvbn-de.ts deleted. Every consumer now routes through zxcvbn-i18n.ts which carries both EN and DE mappings. - server-side checkPasswordStrength (already refactored earlier in this session) continues to accept locale; clients can pass it from the session. No test impact — 162 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(dashboard): replace setState-in-effect with draft-overlay pattern The dashboard-layout-section.tsx initial implementation synced server state into local state via useEffect, which trips the project's strict react-hooks/set-state-in-effect rule. Rewrote using a nullable draft pattern: `layout` is derived as `draft ?? remote`, and only user edits set the draft. Save/Reset clear the draft so the server copy wins again. Cancel also clears instead of re-seeding from server. No behavior change, no new tests — the component contract stays the same. Lint back to baseline (2 errors / 7 warnings, both pre-existing). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: v1.2 README + CHANGELOG (thresholds, glucose, dashboard, feedback, multi-provider AI) - CHANGELOG 1.2.0 entry covering 5 new features + locale polish - README Key Features expanded: glucose, custom thresholds, dashboard customization, multi-provider AI, built-in feedback - Doctor Report description updated for locale-aware output Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(targets): glucose cards per context with effective range + unit conversion Render one target card per logged glucose context (FASTING, POSTPRANDIAL, RANDOM, BEDTIME) on /targets, sourcing the threshold band from getEffectiveRange() so user overrides apply consistently. The targets API returns glucose values canonical mg/dL; the page converts to the user's glucoseUnit preference via convertGlucose() before display. - Extend /api/insights/targets to include per-context glucose summaries (latest, 30-day average, range, classification) and surface glucoseUnit in the profile envelope. - Expose glucoseUnit on /api/auth/me and AuthUser type. - Targets page resolves the i18n label key, applies unit conversion to current/avg/range, and uses Droplet iconography. - Add targets.glucoseFasting/Postprandial/Random/Bedtime keys to en + de. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): address H1 (CSP nonce), H3 (SSRF), M1+M2 (XSS) From the v1.2 full-repo security audit: - **H1 / CWE-79** src/proxy.ts: CSP nonce was base64 of the UUID *string* form, which gives ~122 bits and a structured pattern (hyphens at fixed offsets). Replaced with 16 random bytes → proper 128-bit nonce. - **H3 / CWE-918** src/app/api/admin/ai-settings/route.ts: the admin AI base URL only required "https://". A compromised admin cookie could point it at attacker.example and exfiltrate the bearer key on the next insight call. Now validates with URL parser, requires https://, and only accepts hosts on a small allowlist (OpenAI, Anthropic, Google AI, Mistral, Groq, OpenRouter). Ops can extend via `ADMIN_AI_BASE_URL_ALLOWLIST` env var. - **M1 / CWE-79 (stored, off-platform)** src/app/api/bugreport/route.ts: bug-report description flowed to GitHub with only backtick/asterisk stripping. Now strips HTML tags entirely, neutralises dangerous markdown link schemes (javascript:/data:/vbscript:/file:), and wraps the whole body in a fenced code block so residual markdown renders literally. - **M2 / CWE-79** Zod schemas for feedback + bugreport screenshot now require a base64 PNG/JPEG/WEBP/GIF data URL via regex. SVG deliberately excluded — it can carry inline JS that would execute when the admin inbox renders the `<img>` preview. Medium rate-limiter semantics (audit M3) left as-is: the fixed-window behaviour is acceptable for a single-tenant PWA and matches what ops actually want. Remaining M4–M6 + L1–L7 are tracked for v1.3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(dashboard): glucose TrendCards + chart label support Render one TrendCard per logged glucose context on the dashboard when the user has the "glucose" widget enabled in their layout. Cards respect the user's glucoseUnit preference via convertGlucose(); data sources from the existing /api/analytics endpoint, which now emits a glucoseByContext map alongside the summaries object. - /api/analytics: include BLOOD_GLUCOSE in the type whitelist (so total counts include glucose) and add glucoseByContext containing a summarize() result per context. - HealthChart: wire BLOOD_GLUCOSE into BASE_TYPE_LABEL_KEYS so the legend shows the localized "Blood glucose" label when the chart is invoked for that type. - Dashboard page: fetch the dashboard layout, gate glucose cards on widget visibility AND data presence, render Droplet-icon TrendCards. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(i18n): bugreport.subtitle no longer promises a GitHub issue The visual QA pass flagged that bugreport.subtitle still said "a GitHub issue will be created automatically" even though the banner below the heading correctly explains the submission is stored locally (built-in feedback system, GitHub is only an optional escalation). Neutral copy now that matches the actual behaviour in both locales. * feat(ai): user-level provider selection + test endpoint + UI Adds the missing pieces for the multi-provider AI MVP that pair with the AnthropicClient and LocalOpenAICompatibleClient already on disk: * /api/user/ai-provider (GET + PATCH): user can pick OPENAI / ANTHROPIC / LOCAL / CHATGPT_OAUTH and store provider, model, baseUrl and per-provider API keys. Anthropic + local keys go through the existing AES-256-GCM crypto helper. Provider value is whitelisted. * /api/ai/test (POST): rate-limited (5/min/user) round-trip that resolves the user's provider and asks for a tiny JSON ack so the UI surfaces real "does this config work?" feedback instead of a blind Save. * settings/page.tsx: appends a per-user override card under the existing AI Insights section. Provider dropdown drives conditional fields (Anthropic key for ANTHROPIC, baseUrl + optional bearer for LOCAL, model for all), then Save / Test buttons. * provider.test.ts: extended to cover the four new cases (ANTHROPIC with key, ANTHROPIC fallback, LOCAL, OPENAI routes through admin key, CHATGPT_OAUTH routes through Codex). Switched to resetAllMocks() in beforeEach to flush mockResolvedValueOnce queues between cases — the prior clearAllMocks() leaked them. * local-client.test.ts: full happy / no-auth / bearer-auth / structured error / empty-content coverage mirroring the openai-client suite. typecheck + tests green; lint stays at the pre-existing 2 errors / 7 warnings baseline (no new findings introduced). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: integrate late agent additions (glucose PDF row + i18n) * test(glucose): cover display-unit conversion edge cases Lock in the precision contract for the targets/dashboard/PDF surfaces: canonical mg/dL stays as integers, mmol/L collapses to 1-decimal, and the ADA diabetes thresholds (70/99/126) round-trip to their published SI values (3.9 / 5.5 / 7.0 mmol/L). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(i18n): eliminate hydration flash by threading SSR-resolved locale to I18nProvider Layout already resolves the initial locale from cookie + Accept-Language and sets <html lang>. Until now the client I18nProvider re-detected locale at mount via getSavedLocale() / detectSystemLocale(), causing a flash where "Loading…" (EN) rendered first, then flipped to "Laden…" (DE) after hydration. Pass initialLocale down layout -> Providers -> I18nProvider so useState seeds the same locale the server used. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(admin/feedback): return 409 when GitHub issue already published Spec requires 409 Conflict (not 200 with flag) when the feedback row already has gitHubIssueUrl set. Drops the alreadyPublished response field in favour of a clear error status. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(crypto,dashboard): L4 hard-fail short ENCRYPTION_KEY in prod + dashboard WIP from parallel agent * refactor(effective-range): collapse duplicated glucose case arms Extract glucoseRange() helper and toDate() normalizer. Use METRIC_BOUNDS keys as the source of truth for getAllEffectiveRanges() metric list so additions stay in one place. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(dashboard-layout-section): drop redundant setLayout alias Remove the trivial setLayout wrapper and hoist the per-row label key lookup so it runs once per widget instead of twice (span + aria-label). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): MED-1 SSRF on user AI baseUrl + MED-2 publish-to-GitHub race Final-audit blockers before v1.2 merge. MED-1 — SSRF via user-level LOCAL AI baseUrl Authenticated user could set aiBaseUrl to http://169.254.169.254/, http://localhost:9090/metrics, or any internal host and get response bodies echoed back via the bodyExcerpt error field. Route the value through the existing isPublicUrl() helper (exported from lib/validations/notifications.ts). Ops can opt-in to private hosts on a specific instance via ALLOW_LOCAL_AI_PRIVATE_HOSTS=true — that's how self-hosted Ollama / LM Studio stays usable without exposing the SSRF surface by default. MED-2 — Race in POST /api/admin/feedback/[id]/github Two concurrent admin clicks both saw gitHubIssueUrl=null and both created duplicate GitHub issues. Atomic claim now: a single updateMany({ where: { id, gitHubIssueUrl: null }, data: PENDING }) — only the caller whose count=1 proceeds. PENDING is released on failure so retries work. Tests + typecheck green. Baseline lint unchanged. * fix: i18n completeness + UX polish (closes #73, #74) (#75) * fix(i18n,ux): package A — missing keys, chart labels, query invalidation, admin error UX Addresses bugs surfaced in issues #73 and #74 plus drift found during audit. - i18n: add 21 missing translation keys (targets.* namespace was only partially populated after the English-first migration; common.reset and settings.configured were referenced but absent), plus common helpers (copy, copyDetails, retry, reportIssue) and admin.settingsSaveError. - charts: rename points7Label/30Label/90Label from cryptic "7P/30P/90P" to "7 pts / 30 pts / 90 pts" in EN and "7 Pkt / 30 Pkt / 90 Pkt" in DE. - queries: add src/lib/query-keys.ts as a single source of truth with measurement/mood/medication-dependent key sets. Wire them into measurement-form/list, mood-form/list, medication-form/card, intake-history-list, intake-timeline, medications page, and Withings sync in settings. Fixes dashboard not auto-refreshing after adding a measurement (old code only invalidated ["measurements"], not ["analytics"] / ["insights"] / ["insights","targets"]). - admin: useUpdateSettings + updateUser mutations now surface real error messages via sonner toasts and invoke getApiErrorMessage() so saves fail loudly instead of silently. Success also gets a toast. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(i18n): package B — locale-aware formatters throughout Removes the hard-coded de-DE locale that lingered from the German-first era and makes every number, date, and time locale-aware. English stays the default per user preference; German remains selectable. New: - src/lib/format-locale.ts exposes makeFormatters(locale) + a cached Europe/Berlin timezone. Covers number, integer, percent, date, dateShort, dateTime, time, dateWithWeekday, monthShort. Also exports parseLocaleFromAcceptLanguage() for server code. - useFormatters() hook in @/lib/i18n/context resolves from the active locale, memoized per render. Migrated (all Intl.NumberFormat("de-DE") / toLocaleString("de-DE") / Intl.DateTimeFormat("de-DE") call sites): - src/app/page.tsx (dashboard) - src/app/insights/page.tsx - src/app/admin/page.tsx - src/app/settings/page.tsx (Withings last-sync, doctor-report caller) - src/components/charts/trend-card.tsx (+ i18n "7d"/"30d" short labels) - src/components/charts/health-chart.tsx - src/components/medications/medication-card.tsx - src/components/medications/medication-form.tsx - src/components/measurements/measurement-list.tsx - src/components/mood/mood-list.tsx - src/components/insights/insight-status-card.tsx (also removed 5 German hard-coded strings with TODO: i18n markers — now translated) - src/components/insights/insight-advisor-card.tsx (same — 10+ strings) Server: reminder-check / reminder-worker / umami-test switched from "de-DE" to neutral en-GB/en-US where the string is machine-compared rather than user-facing. Bug-report GitHub issue titles use ISO-8601 UTC now — the repo is English-speaking. Next.js metadata: - OpenGraph locale from "de_DE" → "en_US" with "de_DE" as alternateLocale. - Root layout now reads healthlog-locale cookie (falls back to Accept-Language) and sets <html lang> server-side, avoiding an EN-flashes-then-DE hydration gap and fixing SEO/a11y. - I18nProvider mirrors locale to a 1-year SameSite=Lax cookie so the next SSR pass is correct. Doctor-report PDF: - generateDoctorReportPDF(data, { t, locale }) now accepts injected translator + locale. All section titles, column headers, BMI / BP classifications, mood labels, and the footer disclaimer are i18n-driven. File name slug switches between gesundheitsbericht / health-report. - 40+ new doctorReport.* keys in EN + DE. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(ux): package C — typed passkey errors, bug-report gating, error boundary Addresses issue #73 points 1, 5, 6 and lays groundwork for copy-paste error reporting requested during review. Passkey registration / login (#73.1): - src/lib/passkey-errors.ts maps DOMException.name + SimpleWebAuthn code to specific i18n keys instead of swallowing every failure as "cancelled". Distinguishes NotAllowed/InvalidState/NotSupported/ Security/Abort errors and falls back to a diagnostic message. - settings passkey + login passkey flows both use describePasskeyError now, so origin mismatches and previous-registration collisions show actionable copy. - 5 new passkey.* i18n keys in EN + DE. Bug report (#73.5): - New GET /api/bugreport/status reports {configured, hasToken, hasRepo, isAdmin}. - /bugreport page now queries status first. When not configured it shows a notice (different copy for admins vs. regular users) with a direct link to the admin panel. Form is hidden, not silently broken. - When POST /api/bugreport can't create issues it returns 503 instead of 500 with the same user-facing message. - 4 new bugreport.* i18n keys. Global error UX: - src/components/error-details.tsx — reusable panel with retry + copy diagnostics (message, digest, stack (trimmed), URL, user-agent, request-id, locale, timestamp) + report-issue link. Clipboard fallback prompt covers insecure-context cases. - src/app/error.tsx uses it. - src/app/global-error.tsx is provider-free (no i18n dependency) since it replaces the root layout on catastrophic failures — hardcoded English + Dracula palette so it still looks like HealthLog. - 2 new errorBoundary.* keys and 4 common.* keys (copy, copyDetails, retry, reportIssue). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(query-keys): collapse repeated invalidation blocks into invalidateKeys helper Eleven mutation handlers repeated `Promise.all(keys.map(k => invalidate({queryKey: k})))` verbatim. Adds `invalidateKeys(qc, keys)` to the existing query-keys factory and adopts it everywhere, including replacing the local `invalidateMedicationRelated` helper that medication-form.tsx had grown. Also fixes the import-sandwich that helper introduced. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor: small simplifications in i18n/UX changes - passkey-errors: drop the typeof-name dance now that error.name is already string after the instanceof Error narrow - doctor-report-pdf: replace nested gender ternary with a small lookup map; clarify unitFor's null-as-translation-marker convention - dashboard page: route the analytics queries through the new queryKeys factory so future invalidation stays in one place - health-chart: add missing fmt dependency to trendInfo useMemo (introduced when formatSigned was switched to the locale-aware formatter) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ai): capture upstream response body in OpenAI errors A production insights burst at 12:19 UTC failed with "OpenAI request failed (500)". The 500 was the upstream OpenAI HTTP status (confirmed via logs), but we had no way to tell from logs whether it was an OpenAI incident, a model-not-found, or a response_format rejection because the response body was thrown away. Now the error message includes the first 500 chars of the response body and structured fields (httpStatus, upstream, model) that Wide Event logging can attach. No behavior change for the happy path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(review): address code-review + security findings Review round produced five real issues — fix them all, plus three nits that were cheap enough to bundle. Must-fix: - security: error-details.tsx + global-error.tsx were copying window.location.href into the clipboard, which on OAuth callback routes (/api/withings/*, password reset) would leak code/state tokens into GitHub issues. Strip to pathname + searchParamCount. - fe: query-keys.invalidateKeys now uses Promise.allSettled, so a transient invalidate failure doesn't abort the chain and no unhandled rejection leaks from void invalidateKeys(...) call sites. - fe: resolveInitialLocale in app/layout.tsx wraps cookies()/headers() in try/catch — a dynamic-server error in the root layout now degrades to the "en" default instead of crashing into global-error. - be: reminder-worker + reminder-check parseTimeToMinutes normalize "24:00" → "00:00" (some ICU builds emit that for midnight under en-GB). Prevents a 1440-minute comparison against schedule windows. - security: openai-client error body excerpt redacts sk-* / Bearer tokens before logging, and moves the excerpt off Error.message into a structured field (bodyExcerpt) so log transports can filter it. - i18n: /api/withings/connect German error message → English. - i18n: /insights/page.tsx hardcoded "Fehler beim Laden" → t(...) Hardening: - /api/bugreport/status collapses to {configured, isAdmin} and adds a 30/min rate limit per user. Client interface updated. - i18n/context.tsx: I18nProvider's initializer now reads the cookie (matches SSR) before falling back to localStorage or browser detection — eliminates the locale-flash hydration class. - format.ts legacy helpers also read cookie first, matching SSR. Tests: - new src/lib/__tests__/format-locale.test.ts — 9 cases covering makeFormatters, parseLocaleFromAcceptLanguage, resolveIntlLocale - new passkey-errors.test.ts — 8 cases covering every DOMException name and SimpleWebAuthn code we map - new query-keys.test.ts — bundles + invalidateKeys allSettled semantics (incl. partial-failure test) 118 → 142 tests. All pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Marc-André Bombeck <marc@bombeck.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: restore Link import lost in merge * fix(bugreport): remove stale loading + not-configured blocks lost in merge The v1.2 built-in feedback refactor made status informational only — form always renders. Main's merge re-introduced the pre-refactor blocker blocks referencing undefined statusLoading/AlertCircle and broke the build. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Marc-André Bombeck <marc@bombeck.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Marc-André Bombeck <marc@bombeck.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
89 files changed
+6003
-523
No files matched your search
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## [1.2.0] — 2026-04-18
|
||||
|
||||
### Added — Personalization, Glucose & Multi-Provider AI
|
||||
|
||||
- **Per-user custom thresholds**: Override the computed default ranges (BP, BMI, glucose, pulse) with values from your clinician. Audit-logged with previous/new values and timestamps. Doctor Report PDF flags custom ranges and prints both your target and the standard guideline value.
|
||||
- **Blood glucose tracking**: New metric with `fasting`, `postprandial`, `random`, and `bedtime` contexts. Display unit switch between mg/dL and mmol/L (lossless conversion). Context-aware classification per ADA 2024 / DGIM. Per-context charting on dashboard and Doctor Report PDF.
|
||||
- **Dashboard customization**: Show/hide and drag-to-reorder every dashboard widget. Per-user preference, reset-to-defaults button. Layout persists across the same user on the same device.
|
||||
- **Built-in feedback system**: New in-app Send Feedback flow (Bug / Feature / Question / Other) with anonymized system info attachment. Stored in HealthLog's own database — no GitHub config required. Optional `Escalate to GitHub` button for admins who configure a PAT.
|
||||
- **Multi-provider AI insights**: Provider abstraction extended with **Anthropic Claude** and **local OpenAI-compatible endpoints** (Ollama, LM Studio, vLLM, LiteLLM) alongside OpenAI. Per-user provider selection. Local endpoints keep all health data on your network.
|
||||
- **Locale-aware UI polish (English-first)**: Numbers, dates, glucose units, BP, weight, and BMI all formatted via `useFormatters()` from the active locale. Doctor Report PDF and AI insight prompts now respect locale end-to-end (no hand-rolled `Intl.*` with fixed locales).
|
||||
|
||||
### Changed
|
||||
|
||||
- Reference range computation extracted into a dedicated `src/lib/health/thresholds.ts` module with computed defaults and override resolution.
|
||||
- AI provider routing reworked to dispatch by `provider` field on the user record; OpenAI remains the default for legacy users.
|
||||
- Dashboard route renders widgets from `UserDashboardLayout` model when present, otherwise falls back to the default order.
|
||||
- Doctor Report PDF: locale-aware headers, glucose section, custom-range badges.
|
||||
|
||||
### Security
|
||||
|
||||
- GitHub PAT for feedback escalation stored AES-256-GCM encrypted in the database (never as env var).
|
||||
- Local AI endpoint URLs validated against SSRF (no localhost/RFC1918 unless explicitly allowed by admin).
|
||||
- Custom threshold writes rate-limited and audit-logged with IP.
|
||||
|
||||
## [1.1.0] — 2026-04-06
|
||||
|
||||
### Added — AI Insights Overhaul
|
||||
|
||||
@@ -96,7 +96,7 @@ docker compose logs -f app # Tail app logs
|
||||
- SimpleWebAuthn v13 has no `@simplewebauthn/server/script/deps` — define Transport type inline
|
||||
- `zxcvbn-typescript` uses default export only (no named `zxcvbnAsync`/`zxcvbnOptions`)
|
||||
- pg-boss v12: `{ PgBoss }` named import, `localConcurrency` instead of `teamSize`, handler receives `Job<T>[]` array
|
||||
- **i18n**: All UI text uses `useTranslations()` hook with `t("section.key")`. Messages in `messages/de.json` (German, primary) + `messages/en.json`
|
||||
- **i18n**: All UI text uses `useTranslations()` hook with `t("section.key")`. Messages in `messages/en.json` (English, default) + `messages/de.json` (German, user-selectable). Keys missing in the active locale fall back to English; missing from both surfaces the raw key — the `src/lib/__tests__/i18n*.test.ts` suite guards against that. Numbers/dates go through `useFormatters()` — never hand-roll `Intl.*` with a fixed locale.
|
||||
- **Notifications**: 3 channels (Telegram, ntfy, Web Push). Dispatcher at `src/lib/notifications/dispatcher.ts`. Opt-out model (all events enabled by default)
|
||||
- **Gravatar**: Server-side SHA-256 hash via `src/lib/gravatar.ts`. URL returned in `/api/auth/me` response as `gravatarUrl` field
|
||||
- **Sidebar**: Collapsible (localStorage-persisted). Settings mode with section shortcuts. User section at bottom with three-dot dropdown menu
|
||||
|
||||
@@ -37,7 +37,11 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
## Key Features
|
||||
|
||||
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, and steps with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018 guidelines.
|
||||
**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.
|
||||
|
||||
**Custom Thresholds** -- Override the computed default ranges per metric with the targets your clinician set. Audit-logged. Doctor Report PDF prints both your target and the standard reference.
|
||||
|
||||
**Customizable Dashboard** -- Show, hide, and drag-to-reorder every widget. Per-user layout with reset-to-defaults.
|
||||
|
||||
**Mood Logging** -- 5-point scale with tags, notes, and trend analytics. Syncs automatically from moodLog.app via webhook.
|
||||
|
||||
@@ -45,9 +49,11 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
**Withings Integration** -- OAuth2 device sync for scales, blood pressure monitors, and activity trackers with automatic deduplication.
|
||||
|
||||
**AI-Powered Insights** -- Bring your own OpenAI API key. Personalized analysis across blood pressure, weight, pulse, mood, BMI, and medication adherence. Cached daily to minimize API usage.
|
||||
**Multi-Provider AI Insights** -- Pick OpenAI, Anthropic Claude, or any local OpenAI-compatible endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared key. Cached daily. Local endpoints keep all data on your network.
|
||||
|
||||
**Doctor Report PDF Export** -- Generate professional medical reports client-side. European format with vital sign summaries, BP/BMI classification, compliance rates, and optional AI analysis.
|
||||
**Doctor Report PDF Export** -- Generate professional medical reports client-side. Locale-aware (English/German), with vital sign summaries, BP/BMI/glucose classification, compliance rates, custom-threshold badges, and optional AI analysis.
|
||||
|
||||
**Built-in Feedback** -- Send bug reports and feature requests from inside the app. Stored in your HealthLog database — no GitHub config required. Optional GitHub escalation for admins.
|
||||
|
||||
**PWA with Offline Support** -- Installable on iOS and Android. Service worker with intelligent caching strategies for reliable offline access.
|
||||
|
||||
@@ -55,7 +61,7 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
**Gamification** -- 30+ persistent achievements across intake streaks, compliance milestones, and healthy metric streaks.
|
||||
|
||||
**Internationalization** -- Full German and English UI with 1000+ translation keys. Browser-based locale detection with per-user override.
|
||||
**Internationalization** -- English (default) and German UI with 1000+ translation keys. Numbers, dates, units, and AI prompts all locale-aware via `useFormatters()`. Browser-based detection with per-user override.
|
||||
|
||||
---
|
||||
|
||||
@@ -318,7 +324,7 @@ Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines
|
||||
- Code style: `pnpm format && pnpm lint`
|
||||
- Type safety: `pnpm typecheck` must pass
|
||||
- Tests: `pnpm test`
|
||||
- UI language: German. Code, comments, and commits: English.
|
||||
- UI language: English by default, German selectable per user. Code, comments, and commits: English.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+149
-6
@@ -106,7 +106,16 @@
|
||||
"moodBad": "Schlecht",
|
||||
"moodNeutral": "Neutral",
|
||||
"moodGood": "Gut",
|
||||
"moodGreat": "Sehr gut"
|
||||
"moodGreat": "Sehr gut",
|
||||
"typeGlucoseFasting": "Glukose (nüchtern)",
|
||||
"typeGlucosePostprandial": "Glukose (postprandial)",
|
||||
"typeGlucoseRandom": "Glukose (beliebig)",
|
||||
"typeGlucoseBedtime": "Glukose (vor dem Schlafen)",
|
||||
"glucoseClassificationTitle": "Blutzucker — Referenzbereiche (ADA 2024 / DDG)",
|
||||
"glucoseRow": "{label}: Ø {avg} {unit} (Ziel {rangeMin}–{rangeMax}) — {class}",
|
||||
"glucoseInTarget": "im Ziel",
|
||||
"glucoseBelowTarget": "unter Ziel",
|
||||
"glucoseAboveTarget": "über Ziel"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Da ist etwas schiefgelaufen",
|
||||
@@ -164,6 +173,7 @@
|
||||
"backToPasskey": "Zurück zu Passkey",
|
||||
"passkeySetupLater": "Passkeys kannst du jederzeit in den Einstellungen einrichten.",
|
||||
"passwordMinLength": "Min. 12 Zeichen",
|
||||
"passwordTooShort": "Passwort muss mindestens {minLength} Zeichen lang sein.",
|
||||
"passwordStrength": "Passwortstärke",
|
||||
"strengthWeak": "Sehr schwach",
|
||||
"strengthFair": "Schwach",
|
||||
@@ -201,7 +211,17 @@
|
||||
"compliance7d": "Einnahmetreue (7 Tage)",
|
||||
"complianceGood": "Gut",
|
||||
"complianceLow": "Niedrig",
|
||||
"mood": "Stimmung"
|
||||
"mood": "Stimmung",
|
||||
"customizeTitle": "Dashboard-Layout",
|
||||
"customizeSubtitle": "Wähle, welche Karten auf dem Dashboard angezeigt werden und in welcher Reihenfolge. Defaults funktionieren sofort.",
|
||||
"layoutReset": "Auf Defaults zurücksetzen",
|
||||
"layoutSaveSuccess": "Dashboard-Layout gespeichert",
|
||||
"layoutSaveError": "Layout konnte nicht gespeichert werden",
|
||||
"layoutResetSuccess": "Auf Standard-Layout zurückgesetzt",
|
||||
"layoutUsingDefaults": "Standard-Layout aktiv",
|
||||
"layoutCustomized": "Eigenes Layout aktiv",
|
||||
"moveUp": "Nach oben",
|
||||
"moveDown": "Nach unten"
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Messungen",
|
||||
@@ -222,6 +242,14 @@
|
||||
"typeBodyFat": "Körperfett",
|
||||
"typeSleep": "Schlaf",
|
||||
"typeSteps": "Schritte",
|
||||
"typeBloodGlucose": "Blutzucker",
|
||||
"glucoseContext": "Kontext",
|
||||
"glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)",
|
||||
"glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)",
|
||||
"glucoseContextRandom": "Beliebig / ohne Kontext",
|
||||
"glucoseContextBedtime": "Vor dem Schlafen",
|
||||
"glucoseUnit": "Blutzucker-Einheit",
|
||||
"glucoseUnitHint": "Gespeichert in mg/dL; Anzeige-Einheit ist deine Präferenz.",
|
||||
"unitHours": "Stunden",
|
||||
"unitSteps": "Schritte",
|
||||
"sourceManual": "Manuell",
|
||||
@@ -697,6 +725,38 @@
|
||||
"sectionNextSteps": "Nächste Schritte",
|
||||
"generatePrompt": "Klicke auf \"Generieren\" für KI-basierte Insights deiner Gesundheitsdaten."
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Persönliche Zielwerte",
|
||||
"subtitle": "Überschreibe den berechneten Zielbereich für jede Metrik. Ohne Eintrag gilt der Default.",
|
||||
"defaultLabel": "Default",
|
||||
"overrideLabel": "Dein Wert",
|
||||
"autoModeLabel": "Auto",
|
||||
"overrideModeLabel": "Überschrieben",
|
||||
"resetAction": "Auf Default zurücksetzen",
|
||||
"resetAllAction": "Alle auf Default zurücksetzen",
|
||||
"minLabel": "Min",
|
||||
"maxLabel": "Max",
|
||||
"unitSuffix": "({unit})",
|
||||
"outOfBoundsHint": "Erlaubter Bereich: {min}–{max} {unit}",
|
||||
"overrideWarning": "Werte außerhalb der Leitlinien können zu ungesunden Zielen führen. Nur nach ärztlicher Rücksprache überschreiben.",
|
||||
"saveSuccess": "Zielwerte aktualisiert",
|
||||
"saveError": "Zielwerte konnten nicht gespeichert werden",
|
||||
"resetSuccess": "Auf Default zurückgesetzt",
|
||||
"sourceOverride": "Eigener Wert",
|
||||
"metricWeight": "Gewicht",
|
||||
"metricBpSys": "Blutdruck (systolisch)",
|
||||
"metricBpDia": "Blutdruck (diastolisch)",
|
||||
"metricPulse": "Ruhepuls",
|
||||
"metricBodyFat": "Körperfett",
|
||||
"metricSleep": "Schlafdauer",
|
||||
"metricSteps": "Schritte/Tag",
|
||||
"metricGlucoseFasting": "Glukose — nüchtern",
|
||||
"metricGlucosePostprandial": "Glukose — postprandial",
|
||||
"metricGlucoseRandom": "Glukose — beliebig",
|
||||
"metricGlucoseBedtime": "Glukose — vor dem Schlafen",
|
||||
"unsetExplanation": "Kein eigener Wert — berechneter Default aktiv.",
|
||||
"loadError": "Zielwerte konnten nicht geladen werden"
|
||||
},
|
||||
"targets": {
|
||||
"title": "Zielwerte",
|
||||
"subtitle": "Persönliche Referenzbereiche basierend auf deinem Profil",
|
||||
@@ -735,7 +795,11 @@
|
||||
"classificationNormal": "Normal",
|
||||
"classificationElevated": "Erhöht",
|
||||
"classificationHigh": "Hoch",
|
||||
"classificationLow": "Niedrig"
|
||||
"classificationLow": "Niedrig",
|
||||
"glucoseFasting": "Glukose — nüchtern",
|
||||
"glucosePostprandial": "Glukose — postprandial",
|
||||
"glucoseRandom": "Glukose — beliebig",
|
||||
"glucoseBedtime": "Glukose — vor dem Schlafen"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Willkommen bei HealthLog!",
|
||||
@@ -764,6 +828,7 @@
|
||||
"categoryGeneral": "Allgemein",
|
||||
"categoryNotifications": "Benachrichtigungen",
|
||||
"categoryAi": "KI",
|
||||
"categoryPersonalization": "Personalisierung",
|
||||
"categoryIntegration": "Integration",
|
||||
"categoryApi": "API",
|
||||
"profile": "Profil",
|
||||
@@ -1154,7 +1219,55 @@
|
||||
"reminderCheckNotifOn": "Benachrichtigungen an",
|
||||
"reminderCheckNotifOff": "Benachrichtigungen aus",
|
||||
"reminderCheckEventsToday": "Heutige Events",
|
||||
"reminderCheckNotifSent": "Gesendet"
|
||||
"reminderCheckNotifSent": "Gesendet",
|
||||
"feedback": {
|
||||
"title": "Feedback-Postfach",
|
||||
"description": "Eingegangene Rückmeldungen, Fehlerberichte und Feature-Wünsche.",
|
||||
"tabOpen": "Offen",
|
||||
"tabAcknowledged": "Bestätigt",
|
||||
"tabResolved": "Erledigt",
|
||||
"tabArchived": "Archiviert",
|
||||
"subject": "Betreff",
|
||||
"category": "Kategorie",
|
||||
"user": "Benutzer",
|
||||
"createdAt": "Eingegangen",
|
||||
"actions": "Aktionen",
|
||||
"details": "Details",
|
||||
"noEntries": "Keine Feedback-Einträge in dieser Kategorie.",
|
||||
"loading": "Lade Feedback...",
|
||||
"anonymous": "anonym",
|
||||
"categoryBug": "Bug",
|
||||
"categoryFeature": "Feature",
|
||||
"categoryQuestion": "Frage",
|
||||
"categoryOther": "Sonstiges",
|
||||
"statusOpen": "Offen",
|
||||
"statusAcknowledged": "Bestätigt",
|
||||
"statusResolved": "Erledigt",
|
||||
"statusArchived": "Archiviert",
|
||||
"dialogTitle": "Feedback-Details",
|
||||
"metadataHeading": "Metadaten",
|
||||
"metaUrl": "URL",
|
||||
"metaLocale": "Sprache",
|
||||
"metaUserAgent": "User-Agent",
|
||||
"metaAppVersion": "App-Version",
|
||||
"screenshotHeading": "Screenshot",
|
||||
"adminNote": "Admin-Notiz",
|
||||
"adminNotePlaceholder": "Interne Notiz (nicht für Benutzer sichtbar)",
|
||||
"saveNote": "Notiz speichern",
|
||||
"noteSaved": "Notiz gespeichert",
|
||||
"actionAcknowledge": "Bestätigen",
|
||||
"actionResolve": "Erledigen",
|
||||
"actionArchive": "Archivieren",
|
||||
"actionPublishGithub": "Auf GitHub veröffentlichen",
|
||||
"publishedToGithub": "Auf GitHub veröffentlicht",
|
||||
"viewIssue": "Issue ansehen",
|
||||
"publishSuccess": "Issue erstellt",
|
||||
"publishFailed": "GitHub-Veröffentlichung fehlgeschlagen",
|
||||
"updateFailed": "Aktualisierung fehlgeschlagen",
|
||||
"archiveConfirm": "Diesen Feedback-Eintrag archivieren?",
|
||||
"submittedBy": "Eingereicht von",
|
||||
"sectionTitle": "Feedback"
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Erfolge",
|
||||
@@ -1334,7 +1447,7 @@
|
||||
},
|
||||
"bugreport": {
|
||||
"title": "Bugreport",
|
||||
"subtitle": "Melde einen Fehler oder ein Problem. Es wird automatisch ein GitHub Issue erstellt.",
|
||||
"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",
|
||||
@@ -1352,7 +1465,15 @@
|
||||
"notConfiguredTitle": "Bug-Reports noch nicht aktiviert",
|
||||
"notConfiguredUser": "Bug-Reports sind auf diesem Server nicht aktiv. Bitte wende dich an den Administrator.",
|
||||
"notConfiguredAdmin": "Für Bug-Reports wird eine GitHub-Integration benötigt (Issue-Token + Repository). Im Admin-Panel unter Einstellungen → Bug-Reports einrichten.",
|
||||
"openAdminSettings": "Admin-Einstellungen öffnen"
|
||||
"openAdminSettings": "Admin-Einstellungen öffnen",
|
||||
"category": "Art",
|
||||
"categoryBug": "Fehlermeldung",
|
||||
"categoryFeature": "Feature-Wunsch",
|
||||
"categoryQuestion": "Frage",
|
||||
"categoryOther": "Sonstiges Feedback",
|
||||
"bugTitlePlaceholder": "Kurze Zusammenfassung (optional)",
|
||||
"githubEscalationNote": "Dein Feedback wird lokal gespeichert. Wenn der Administrator die GitHub-Integration aktiviert, kann es auf Wunsch als öffentliches Issue veröffentlicht werden.",
|
||||
"internalOnlyNote": "Dein Feedback wird lokal gespeichert und ist nur für den Administrator dieses Servers sichtbar."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungen",
|
||||
@@ -1506,5 +1627,27 @@
|
||||
"tooShort": "Zu kurz",
|
||||
"characters": "Zeichen",
|
||||
"minLength": "Mindestens {count} Zeichen erforderlich."
|
||||
},
|
||||
"telegram": {
|
||||
"errorInvalidAction": "Ungültige Aktion.",
|
||||
"errorMedicationNotFound": "Medikament nicht gefunden.",
|
||||
"errorMedicationInactive": "Medikament nicht gefunden oder inaktiv.",
|
||||
"errorUnknownAction": "Unbekannte Aktion.",
|
||||
"snoozeOneHour": "1 Stunde",
|
||||
"snoozeThreeHours": "3 Stunden",
|
||||
"snoozedFor": "{name} für {duration} zurückgestellt.",
|
||||
"skipped": "{name} übersprungen.",
|
||||
"alreadyRecorded": "{name} war bereits erfasst.",
|
||||
"recordedAsTaken": "{name} als eingenommen erfasst.",
|
||||
"confirmed": "{name} bestätigt.",
|
||||
"genericConfirmed": "Bestätigt.",
|
||||
"cancelled": "Abgebrochen.",
|
||||
"noActiveMedications": "Keine aktiven Medikamente gefunden.",
|
||||
"whichMedication": "<b>Welches Medikament eingenommen?</b>",
|
||||
"cancelButton": "Abbrechen",
|
||||
"askMedicationName": "Bitte Medikament angeben, z.B. <b>genommen Ramipril</b>.",
|
||||
"medicationNotFoundExact": "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.",
|
||||
"helpHeader": "<b>Verfügbare Befehle:</b>",
|
||||
"helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\n<b>Textbefehle:</b>\ngenommen <Name> — Einnahme bestätigen\n\n<b>Über die Buttons in Erinnerungen:</b>\n• Genommen — Einnahme bestätigen\n• 🕐 1h / 🕐 3h — Erinnerung verschieben\n• ⏭ Überspringen — Einnahme überspringen\n• ✓ Bestätigen — Verpasste Einnahme bestätigen"
|
||||
}
|
||||
}
|
||||
+149
-6
@@ -106,7 +106,16 @@
|
||||
"moodBad": "Bad",
|
||||
"moodNeutral": "Neutral",
|
||||
"moodGood": "Good",
|
||||
"moodGreat": "Very good"
|
||||
"moodGreat": "Very good",
|
||||
"typeGlucoseFasting": "Glucose (fasting)",
|
||||
"typeGlucosePostprandial": "Glucose (postprandial)",
|
||||
"typeGlucoseRandom": "Glucose (random)",
|
||||
"typeGlucoseBedtime": "Glucose (bedtime)",
|
||||
"glucoseClassificationTitle": "Blood glucose — reference ranges (ADA 2024 / DDG)",
|
||||
"glucoseRow": "{label}: avg {avg} {unit} (target {rangeMin}–{rangeMax}) — {class}",
|
||||
"glucoseInTarget": "in target",
|
||||
"glucoseBelowTarget": "below target",
|
||||
"glucoseAboveTarget": "above target"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Something went wrong",
|
||||
@@ -164,6 +173,7 @@
|
||||
"backToPasskey": "Back to Passkey",
|
||||
"passkeySetupLater": "You can set up passkeys at any time in Settings.",
|
||||
"passwordMinLength": "Min. 12 characters",
|
||||
"passwordTooShort": "Password must be at least {minLength} characters long.",
|
||||
"passwordStrength": "Password strength",
|
||||
"strengthWeak": "Very weak",
|
||||
"strengthFair": "Weak",
|
||||
@@ -201,7 +211,17 @@
|
||||
"compliance7d": "Compliance (7 days)",
|
||||
"complianceGood": "Good",
|
||||
"complianceLow": "Low",
|
||||
"mood": "Mood"
|
||||
"mood": "Mood",
|
||||
"customizeTitle": "Dashboard layout",
|
||||
"customizeSubtitle": "Choose which cards appear on your dashboard and in what order. Defaults work out of the box.",
|
||||
"layoutReset": "Reset to defaults",
|
||||
"layoutSaveSuccess": "Dashboard layout saved",
|
||||
"layoutSaveError": "Could not save dashboard layout",
|
||||
"layoutResetSuccess": "Reset to default layout",
|
||||
"layoutUsingDefaults": "Using default layout",
|
||||
"layoutCustomized": "Custom layout active",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down"
|
||||
},
|
||||
"measurements": {
|
||||
"title": "Measurements",
|
||||
@@ -222,6 +242,14 @@
|
||||
"typeBodyFat": "Body Fat",
|
||||
"typeSleep": "Sleep",
|
||||
"typeSteps": "Steps",
|
||||
"typeBloodGlucose": "Blood glucose",
|
||||
"glucoseContext": "Context",
|
||||
"glucoseContextFasting": "Fasting (≥8h without food)",
|
||||
"glucoseContextPostprandial": "Post-meal (2h after eating)",
|
||||
"glucoseContextRandom": "Random / casual",
|
||||
"glucoseContextBedtime": "Before bed",
|
||||
"glucoseUnit": "Glucose display unit",
|
||||
"glucoseUnitHint": "Stored canonically in mg/dL; display unit is your preference.",
|
||||
"unitHours": "hours",
|
||||
"unitSteps": "steps",
|
||||
"sourceManual": "Manual",
|
||||
@@ -697,6 +725,38 @@
|
||||
"sectionNextSteps": "Next steps",
|
||||
"generatePrompt": "Click \"Generate\" for AI-powered insights about your health data."
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Personal thresholds",
|
||||
"subtitle": "Override the computed target range for any metric. Defaults stay in effect until you set a value here.",
|
||||
"defaultLabel": "Default",
|
||||
"overrideLabel": "Your override",
|
||||
"autoModeLabel": "Auto",
|
||||
"overrideModeLabel": "Override",
|
||||
"resetAction": "Reset to default",
|
||||
"resetAllAction": "Reset all to default",
|
||||
"minLabel": "Min",
|
||||
"maxLabel": "Max",
|
||||
"unitSuffix": "({unit})",
|
||||
"outOfBoundsHint": "Allowed range: {min}–{max} {unit}",
|
||||
"overrideWarning": "Overriding with values outside guideline ranges may produce unhealthy targets. Only do this on doctor's advice.",
|
||||
"saveSuccess": "Thresholds updated",
|
||||
"saveError": "Could not save thresholds",
|
||||
"resetSuccess": "Reset to default",
|
||||
"sourceOverride": "User override",
|
||||
"metricWeight": "Weight",
|
||||
"metricBpSys": "Blood pressure (systolic)",
|
||||
"metricBpDia": "Blood pressure (diastolic)",
|
||||
"metricPulse": "Resting pulse",
|
||||
"metricBodyFat": "Body fat",
|
||||
"metricSleep": "Sleep duration",
|
||||
"metricSteps": "Daily steps",
|
||||
"metricGlucoseFasting": "Glucose — fasting",
|
||||
"metricGlucosePostprandial": "Glucose — post-meal",
|
||||
"metricGlucoseRandom": "Glucose — random",
|
||||
"metricGlucoseBedtime": "Glucose — bedtime",
|
||||
"unsetExplanation": "No override — using computed default.",
|
||||
"loadError": "Could not load thresholds"
|
||||
},
|
||||
"targets": {
|
||||
"title": "Targets",
|
||||
"subtitle": "Personal reference ranges based on your profile",
|
||||
@@ -735,7 +795,11 @@
|
||||
"classificationNormal": "Normal",
|
||||
"classificationElevated": "Elevated",
|
||||
"classificationHigh": "High",
|
||||
"classificationLow": "Low"
|
||||
"classificationLow": "Low",
|
||||
"glucoseFasting": "Glucose — fasting",
|
||||
"glucosePostprandial": "Glucose — post-meal",
|
||||
"glucoseRandom": "Glucose — random",
|
||||
"glucoseBedtime": "Glucose — bedtime"
|
||||
},
|
||||
"onboarding": {
|
||||
"welcome": "Welcome to HealthLog!",
|
||||
@@ -764,6 +828,7 @@
|
||||
"categoryGeneral": "General",
|
||||
"categoryNotifications": "Notifications",
|
||||
"categoryAi": "AI",
|
||||
"categoryPersonalization": "Personalization",
|
||||
"categoryIntegration": "Integration",
|
||||
"categoryApi": "API",
|
||||
"profile": "Profile",
|
||||
@@ -1154,7 +1219,55 @@
|
||||
"reminderCheckNotifOn": "Notifications on",
|
||||
"reminderCheckNotifOff": "Notifications off",
|
||||
"reminderCheckEventsToday": "Events today",
|
||||
"reminderCheckNotifSent": "Sent"
|
||||
"reminderCheckNotifSent": "Sent",
|
||||
"feedback": {
|
||||
"title": "Feedback Inbox",
|
||||
"description": "User-submitted feedback, bug reports, and feature requests.",
|
||||
"tabOpen": "Open",
|
||||
"tabAcknowledged": "Acknowledged",
|
||||
"tabResolved": "Resolved",
|
||||
"tabArchived": "Archived",
|
||||
"subject": "Subject",
|
||||
"category": "Category",
|
||||
"user": "User",
|
||||
"createdAt": "Submitted",
|
||||
"actions": "Actions",
|
||||
"details": "Details",
|
||||
"noEntries": "No feedback in this category.",
|
||||
"loading": "Loading feedback...",
|
||||
"anonymous": "anonymous",
|
||||
"categoryBug": "Bug",
|
||||
"categoryFeature": "Feature",
|
||||
"categoryQuestion": "Question",
|
||||
"categoryOther": "Other",
|
||||
"statusOpen": "Open",
|
||||
"statusAcknowledged": "Acknowledged",
|
||||
"statusResolved": "Resolved",
|
||||
"statusArchived": "Archived",
|
||||
"dialogTitle": "Feedback details",
|
||||
"metadataHeading": "Metadata",
|
||||
"metaUrl": "URL",
|
||||
"metaLocale": "Locale",
|
||||
"metaUserAgent": "User agent",
|
||||
"metaAppVersion": "App version",
|
||||
"screenshotHeading": "Screenshot",
|
||||
"adminNote": "Admin note",
|
||||
"adminNotePlaceholder": "Internal note (not visible to user)",
|
||||
"saveNote": "Save note",
|
||||
"noteSaved": "Note saved",
|
||||
"actionAcknowledge": "Acknowledge",
|
||||
"actionResolve": "Resolve",
|
||||
"actionArchive": "Archive",
|
||||
"actionPublishGithub": "Publish to GitHub",
|
||||
"publishedToGithub": "Published to GitHub",
|
||||
"viewIssue": "View issue",
|
||||
"publishSuccess": "Issue created",
|
||||
"publishFailed": "GitHub publish failed",
|
||||
"updateFailed": "Update failed",
|
||||
"archiveConfirm": "Archive this feedback entry?",
|
||||
"submittedBy": "Submitted by",
|
||||
"sectionTitle": "Feedback"
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Achievements",
|
||||
@@ -1334,7 +1447,7 @@
|
||||
},
|
||||
"bugreport": {
|
||||
"title": "Bug Report",
|
||||
"subtitle": "Report a bug or issue. A GitHub issue will be created automatically.",
|
||||
"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",
|
||||
@@ -1352,7 +1465,15 @@
|
||||
"notConfiguredTitle": "Bug reports not available yet",
|
||||
"notConfiguredUser": "Bug reporting is disabled on this server. Please contact your administrator.",
|
||||
"notConfiguredAdmin": "Bug reporting requires a GitHub integration (issue token + repository). Configure it in the admin panel under Settings → Bug reports.",
|
||||
"openAdminSettings": "Open admin settings"
|
||||
"openAdminSettings": "Open admin settings",
|
||||
"category": "Type",
|
||||
"categoryBug": "Bug report",
|
||||
"categoryFeature": "Feature request",
|
||||
"categoryQuestion": "Question",
|
||||
"categoryOther": "Other feedback",
|
||||
"bugTitlePlaceholder": "Short summary (optional)",
|
||||
"githubEscalationNote": "Your feedback is stored locally. If the admin enables GitHub integration, it can be promoted to a public issue on request.",
|
||||
"internalOnlyNote": "Your feedback is stored locally and only visible to the administrator of this server."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
@@ -1506,5 +1627,27 @@
|
||||
"tooShort": "Too short",
|
||||
"characters": "characters",
|
||||
"minLength": "At least {count} characters required."
|
||||
},
|
||||
"telegram": {
|
||||
"errorInvalidAction": "Invalid action.",
|
||||
"errorMedicationNotFound": "Medication not found.",
|
||||
"errorMedicationInactive": "Medication not found or inactive.",
|
||||
"errorUnknownAction": "Unknown action.",
|
||||
"snoozeOneHour": "1 hour",
|
||||
"snoozeThreeHours": "3 hours",
|
||||
"snoozedFor": "{name} snoozed for {duration}.",
|
||||
"skipped": "{name} skipped.",
|
||||
"alreadyRecorded": "{name} was already recorded.",
|
||||
"recordedAsTaken": "{name} recorded as taken.",
|
||||
"confirmed": "{name} confirmed.",
|
||||
"genericConfirmed": "Confirmed.",
|
||||
"cancelled": "Cancelled.",
|
||||
"noActiveMedications": "No active medications found.",
|
||||
"whichMedication": "<b>Which medication did you take?</b>",
|
||||
"cancelButton": "Cancel",
|
||||
"askMedicationName": "Please specify a medication, e.g. <b>taken Ramipril</b>.",
|
||||
"medicationNotFoundExact": "Medication not found. Please send the name exactly as in HealthLog.",
|
||||
"helpHeader": "<b>Available commands:</b>",
|
||||
"helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\n<b>Text commands:</b>\ntaken <name> — confirm an intake\n\n<b>Buttons in reminders:</b>\n• Taken — confirm intake\n• 🕐 1h / 🕐 3h — postpone reminder\n• ⏭ Skip — skip intake\n• ✓ Confirm — confirm a missed intake"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
-- v1.2: Personalization + Blood Glucose + Built-in Feedback
|
||||
--
|
||||
-- This migration is purely additive.
|
||||
-- - New enums: measurement_type.BLOOD_GLUCOSE, glucose_context, feedback_category, feedback_status.
|
||||
-- - New columns on users: thresholds_json, dashboard_widgets_json, glucose_unit,
|
||||
-- ai_provider, ai_model, ai_base_url, ai_anthropic_key_encrypted, ai_local_key_encrypted.
|
||||
-- - New column on measurements: glucose_context (only set for BLOOD_GLUCOSE rows).
|
||||
-- - New table: feedback.
|
||||
|
||||
-- ── Enums ────────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BLOOD_GLUCOSE';
|
||||
|
||||
CREATE TYPE "glucose_context" AS ENUM ('FASTING', 'POSTPRANDIAL', 'RANDOM', 'BEDTIME');
|
||||
|
||||
CREATE TYPE "feedback_category" AS ENUM ('BUG', 'FEATURE_REQUEST', 'QUESTION', 'OTHER');
|
||||
|
||||
CREATE TYPE "feedback_status" AS ENUM ('OPEN', 'ACKNOWLEDGED', 'RESOLVED', 'ARCHIVED');
|
||||
|
||||
-- ── users personalization columns ───────────────────────────────────────
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "thresholds_json" JSONB,
|
||||
ADD COLUMN "dashboard_widgets_json" JSONB,
|
||||
ADD COLUMN "glucose_unit" TEXT,
|
||||
ADD COLUMN "ai_provider" TEXT,
|
||||
ADD COLUMN "ai_model" TEXT,
|
||||
ADD COLUMN "ai_base_url" TEXT,
|
||||
ADD COLUMN "ai_anthropic_key_encrypted" TEXT,
|
||||
ADD COLUMN "ai_local_key_encrypted" TEXT;
|
||||
|
||||
-- ── measurements.glucose_context ────────────────────────────────────────
|
||||
|
||||
ALTER TABLE "measurements"
|
||||
ADD COLUMN "glucose_context" "glucose_context";
|
||||
|
||||
-- Enforce: glucose_context is only set for BLOOD_GLUCOSE rows.
|
||||
ALTER TABLE "measurements"
|
||||
ADD CONSTRAINT "measurements_glucose_context_requires_type"
|
||||
CHECK (
|
||||
(type = 'BLOOD_GLUCOSE' AND glucose_context IS NOT NULL)
|
||||
OR
|
||||
(type <> 'BLOOD_GLUCOSE' AND glucose_context IS NULL)
|
||||
);
|
||||
|
||||
-- ── feedback table ──────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE "feedback" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"email" TEXT,
|
||||
"category" "feedback_category" NOT NULL DEFAULT 'BUG',
|
||||
"subject" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL,
|
||||
"screenshot_base64" TEXT,
|
||||
"metadata" JSONB,
|
||||
"status" "feedback_status" NOT NULL DEFAULT 'OPEN',
|
||||
"admin_note" TEXT,
|
||||
"github_issue_url" TEXT,
|
||||
"notified_email" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "feedback_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "feedback_status_created_at_idx" ON "feedback" ("status", "created_at");
|
||||
CREATE INDEX "feedback_user_id_idx" ON "feedback" ("user_id");
|
||||
|
||||
ALTER TABLE "feedback"
|
||||
ADD CONSTRAINT "feedback_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
+80
-1
@@ -53,6 +53,24 @@ model User {
|
||||
// Locale (null = use system/admin default)
|
||||
locale String? @map("locale") // "de" | "en" | null
|
||||
|
||||
// Personalization
|
||||
// Per-user overrides for computed default thresholds. Shape:
|
||||
// { "WEIGHT": { "min": 70, "max": 85 },
|
||||
// "BLOOD_PRESSURE_SYS": { "min": 110, "max": 130 }, ... }
|
||||
// Null or missing metric → use computed default.
|
||||
thresholdsJson Json? @map("thresholds_json")
|
||||
// Per-user dashboard layout — widget visibility + ordering.
|
||||
// Shape: { "widgets": [{ "id": "weight", "visible": true, "order": 0 }, ...], "version": 1 }
|
||||
dashboardWidgetsJson Json? @map("dashboard_widgets_json")
|
||||
// Blood-glucose display unit preference. Canonical storage is mg/dL.
|
||||
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
|
||||
// AI provider selection for insights. Null = OPENAI fallback.
|
||||
aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH"
|
||||
aiModel String? @map("ai_model")
|
||||
aiBaseUrl String? @map("ai_base_url")
|
||||
aiAnthropicKeyEncrypted String? @map("ai_anthropic_key_encrypted")
|
||||
aiLocalKeyEncrypted String? @map("ai_local_key_encrypted")
|
||||
|
||||
// Onboarding
|
||||
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
|
||||
|
||||
@@ -71,6 +89,7 @@ model User {
|
||||
pushSubscriptions PushSubscription[]
|
||||
dataBackups DataBackup[]
|
||||
achievements UserAchievement[]
|
||||
feedback Feedback[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -130,10 +149,22 @@ enum MeasurementType {
|
||||
BODY_FAT
|
||||
SLEEP_DURATION
|
||||
ACTIVITY_STEPS
|
||||
BLOOD_GLUCOSE // Canonical storage: mg/dL. Display unit (mg/dL or mmol/L) is user preference.
|
||||
|
||||
@@map("measurement_type")
|
||||
}
|
||||
|
||||
/// Context for glucose measurements — affects which target range applies.
|
||||
/// NULL for non-glucose measurements.
|
||||
enum GlucoseContext {
|
||||
FASTING // ≥8h without food, typically morning
|
||||
POSTPRANDIAL // 2h after meal
|
||||
RANDOM // any time, non-fasting, non-post-meal
|
||||
BEDTIME // before sleep
|
||||
|
||||
@@map("glucose_context")
|
||||
}
|
||||
|
||||
enum MeasurementSource {
|
||||
MANUAL
|
||||
WITHINGS
|
||||
@@ -147,11 +178,13 @@ model Measurement {
|
||||
userId String @map("user_id")
|
||||
type MeasurementType
|
||||
value Float
|
||||
unit String // kg, mmHg, bpm, %, hours, steps
|
||||
unit String // kg, mmHg, bpm, %, hours, steps, mg/dL
|
||||
source MeasurementSource @default(MANUAL)
|
||||
measuredAt DateTime @map("measured_at")
|
||||
notes String? // encrypted at app level if sensitive
|
||||
externalId String? @map("external_id") // Withings grpid for dedup
|
||||
// Only set for BLOOD_GLUCOSE measurements. NULL for all others.
|
||||
glucoseContext GlucoseContext? @map("glucose_context")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -386,6 +419,52 @@ model AppSettings {
|
||||
@@map("app_settings")
|
||||
}
|
||||
|
||||
// ─── Built-in Feedback ───────────────────────────────────────
|
||||
|
||||
enum FeedbackCategory {
|
||||
BUG
|
||||
FEATURE_REQUEST
|
||||
QUESTION
|
||||
OTHER
|
||||
|
||||
@@map("feedback_category")
|
||||
}
|
||||
|
||||
enum FeedbackStatus {
|
||||
OPEN
|
||||
ACKNOWLEDGED
|
||||
RESOLVED
|
||||
ARCHIVED
|
||||
|
||||
@@map("feedback_status")
|
||||
}
|
||||
|
||||
/// Stores user-submitted feedback without requiring external GitHub config.
|
||||
/// Admin inbox lives at /admin#feedback; optional escalation publishes to a
|
||||
/// GitHub issue reusing the existing integration when configured.
|
||||
model Feedback {
|
||||
id String @id @default(cuid())
|
||||
userId String? @map("user_id") // Null after user deletion; email snapshot remains.
|
||||
email String? // Snapshot at submission time for triage continuity.
|
||||
category FeedbackCategory @default(BUG)
|
||||
subject String
|
||||
description String
|
||||
screenshotBase64 String? @map("screenshot_base64") // Optional, <5MB, PNG/JPG.
|
||||
metadata Json? // URL, userAgent, locale, appVersion, …
|
||||
status FeedbackStatus @default(OPEN)
|
||||
adminNote String? @map("admin_note")
|
||||
gitHubIssueUrl String? @map("github_issue_url")
|
||||
notifiedEmail Boolean @default(false) @map("notified_email")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([status, createdAt])
|
||||
@@index([userId])
|
||||
@@map("feedback")
|
||||
}
|
||||
|
||||
// ─── Audit Log ─────────────────────────────────────────────
|
||||
|
||||
model AuditLog {
|
||||
|
||||
@@ -19,6 +19,14 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Shield,
|
||||
Users,
|
||||
@@ -45,6 +53,10 @@ import {
|
||||
Key,
|
||||
Bug,
|
||||
Cog,
|
||||
MessageSquare,
|
||||
Inbox,
|
||||
ExternalLink,
|
||||
GitPullRequest,
|
||||
} from "lucide-react";
|
||||
import { PasswordStrength } from "@/components/ui/password-strength";
|
||||
import { formatDate, formatDateTime } from "@/lib/format";
|
||||
@@ -167,6 +179,11 @@ export default function AdminPage() {
|
||||
<WebPushVapidSection id="section-admin-webpush" />
|
||||
<BugReportSection id="section-admin-bugreport" />
|
||||
|
||||
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.sectionTitle")}
|
||||
</h2>
|
||||
<FeedbackInboxSection id="section-admin-feedback" />
|
||||
|
||||
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||
{t("admin.sectionMedication")}
|
||||
</h2>
|
||||
@@ -2169,3 +2186,501 @@ function UserManagementSection({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────── Feedback Inbox ─────────────────────── */
|
||||
|
||||
type FeedbackStatusType = "OPEN" | "ACKNOWLEDGED" | "RESOLVED" | "ARCHIVED";
|
||||
type FeedbackCategoryType = "BUG" | "FEATURE_REQUEST" | "QUESTION" | "OTHER";
|
||||
|
||||
interface FeedbackItem {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
email: string | null;
|
||||
category: FeedbackCategoryType;
|
||||
subject: string;
|
||||
description: string;
|
||||
status: FeedbackStatusType;
|
||||
adminNote: string | null;
|
||||
gitHubIssueUrl: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
screenshotBase64: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: { username: string } | null;
|
||||
}
|
||||
|
||||
interface FeedbackListResponse {
|
||||
items: FeedbackItem[];
|
||||
meta: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
countsByStatus: Partial<Record<FeedbackStatusType, number>>;
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_TABS: FeedbackStatusType[] = [
|
||||
"OPEN",
|
||||
"ACKNOWLEDGED",
|
||||
"RESOLVED",
|
||||
"ARCHIVED",
|
||||
];
|
||||
|
||||
function FeedbackInboxSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ["admin", "status"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/admin/status");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as SystemStatus;
|
||||
},
|
||||
});
|
||||
const githubConfigured = Boolean(status?.integrations.bugReport?.configured);
|
||||
|
||||
const [activeStatus, setActiveStatus] =
|
||||
useState<FeedbackStatusType>("OPEN");
|
||||
const [selected, setSelected] = useState<FeedbackItem | null>(null);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["admin", "feedback", activeStatus],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/admin/feedback?status=${activeStatus}&limit=100`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
return (await res.json()).data as FeedbackListResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const counts = data?.meta.countsByStatus ?? {};
|
||||
|
||||
function refresh() {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "feedback"] });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Inbox className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("admin.feedback.title")}</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.feedback.description")}
|
||||
</p>
|
||||
|
||||
<Tabs
|
||||
value={activeStatus}
|
||||
onValueChange={(v) => setActiveStatus(v as FeedbackStatusType)}
|
||||
className="mt-4"
|
||||
>
|
||||
<TabsList>
|
||||
{STATUS_TABS.map((s) => (
|
||||
<TabsTrigger key={s} value={s}>
|
||||
<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>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{STATUS_TABS.map((s) => (
|
||||
<TabsContent key={s} value={s} className="mt-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.feedback.loading")}
|
||||
</span>
|
||||
</div>
|
||||
) : !data?.items.length ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.feedback.noEntries")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.createdAt")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.category")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.subject")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
{t("admin.feedback.user")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
{t("admin.feedback.actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y">
|
||||
{data.items.map((item, i) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={`cursor-pointer hover:bg-muted/40 ${i % 2 === 0 ? "bg-muted/30" : ""}`}
|
||||
onClick={() => setSelected(item)}
|
||||
>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
|
||||
{formatDateTime(item.createdAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<FeedbackCategoryBadge category={item.category} />
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium truncate max-w-[28ch]">
|
||||
{item.subject}
|
||||
</td>
|
||||
<td className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{item.user?.username ?? t("admin.feedback.anonymous")}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelected(item);
|
||||
}}
|
||||
>
|
||||
{t("admin.feedback.details")}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{selected && (
|
||||
<FeedbackDetailDialog
|
||||
item={selected}
|
||||
open={!!selected}
|
||||
onOpenChange={(open) => !open && setSelected(null)}
|
||||
githubConfigured={githubConfigured}
|
||||
onMutated={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackCategoryBadge({ category }: { category: FeedbackCategoryType }) {
|
||||
const { t } = useTranslations();
|
||||
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",
|
||||
},
|
||||
FEATURE_REQUEST: {
|
||||
label: t("admin.feedback.categoryFeature"),
|
||||
className: "bg-purple-500/15 text-purple-400 border-purple-500/30",
|
||||
},
|
||||
QUESTION: {
|
||||
label: t("admin.feedback.categoryQuestion"),
|
||||
className: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
||||
},
|
||||
OTHER: {
|
||||
label: t("admin.feedback.categoryOther"),
|
||||
className: "bg-muted text-muted-foreground border-border",
|
||||
},
|
||||
};
|
||||
const cfg = map[category];
|
||||
return <Badge className={`text-xs border ${cfg.className}`}>{cfg.label}</Badge>;
|
||||
}
|
||||
|
||||
function FeedbackDetailDialog({
|
||||
item,
|
||||
open,
|
||||
onOpenChange,
|
||||
githubConfigured,
|
||||
onMutated,
|
||||
}: {
|
||||
item: FeedbackItem;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
githubConfigured: boolean;
|
||||
onMutated: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [note, setNote] = useState(item.adminNote ?? "");
|
||||
const [issueUrl, setIssueUrl] = useState(item.gitHubIssueUrl);
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: async (payload: {
|
||||
status?: FeedbackStatusType;
|
||||
adminNote?: string | null;
|
||||
}) => {
|
||||
const res = await fetch(`/api/admin/feedback/${item.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error(await getApiErrorMessage(res));
|
||||
},
|
||||
onSuccess: (_, vars) => {
|
||||
onMutated();
|
||||
if (vars.adminNote !== undefined) {
|
||||
toast.success(t("admin.feedback.noteSaved"));
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const archive = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(`/api/admin/feedback/${item.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error(await getApiErrorMessage(res));
|
||||
},
|
||||
onSuccess: () => {
|
||||
onMutated();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const publish = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch(`/api/admin/feedback/${item.id}/github`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error(await getApiErrorMessage(res));
|
||||
return (await res.json()).data as { issueUrl: string };
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setIssueUrl(data.issueUrl);
|
||||
toast.success(t("admin.feedback.publishSuccess"));
|
||||
onMutated();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : t("admin.feedback.publishFailed"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const meta = item.metadata ?? {};
|
||||
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;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
{item.subject}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<FeedbackCategoryBadge category={item.category} />
|
||||
<span>
|
||||
{t("admin.feedback.submittedBy")}:{" "}
|
||||
{item.user?.username ?? t("admin.feedback.anonymous")}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{formatDateTime(item.createdAt)}</span>
|
||||
{issueUrl && (
|
||||
<a
|
||||
href={issueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1"
|
||||
>
|
||||
<GitPullRequest className="h-3 w-3" />
|
||||
{t("admin.feedback.viewIssue")}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
|
||||
{item.description}
|
||||
</div>
|
||||
|
||||
{(url || locale || userAgent || appVersion) && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("admin.feedback.metadataHeading")}
|
||||
</h4>
|
||||
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
{url && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUrl")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{url}</dd>
|
||||
</>
|
||||
)}
|
||||
{locale && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaLocale")}
|
||||
</dt>
|
||||
<dd className="font-mono">{locale}</dd>
|
||||
</>
|
||||
)}
|
||||
{appVersion && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaAppVersion")}
|
||||
</dt>
|
||||
<dd className="font-mono">{appVersion}</dd>
|
||||
</>
|
||||
)}
|
||||
{userAgent && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUserAgent")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{userAgent}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.screenshotBase64 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
||||
{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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-feedback-note" className="text-xs">
|
||||
{t("admin.feedback.adminNote")}
|
||||
</Label>
|
||||
<textarea
|
||||
id="admin-feedback-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
placeholder={t("admin.feedback.adminNotePlaceholder")}
|
||||
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending || note === (item.adminNote ?? "")}
|
||||
onClick={() => update.mutate({ adminNote: note || null })}
|
||||
>
|
||||
{update.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("admin.feedback.saveNote")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending || item.status === "ACKNOWLEDGED"}
|
||||
onClick={() => update.mutate({ status: "ACKNOWLEDGED" })}
|
||||
>
|
||||
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionAcknowledge")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={update.isPending || item.status === "RESOLVED"}
|
||||
onClick={() => update.mutate({ status: "RESOLVED" })}
|
||||
>
|
||||
<CheckCircle2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionResolve")}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={archive.isPending || item.status === "ARCHIVED"}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t("admin.feedback.actionArchive")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("admin.feedback.archiveConfirm")}
|
||||
</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => archive.mutate()}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{t("admin.feedback.actionArchive")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{githubConfigured && !issueUrl && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={publish.isPending}
|
||||
onClick={() => publish.mutate()}
|
||||
className="ml-auto"
|
||||
>
|
||||
{publish.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<GitPullRequest className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("admin.feedback.actionPublishGithub")}
|
||||
</Button>
|
||||
)}
|
||||
{issueUrl && (
|
||||
<Badge className="ml-auto border-dracula-green/30 bg-dracula-green/15 text-dracula-green">
|
||||
{t("admin.feedback.publishedToGithub")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -71,8 +71,39 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
if (baseUrl !== undefined) {
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) throw new HttpError(422, "Base URL cannot be empty");
|
||||
if (!trimmed.startsWith("https://")) {
|
||||
throw new HttpError(422, "Base URL must start with https://");
|
||||
|
||||
// The admin AI base URL has full egress of the bearer key on every
|
||||
// insight call. Lock it to HTTPS + a hostname allowlist so a
|
||||
// compromised admin (or stolen cookie) cannot point it at
|
||||
// attacker.example and exfiltrate the key on the next AI call.
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
throw new HttpError(422, "Base URL is not a valid URL");
|
||||
}
|
||||
if (parsed.protocol !== "https:") {
|
||||
throw new HttpError(422, "Base URL must use https://");
|
||||
}
|
||||
const allowedHosts = new Set([
|
||||
"api.openai.com",
|
||||
"api.anthropic.com",
|
||||
"generativelanguage.googleapis.com",
|
||||
"api.mistral.ai",
|
||||
"api.groq.com",
|
||||
"openrouter.ai",
|
||||
]);
|
||||
for (const h of (process.env.ADMIN_AI_BASE_URL_ALLOWLIST ?? "")
|
||||
.split(",")
|
||||
.map((h) => h.trim())
|
||||
.filter(Boolean)) {
|
||||
allowedHosts.add(h);
|
||||
}
|
||||
if (!allowedHosts.has(parsed.hostname)) {
|
||||
throw new HttpError(
|
||||
422,
|
||||
`Base URL host '${parsed.hostname}' not in allowlist. Set ADMIN_AI_BASE_URL_ALLOWLIST if you need a custom provider.`,
|
||||
);
|
||||
}
|
||||
updates.adminAiBaseUrl = trimmed;
|
||||
auditDetails.baseUrl = trimmed;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* POST /api/admin/feedback/[id]/github — promote feedback to a GitHub Issue.
|
||||
*
|
||||
* Reuses publishFeedbackToGithub() so the issue body matches the legacy
|
||||
* /api/bugreport flow. Idempotent: if gitHubIssueUrl is already set we return
|
||||
* the existing URL instead of creating a duplicate.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
publishFeedbackToGithub,
|
||||
GithubPublishError,
|
||||
} from "@/lib/feedback/publish-github";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user: admin } = await requireAdmin();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const feedback = await prisma.feedback.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
category: true,
|
||||
subject: true,
|
||||
description: true,
|
||||
gitHubIssueUrl: true,
|
||||
screenshotBase64: true,
|
||||
metadata: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
});
|
||||
if (!feedback) return apiError("Feedback not found", 404);
|
||||
|
||||
if (feedback.gitHubIssueUrl) {
|
||||
return apiError(
|
||||
`Already published: ${feedback.gitHubIssueUrl}`,
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
// Atomic claim to prevent concurrent admin clicks from creating two
|
||||
// duplicate GitHub issues. Only the first caller whose updateMany
|
||||
// matches (gitHubIssueUrl still null) proceeds. We write a PENDING
|
||||
// sentinel now and replace it with the real URL after the API call.
|
||||
const PENDING = "pending://creating";
|
||||
const claim = await prisma.feedback.updateMany({
|
||||
where: { id, gitHubIssueUrl: null },
|
||||
data: { gitHubIssueUrl: PENDING },
|
||||
});
|
||||
if (claim.count !== 1) {
|
||||
return apiError("Already being published by another request", 409);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await publishFeedbackToGithub({
|
||||
category: feedback.category,
|
||||
subject: feedback.subject,
|
||||
description: feedback.description,
|
||||
username: feedback.user?.username ?? "anonymous",
|
||||
hasScreenshot: Boolean(feedback.screenshotBase64),
|
||||
metadata: feedback.metadata,
|
||||
});
|
||||
|
||||
await prisma.feedback.update({
|
||||
where: { id },
|
||||
data: { gitHubIssueUrl: result.issueUrl },
|
||||
});
|
||||
|
||||
await auditLog("admin.feedback.github_publish", {
|
||||
userId: admin.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { feedbackId: id, issueNumber: result.issueNumber },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.feedback.github_publish" },
|
||||
meta: { feedback_id: id, issue_number: result.issueNumber },
|
||||
});
|
||||
|
||||
return apiSuccess({ issueUrl: result.issueUrl });
|
||||
} catch (err) {
|
||||
// Release the PENDING claim so a retry can succeed.
|
||||
await prisma.feedback
|
||||
.updateMany({
|
||||
where: { id, gitHubIssueUrl: PENDING },
|
||||
data: { gitHubIssueUrl: null },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
if (err instanceof GithubPublishError) {
|
||||
return apiError(err.message, err.status);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* PATCH /api/admin/feedback/[id] — change status or admin note.
|
||||
* DELETE /api/admin/feedback/[id] — archive (soft: sets status to ARCHIVED).
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { updateFeedbackSchema } from "@/lib/validations/feedback";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user } = await requireAdmin();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = updateFeedbackSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const before = await prisma.feedback.findUnique({ where: { id } });
|
||||
if (!before) return apiError("Feedback not found", 404);
|
||||
|
||||
const updated = await prisma.feedback.update({
|
||||
where: { id },
|
||||
data: parsed.data,
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("admin.feedback.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
feedbackId: id,
|
||||
before: { status: before.status, adminNote: before.adminNote },
|
||||
after: parsed.data,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.feedback.update" },
|
||||
meta: { feedback_id: id },
|
||||
});
|
||||
|
||||
return apiSuccess(updated);
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user } = await requireAdmin();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const updated = await prisma.feedback.update({
|
||||
where: { id },
|
||||
data: { status: "ARCHIVED" },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
await auditLog("admin.feedback.archive", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { feedbackId: id },
|
||||
});
|
||||
|
||||
return apiSuccess(updated);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Admin feedback inbox. Lists all feedback with status filter.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { NextRequest } from "next/server";
|
||||
import type { FeedbackStatus } from "@/generated/prisma/client";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
await requireAdmin();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const status = url.searchParams.get("status") as FeedbackStatus | null;
|
||||
const limit = Math.min(
|
||||
100,
|
||||
Math.max(1, Number(url.searchParams.get("limit") ?? 50)),
|
||||
);
|
||||
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
|
||||
|
||||
const where = status ? { status } : {};
|
||||
|
||||
const [items, total, counts] = await Promise.all([
|
||||
prisma.feedback.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
email: true,
|
||||
category: true,
|
||||
subject: true,
|
||||
description: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
gitHubIssueUrl: true,
|
||||
metadata: true,
|
||||
screenshotBase64: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
}),
|
||||
prisma.feedback.count({ where }),
|
||||
prisma.feedback.groupBy({
|
||||
by: ["status"],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.feedback.list" },
|
||||
meta: { total, filtered_status: status },
|
||||
});
|
||||
|
||||
const countsByStatus = Object.fromEntries(
|
||||
counts.map((c) => [c.status, c._count._all]),
|
||||
) as Record<FeedbackStatus, number>;
|
||||
|
||||
return apiSuccess({
|
||||
items,
|
||||
meta: { total, limit, offset, countsByStatus },
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { destroyAllSessions } from "@/lib/auth/session";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const POST = apiHandler(async (
|
||||
request: NextRequest,
|
||||
@@ -38,7 +39,11 @@ export const POST = apiHandler(async (
|
||||
const target = await prisma.user.findUnique({ where: { id } });
|
||||
if (!target) return apiError("User not found", 404);
|
||||
|
||||
const strength = checkPasswordStrength(password, [target.username]);
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: target.locale ?? null,
|
||||
});
|
||||
const strength = checkPasswordStrength(password, [target.username], locale);
|
||||
if (!strength.isAcceptable) {
|
||||
return apiError(
|
||||
strength.feedback[0] || "Password too weak (score < 3)",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "ai.test" } });
|
||||
|
||||
const rl = await checkRateLimit(`ai-test:${user.id}`, 5, 60_000);
|
||||
if (!rl.allowed) return apiError("Too many test requests", 429);
|
||||
|
||||
const provider = await resolveProvider(user.id);
|
||||
if (provider.type === "none") {
|
||||
return apiError("No AI provider configured", 422);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: "You are a connection-test responder.",
|
||||
userPrompt: 'Reply with the JSON object {"ok": true} and nothing else.',
|
||||
temperature: 0,
|
||||
maxTokens: 32,
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
ok: true,
|
||||
providerType: result.providerType,
|
||||
model: result.model,
|
||||
tokensUsed: result.tokensUsed,
|
||||
sample: result.content.slice(0, 200),
|
||||
});
|
||||
} catch (e) {
|
||||
const err = e as Error & { httpStatus?: number; bodyExcerpt?: string };
|
||||
return apiError(
|
||||
`${err.message}${err.bodyExcerpt ? ` — ${err.bodyExcerpt.slice(0, 200)}` : ""}`,
|
||||
502,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -20,6 +20,7 @@ export const GET = apiHandler(async () => {
|
||||
"BODY_FAT",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"BLOOD_GLUCOSE",
|
||||
];
|
||||
|
||||
const measurementsByType = await Promise.all(
|
||||
@@ -110,9 +111,28 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-context glucose summaries (canonical mg/dL).
|
||||
const glucoseRows = await prisma.measurement.findMany({
|
||||
where: { userId: user.id, type: "BLOOD_GLUCOSE" },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
});
|
||||
const glucoseByContext: Record<string, ReturnType<typeof summarize>> = {};
|
||||
if (glucoseRows.length > 0) {
|
||||
const contexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"] as const;
|
||||
for (const ctx of contexts) {
|
||||
const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx);
|
||||
if (ctxRows.length === 0) continue;
|
||||
glucoseByContext[ctx] = summarize(
|
||||
ctxRows.map((r): DataPoint => ({ date: r.measuredAt, value: r.value })),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
summaries: results,
|
||||
bmi,
|
||||
bpInTargetPct,
|
||||
glucoseByContext,
|
||||
});
|
||||
});
|
||||
@@ -21,5 +21,6 @@ export const GET = apiHandler(async () => {
|
||||
timezone: user.timezone,
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
gravatarUrl: user.email ? getGravatarUrl(user.email) : null,
|
||||
glucoseUnit: user.glucoseUnit ?? null,
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { destroyAllSessions, createSession } from "@/lib/auth/session";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -56,10 +57,15 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
const strength = checkPasswordStrength(newPassword, [
|
||||
user.username,
|
||||
user.email ?? "",
|
||||
]);
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
});
|
||||
const strength = checkPasswordStrength(
|
||||
newPassword,
|
||||
[user.username, user.email ?? ""],
|
||||
locale,
|
||||
);
|
||||
if (!strength.isAcceptable) {
|
||||
return apiError(
|
||||
strength.feedback[0] || "Password too weak (score < 3)",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ensureDbCompatibility } from "@/lib/db-compat";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
@@ -65,8 +66,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError("Username or email already taken", 409);
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
const strength = checkPasswordStrength(password, [username, email]);
|
||||
// Validate password strength (locale-aware feedback)
|
||||
const locale = await resolveServerLocale({ request });
|
||||
const strength = checkPasswordStrength(password, [username, email], locale);
|
||||
if (!strength.isAcceptable) {
|
||||
return apiError(
|
||||
strength.feedback[0] || "Password too weak (score < 3)",
|
||||
|
||||
@@ -8,11 +8,16 @@ import { decrypt } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// Strict image/* data URL (no SVG — it can carry inline JS).
|
||||
const SCREENSHOT_DATA_URL =
|
||||
/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/;
|
||||
|
||||
const bugReportSchema = z.object({
|
||||
description: z.string().min(10, "Description too short").max(5000),
|
||||
screenshot: z
|
||||
.string()
|
||||
.max(7_000_000, "Screenshot too large (max 5 MB)")
|
||||
.regex(SCREENSHOT_DATA_URL, "Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL")
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -72,16 +77,24 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const dateStr = now.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
const title = `Bug Report – ${dateStr}`;
|
||||
|
||||
// Sanitize user-provided content for GitHub markdown
|
||||
// Sanitize user-provided content for GitHub markdown. Strip HTML tags
|
||||
// entirely (prevents `<img onerror=>` etc.) and neutralise markdown link
|
||||
// targets that start with dangerous schemes like javascript: or data:.
|
||||
const safeUsername = user.username.replace(/[`*_~<>\[\]|]/g, "");
|
||||
const safeDescription = description
|
||||
.replace(/<[^>]*>/g, "") // strip HTML tags
|
||||
.replace(
|
||||
/\]\(\s*(javascript|data|vbscript|file):/gi,
|
||||
"](about:blank#",
|
||||
) // neutralise dangerous markdown link schemes
|
||||
.replace(/```/g, "\\`\\`\\`")
|
||||
.slice(0, 5000);
|
||||
|
||||
// Build issue body
|
||||
// Wrap the user's description in a fenced code block so any residual
|
||||
// markdown renders literally rather than as executable markup.
|
||||
let issueBody = `**Reported by:** ${safeUsername}\n`;
|
||||
issueBody += `**Date:** ${dateStr}\n\n`;
|
||||
issueBody += `## Description\n\n${safeDescription}\n`;
|
||||
issueBody += `## Description\n\n\`\`\`\n${safeDescription}\n\`\`\`\n`;
|
||||
issueBody += `\n---\n*Created via HealthLog bug report*`;
|
||||
|
||||
// If screenshot provided, upload to GitHub as a gist or include as base64
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* GET / PUT / DELETE dashboard widget layout.
|
||||
*
|
||||
* GET returns the resolved effective layout (defaults merged in if the user
|
||||
* hasn't customized yet). PUT replaces the layout atomically. DELETE resets
|
||||
* to default.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
resolveDashboardLayout,
|
||||
serializeDashboardLayout,
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
type DashboardLayout,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
const widgetIdEnum = z.enum([
|
||||
"weight",
|
||||
"bp",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"mood",
|
||||
"medications",
|
||||
"sleep",
|
||||
"steps",
|
||||
"glucose",
|
||||
"bpInTarget",
|
||||
]);
|
||||
|
||||
const layoutSchema = z.object({
|
||||
version: z.literal(1),
|
||||
widgets: z
|
||||
.array(
|
||||
z.object({
|
||||
id: widgetIdEnum,
|
||||
visible: z.boolean(),
|
||||
order: z.number().int().min(0).max(99),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(20),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
});
|
||||
|
||||
const layout = resolveDashboardLayout(row?.dashboardWidgetsJson);
|
||||
return apiSuccess(layout);
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = layoutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const normalized = serializeDashboardLayout(parsed.data as DashboardLayout);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
dashboardWidgetsJson: normalized as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "dashboard.widgets.update" },
|
||||
meta: { visible_count: normalized.widgets.filter((w) => w.visible).length },
|
||||
});
|
||||
|
||||
return apiSuccess(normalized);
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { dashboardWidgetsJson: Prisma.JsonNull },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "dashboard.widgets.reset" } });
|
||||
|
||||
return apiSuccess(DEFAULT_DASHBOARD_LAYOUT);
|
||||
});
|
||||
@@ -5,6 +5,12 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
type ThresholdOverridesJson,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { resolveGlucoseUnit, thresholdMetricForContext } from "@/lib/glucose";
|
||||
import type { GlucoseContext } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Collect data for doctor report PDF generation (client-side).
|
||||
@@ -58,6 +64,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
heightCm: true,
|
||||
glucoseUnit: true,
|
||||
thresholdsJson: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
@@ -145,6 +153,46 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
annotate({ meta: { report_days: days } });
|
||||
|
||||
// Per-context glucose stats (canonical mg/dL) and effective ranges.
|
||||
const glucoseContexts: GlucoseContext[] = [
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
"RANDOM",
|
||||
"BEDTIME",
|
||||
];
|
||||
const glucoseStats: Record<
|
||||
string,
|
||||
{ avg: number; min: number; max: number; count: number; latest: number }
|
||||
> = {};
|
||||
const glucoseRanges: Record<string, { min: number; max: number }> = {};
|
||||
const glucoseRows = measurements.filter((m) => m.type === "BLOOD_GLUCOSE");
|
||||
const overrides = (userProfile?.thresholdsJson ?? null) as ThresholdOverridesJson | null;
|
||||
const profileForRange = {
|
||||
heightCm: userProfile?.heightCm ?? null,
|
||||
dateOfBirth: userProfile?.dateOfBirth ?? null,
|
||||
gender: userProfile?.gender ?? null,
|
||||
};
|
||||
for (const ctx of glucoseContexts) {
|
||||
const rows = glucoseRows.filter((m) => m.glucoseContext === ctx);
|
||||
if (rows.length === 0) continue;
|
||||
const values = rows.map((r) => r.value);
|
||||
glucoseStats[ctx] = {
|
||||
avg: values.reduce((a, b) => a + b, 0) / values.length,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
count: values.length,
|
||||
latest: values[values.length - 1],
|
||||
};
|
||||
const eff = getEffectiveRange(
|
||||
thresholdMetricForContext(ctx),
|
||||
profileForRange,
|
||||
overrides,
|
||||
);
|
||||
if (eff.range) {
|
||||
glucoseRanges[ctx] = { min: eff.range.greenMin, max: eff.range.greenMax };
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
period: { days, since: since.toISOString() },
|
||||
patient: {
|
||||
@@ -155,6 +203,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
measurements: byType,
|
||||
stats,
|
||||
glucoseStats,
|
||||
glucoseRanges,
|
||||
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
|
||||
bmi: bmi ? Math.round(bmi * 10) / 10 : null,
|
||||
compliance: complianceByMed,
|
||||
medications: medications.map((m) => ({
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* POST /api/feedback — user submits feedback. Always works, no GitHub needed.
|
||||
* GET /api/feedback — user's own feedback history (paginated).
|
||||
*
|
||||
* Rate-limited at 5 per hour per user. Screenshots are stored as base64 in
|
||||
* the database (≤5 MB each).
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createFeedbackSchema } from "@/lib/validations/feedback";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`feedback:${user.id}`, 5, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many submissions — please try again later", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = createFeedbackSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const feedback = await prisma.feedback.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
email: user.email ?? null,
|
||||
category: parsed.data.category,
|
||||
subject: parsed.data.subject,
|
||||
description: parsed.data.description,
|
||||
screenshotBase64: parsed.data.screenshot ?? null,
|
||||
metadata:
|
||||
parsed.data.metadata !== undefined
|
||||
? (parsed.data.metadata as Prisma.InputJsonValue)
|
||||
: Prisma.JsonNull,
|
||||
},
|
||||
select: { id: true, createdAt: true, category: true, status: true },
|
||||
});
|
||||
|
||||
await auditLog("feedback.submit", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
feedbackId: feedback.id,
|
||||
category: feedback.category,
|
||||
hasScreenshot: Boolean(parsed.data.screenshot),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "feedback.submit" },
|
||||
meta: {
|
||||
feedback_id: feedback.id,
|
||||
category: feedback.category,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(feedback, 201);
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = Math.min(
|
||||
50,
|
||||
Math.max(1, Number(url.searchParams.get("limit") ?? 25)),
|
||||
);
|
||||
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.feedback.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
category: true,
|
||||
subject: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
gitHubIssueUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.feedback.count({ where: { userId: user.id } }),
|
||||
]);
|
||||
|
||||
return apiSuccess({ items, meta: { total, limit, offset } });
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/lib/insights/blood-pressure-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolveBloodPressureStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveBloodPressureStatusLocale(resolved);
|
||||
|
||||
const result = await generateBloodPressureStatusForUser(
|
||||
user.id,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/lib/insights/bmi-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolveBmiStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveBmiStatusLocale(resolved);
|
||||
|
||||
const result = await generateBmiStatusForUser(user.id, {
|
||||
locale,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/lib/insights/general-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolveGeneralStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveGeneralStatusLocale(resolved);
|
||||
|
||||
const result = await generateGeneralStatusForUser(user.id, {
|
||||
locale,
|
||||
|
||||
@@ -2,13 +2,14 @@ import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { extractFeatures } from "@/lib/insights/features";
|
||||
import { INSIGHTS_SYSTEM_PROMPT, buildUserPrompt } from "@/lib/insights/prompt";
|
||||
import { getInsightsSystemPrompt, buildUserPrompt } from "@/lib/insights/prompt";
|
||||
import { insightResultSchema, type InsightResult } from "@/lib/ai/types";
|
||||
import { resolveProvider } from "@/lib/ai/provider";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -25,9 +26,15 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
insightsPrivacyMode: true,
|
||||
insightsCachedAt: true,
|
||||
insightsCachedText: true,
|
||||
locale: true,
|
||||
},
|
||||
});
|
||||
|
||||
const locale = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: dbUser?.locale ?? user.locale ?? null,
|
||||
});
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const forceRefresh = body.force === true;
|
||||
|
||||
@@ -55,10 +62,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const includeRaw = dbUser?.insightsPrivacyMode === "raw";
|
||||
const features = await extractFeatures(userId, includeRaw);
|
||||
const featuresJson = JSON.stringify(features, null, 2);
|
||||
const userPrompt = buildUserPrompt(featuresJson, dbUser?.insightsPrivacyMode ?? "aggregated");
|
||||
const userPrompt = buildUserPrompt(
|
||||
featuresJson,
|
||||
dbUser?.insightsPrivacyMode ?? "aggregated",
|
||||
locale,
|
||||
);
|
||||
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: INSIGHTS_SYSTEM_PROMPT,
|
||||
systemPrompt: getInsightsSystemPrompt(locale),
|
||||
userPrompt,
|
||||
temperature: 0.3,
|
||||
maxTokens: 1500,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/lib/insights/medication-compliance-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolveMedicationComplianceStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveMedicationComplianceStatusLocale(resolved);
|
||||
|
||||
const result = await generateMedicationComplianceStatusForUser(
|
||||
user.id,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/lib/insights/mood-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolveMoodStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveMoodStatusLocale(resolved);
|
||||
|
||||
const result = await generateMoodStatusForUser(user.id, {
|
||||
locale,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/lib/insights/pulse-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolvePulseStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolvePulseStatusLocale(resolved);
|
||||
|
||||
const result = await generatePulseStatusForUser(user.id, {
|
||||
locale,
|
||||
|
||||
@@ -17,9 +17,14 @@ import {
|
||||
classifyPulseByTarget,
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type { MeasurementType, GlucoseContext } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
type ThresholdOverridesJson,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import { thresholdMetricForContext, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -67,6 +72,8 @@ export const GET = apiHandler(async () => {
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
glucoseUnit: true,
|
||||
thresholdsJson: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -570,6 +577,80 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Blood glucose — one card per logged context.
|
||||
const glucoseContexts: GlucoseContext[] = [
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
"RANDOM",
|
||||
"BEDTIME",
|
||||
];
|
||||
const glucoseUnit = resolveGlucoseUnit(dbUser?.glucoseUnit ?? null);
|
||||
const overrides = (dbUser?.thresholdsJson ?? null) as ThresholdOverridesJson | null;
|
||||
const profileForRange = {
|
||||
heightCm,
|
||||
dateOfBirth: dbUser?.dateOfBirth ?? null,
|
||||
gender: dbUser?.gender ?? null,
|
||||
};
|
||||
const thirtyDaysAgoGlucose = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const glucoseRows = await prisma.measurement.findMany({
|
||||
where: { userId, type: "BLOOD_GLUCOSE" },
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { value: true, measuredAt: true, glucoseContext: true },
|
||||
});
|
||||
|
||||
const labelKeyByContext: Record<GlucoseContext, string> = {
|
||||
FASTING: "targets.glucoseFasting",
|
||||
POSTPRANDIAL: "targets.glucosePostprandial",
|
||||
RANDOM: "targets.glucoseRandom",
|
||||
BEDTIME: "targets.glucoseBedtime",
|
||||
};
|
||||
|
||||
for (const ctx of glucoseContexts) {
|
||||
const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx);
|
||||
if (ctxRows.length === 0) continue;
|
||||
const latest = ctxRows[0].value;
|
||||
const recent = ctxRows.filter((r) => r.measuredAt >= thirtyDaysAgoGlucose);
|
||||
const avg30 =
|
||||
recent.length > 0
|
||||
? Math.round(
|
||||
(recent.reduce((s, r) => s + r.value, 0) / recent.length) * 10,
|
||||
) / 10
|
||||
: null;
|
||||
|
||||
const metric = thresholdMetricForContext(ctx);
|
||||
const eff = getEffectiveRange(metric, profileForRange, overrides);
|
||||
const range = eff.range
|
||||
? { min: eff.range.greenMin, max: eff.range.greenMax }
|
||||
: null;
|
||||
|
||||
let classification: { category: string; color: string } | null = null;
|
||||
if (range) {
|
||||
if (latest >= range.min && latest <= range.max) {
|
||||
classification = { category: "Optimal", color: "#50fa7b" };
|
||||
} else if (
|
||||
eff.range &&
|
||||
latest >= eff.range.orangeMin &&
|
||||
latest <= eff.range.orangeMax
|
||||
) {
|
||||
classification = { category: "Elevated", color: "#f1fa8c" };
|
||||
} else {
|
||||
classification = { category: "High", color: "#ff5555" };
|
||||
}
|
||||
}
|
||||
|
||||
targets.push({
|
||||
type: `BLOOD_GLUCOSE_${ctx}`,
|
||||
label: labelKeyByContext[ctx],
|
||||
current: latest,
|
||||
average30: avg30,
|
||||
trend: null,
|
||||
unit: glucoseUnit,
|
||||
range,
|
||||
classification,
|
||||
source: eff.isOverride ? "Custom" : "ADA 2024 / DDG",
|
||||
});
|
||||
}
|
||||
|
||||
annotate({ action: { name: "insights.targets" }, meta: { targetCount: targets.length } });
|
||||
|
||||
return apiSuccess({
|
||||
@@ -584,6 +665,7 @@ export const GET = apiHandler(async () => {
|
||||
heightCm,
|
||||
age,
|
||||
gender,
|
||||
glucoseUnit,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@/lib/insights/weight-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,9 +14,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const locale = resolveWeightStatusLocale(
|
||||
localeParam ?? user.locale ?? "de",
|
||||
);
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveWeightStatusLocale(resolved);
|
||||
|
||||
const result = await generateWeightStatusForUser(user.id, {
|
||||
locale,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { NextRequest } from "next/server";
|
||||
import type {
|
||||
MeasurementType,
|
||||
MeasurementSource,
|
||||
GlucoseContext,
|
||||
} from "@/generated/prisma/client";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
@@ -85,6 +86,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
source: (m.source ?? "MANUAL") as MeasurementSource,
|
||||
measuredAt: m.measuredAt,
|
||||
notes: m.notes ?? null,
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
@@ -116,7 +119,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { type, value, measuredAt, notes, source } = parsed.data;
|
||||
const { type, value, measuredAt, notes, source, glucoseContext } =
|
||||
parsed.data;
|
||||
|
||||
// Handle unique constraint violation
|
||||
let measurement;
|
||||
@@ -130,6 +134,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
source: (source ?? "MANUAL") as MeasurementSource,
|
||||
measuredAt,
|
||||
notes: notes ?? null,
|
||||
glucoseContext: (glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -11,6 +11,8 @@ import { getClientIp } from "@/lib/api-response";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
|
||||
interface TelegramUpdate {
|
||||
update_id: number;
|
||||
@@ -56,6 +58,20 @@ function escapeHtml(input: string): string {
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the bot locale for an existing Telegram user.
|
||||
*
|
||||
* Defaults to "de" when User.locale is null — existing bots were set up in
|
||||
* German and we don't want to silently flip them. Users who have switched
|
||||
* the UI to English (User.locale = "en") get an English bot too.
|
||||
*/
|
||||
function resolveBotLocale(value: string | null | undefined): Locale {
|
||||
if (value && (locales as readonly string[]).includes(value)) {
|
||||
return value as Locale;
|
||||
}
|
||||
return "de";
|
||||
}
|
||||
|
||||
async function cleanupReminderTracking(medicationId: string): Promise<void> {
|
||||
try {
|
||||
await prisma.telegramReminderMessage.deleteMany({
|
||||
@@ -98,6 +114,7 @@ async function findTelegramUser(chatId: string) {
|
||||
select: {
|
||||
id: true,
|
||||
telegramBotToken: true,
|
||||
locale: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -106,13 +123,15 @@ async function markMedicationTaken(
|
||||
userId: string,
|
||||
medicationId: string,
|
||||
idempotencyKey: string,
|
||||
locale: Locale,
|
||||
): Promise<{ ok: boolean; message: string; medicationName?: string }> {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const medication = await prisma.medication.findFirst({
|
||||
where: { id: medicationId, userId, active: true },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!medication) {
|
||||
return { ok: false, message: "Medikament nicht gefunden oder inaktiv." };
|
||||
return { ok: false, message: t("telegram.errorMedicationInactive") };
|
||||
}
|
||||
|
||||
const existing = await prisma.medicationIntakeEvent.findFirst({
|
||||
@@ -143,8 +162,8 @@ async function markMedicationTaken(
|
||||
return {
|
||||
ok: true,
|
||||
message: existing
|
||||
? `${medication.name} war bereits erfasst.`
|
||||
: `${medication.name} als eingenommen erfasst.`,
|
||||
? t("telegram.alreadyRecorded", { name: medication.name })
|
||||
: t("telegram.recordedAsTaken", { name: medication.name }),
|
||||
medicationName: medication.name,
|
||||
};
|
||||
}
|
||||
@@ -160,6 +179,8 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
const user = await findTelegramUser(chatId);
|
||||
if (!user?.telegramBotToken) return;
|
||||
const botToken = decrypt(user.telegramBotToken);
|
||||
const locale = resolveBotLocale(user.locale);
|
||||
const { t } = getServerTranslator(locale);
|
||||
|
||||
const data = callback.data ?? "";
|
||||
const messageId = callback.message?.message_id;
|
||||
@@ -170,7 +191,7 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
"Ungültige Aktion.",
|
||||
t("telegram.errorInvalidAction"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -183,6 +204,7 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
user.id,
|
||||
medicationId,
|
||||
idempotencyKey,
|
||||
locale,
|
||||
);
|
||||
await answerTelegramCallbackQuery(botToken, callback.id, result.message);
|
||||
if (messageId) {
|
||||
@@ -198,7 +220,7 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
"Ungültige Aktion.",
|
||||
t("telegram.errorInvalidAction"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -211,7 +233,7 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
"Medikament nicht gefunden.",
|
||||
t("telegram.errorMedicationNotFound"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -221,11 +243,12 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
data: { snoozedUntil: new Date(Date.now() + minutes * 60000) },
|
||||
});
|
||||
|
||||
const label = minutes <= 60 ? "1 Stunde" : "3 Stunden";
|
||||
const duration =
|
||||
minutes <= 60 ? t("telegram.snoozeOneHour") : t("telegram.snoozeThreeHours");
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
`${medication.name} für ${label} zurückgestellt.`,
|
||||
t("telegram.snoozedFor", { name: medication.name, duration }),
|
||||
);
|
||||
if (messageId) {
|
||||
await deleteMessage(botToken, chatId, messageId);
|
||||
@@ -237,7 +260,7 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
"Ungültige Aktion.",
|
||||
t("telegram.errorInvalidAction"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -250,7 +273,7 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
"Medikament nicht gefunden.",
|
||||
t("telegram.errorMedicationNotFound"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -290,7 +313,7 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
`${medication.name} übersprungen.`,
|
||||
t("telegram.skipped", { name: medication.name }),
|
||||
);
|
||||
if (messageId) {
|
||||
await deleteMessage(botToken, chatId, messageId);
|
||||
@@ -299,7 +322,11 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
} else if (data.startsWith("ack:")) {
|
||||
const medicationId = data.slice("ack:".length).trim();
|
||||
if (!medicationId) {
|
||||
await answerTelegramCallbackQuery(botToken, callback.id, "Ungültige Aktion.");
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
t("telegram.errorInvalidAction"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -311,7 +338,9 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
medication ? `${medication.name} bestätigt.` : "Bestätigt.",
|
||||
medication
|
||||
? t("telegram.confirmed", { name: medication.name })
|
||||
: t("telegram.genericConfirmed"),
|
||||
);
|
||||
if (messageId) {
|
||||
await deleteMessage(botToken, chatId, messageId);
|
||||
@@ -323,7 +352,11 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
const umidIdx = withoutPrefix.indexOf(":umid:");
|
||||
const medicationId = umidIdx >= 0 ? withoutPrefix.slice(0, umidIdx) : withoutPrefix.trim();
|
||||
if (!medicationId) {
|
||||
await answerTelegramCallbackQuery(botToken, callback.id, "Ungültige Aktion.");
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
t("telegram.errorInvalidAction"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -331,7 +364,12 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
const idempotencyKey =
|
||||
`telegram:add:${chatId}:${msgId}:${medicationId}`.slice(0, 128);
|
||||
|
||||
const result = await markMedicationTaken(user.id, medicationId, idempotencyKey);
|
||||
const result = await markMedicationTaken(
|
||||
user.id,
|
||||
medicationId,
|
||||
idempotencyKey,
|
||||
locale,
|
||||
);
|
||||
await answerTelegramCallbackQuery(botToken, callback.id, result.message);
|
||||
if (messageId) {
|
||||
await deleteMessage(botToken, chatId, messageId);
|
||||
@@ -357,12 +395,12 @@ async function handleCallback(update: TelegramUpdate) {
|
||||
await deleteMessage(botToken, chatId, userMsgId).catch(() => {});
|
||||
}
|
||||
}
|
||||
await answerTelegramCallbackQuery(botToken, callback.id, "Abgebrochen.");
|
||||
await answerTelegramCallbackQuery(botToken, callback.id, t("telegram.cancelled"));
|
||||
} else {
|
||||
await answerTelegramCallbackQuery(
|
||||
botToken,
|
||||
callback.id,
|
||||
"Unbekannte Aktion.",
|
||||
t("telegram.errorUnknownAction"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -376,23 +414,21 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
const user = await findTelegramUser(chatId);
|
||||
if (!user?.telegramBotToken) return;
|
||||
const botToken = decrypt(user.telegramBotToken);
|
||||
const locale = resolveBotLocale(user.locale);
|
||||
const { t } = getServerTranslator(locale);
|
||||
|
||||
if (/^\/help\b/i.test(text) || /^\/start\b/i.test(text) || /^hilfe$/i.test(text)) {
|
||||
// "Help" / start: accept English + German keyword aliases independent of locale
|
||||
if (
|
||||
/^\/help\b/i.test(text) ||
|
||||
/^\/start\b/i.test(text) ||
|
||||
/^hilfe$/i.test(text) ||
|
||||
/^help$/i.test(text)
|
||||
) {
|
||||
const userMsgId = message?.message_id;
|
||||
const resp = await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
`<b>Verfügbare Befehle:</b>\n\n` +
|
||||
`/help — Diese Hilfe anzeigen\n` +
|
||||
`/add — Einnahme erfassen\n` +
|
||||
`/start — Bot starten\n\n` +
|
||||
`<b>Textbefehle:</b>\n` +
|
||||
`genommen <Name> — Einnahme bestätigen\n\n` +
|
||||
`<b>Über die Buttons in Erinnerungen:</b>\n` +
|
||||
`• Genommen — Einnahme bestätigen\n` +
|
||||
`• 🕐 1h / 🕐 3h — Erinnerung verschieben\n` +
|
||||
`• ⏭ Überspringen — Einnahme überspringen\n` +
|
||||
`• ✓ Bestätigen — Verpasste Einnahme bestätigen`,
|
||||
`${t("telegram.helpHeader")}\n\n${t("telegram.helpBody")}`,
|
||||
);
|
||||
const toDelete = [userMsgId, resp.messageId].filter(
|
||||
(id): id is number => id != null,
|
||||
@@ -415,7 +451,7 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
const resp = await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
"Keine aktiven Medikamente gefunden.",
|
||||
t("telegram.noActiveMedications"),
|
||||
);
|
||||
const toDelete = [userMsgId, resp.messageId].filter(
|
||||
(id): id is number => id != null,
|
||||
@@ -433,6 +469,7 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
user.id,
|
||||
meds[0].id,
|
||||
idempotencyKey,
|
||||
locale,
|
||||
);
|
||||
const resp = await sendTelegramMessage(
|
||||
botToken,
|
||||
@@ -462,7 +499,7 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
]),
|
||||
[
|
||||
{
|
||||
text: "Abbrechen",
|
||||
text: t("telegram.cancelButton"),
|
||||
callback_data: `cancel_add${umidSuffix}`,
|
||||
},
|
||||
],
|
||||
@@ -471,7 +508,7 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
const resp = await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
"<b>Welches Medikament eingenommen?</b>",
|
||||
t("telegram.whichMedication"),
|
||||
{ parseMode: "HTML", replyMarkup: keyboard },
|
||||
);
|
||||
// Fallback auto-delete if user never interacts with the selection
|
||||
@@ -484,8 +521,8 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Greeting responses
|
||||
const greetings = ["hi", "hallo", "hey", "moin"];
|
||||
// Greeting responses (kept locale-independent)
|
||||
const greetings = ["hi", "hallo", "hey", "moin", "hello"];
|
||||
const lowerText = text.toLowerCase();
|
||||
const matchedGreeting = greetings.find((g) => lowerText === g);
|
||||
if (matchedGreeting) {
|
||||
@@ -501,10 +538,13 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^genommen\b/i.test(text)) return;
|
||||
// Accept both "genommen <name>" (DE) and "taken <name>" (EN) regardless of
|
||||
// the user's UI locale — keeps backwards compatibility.
|
||||
const intakeKeyword = /^(?:genommen|taken)\b/i;
|
||||
if (!intakeKeyword.test(text)) return;
|
||||
|
||||
const userMsgId = message?.message_id;
|
||||
const nameInput = text.replace(/^genommen\b/i, "").trim();
|
||||
const nameInput = text.replace(intakeKeyword, "").trim();
|
||||
|
||||
let medicationId: string | null = null;
|
||||
if (nameInput) {
|
||||
@@ -530,7 +570,7 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
const resp = await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
"Bitte Medikament angeben, z.B. <b>genommen Ramipril</b>.",
|
||||
t("telegram.askMedicationName"),
|
||||
);
|
||||
const toDelete = [userMsgId, resp.messageId].filter(
|
||||
(id): id is number => id != null,
|
||||
@@ -546,7 +586,7 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
const resp = await sendTelegramMessage(
|
||||
botToken,
|
||||
chatId,
|
||||
"Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.",
|
||||
t("telegram.medicationNotFoundExact"),
|
||||
);
|
||||
const toDelete = [userMsgId, resp.messageId].filter(
|
||||
(id): id is number => id != null,
|
||||
@@ -563,6 +603,7 @@ async function handleTextMessage(update: TelegramUpdate) {
|
||||
user.id,
|
||||
medicationId,
|
||||
idempotencyKey,
|
||||
locale,
|
||||
);
|
||||
const resp = await sendTelegramMessage(
|
||||
botToken,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ALLOWED = new Set(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"]);
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.ai-provider.get" } });
|
||||
|
||||
const u = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
aiProvider: true,
|
||||
aiModel: true,
|
||||
aiBaseUrl: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
provider: u?.aiProvider ?? null,
|
||||
model: u?.aiModel ?? null,
|
||||
baseUrl: u?.aiBaseUrl ?? null,
|
||||
hasAnthropicKey: Boolean(u?.aiAnthropicKeyEncrypted),
|
||||
anthropicKeyPreview: u?.aiAnthropicKeyEncrypted
|
||||
? `...${decrypt(u.aiAnthropicKeyEncrypted).slice(-4)}`
|
||||
: null,
|
||||
hasLocalKey: Boolean(u?.aiLocalKeyEncrypted),
|
||||
});
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.ai-provider.update" } });
|
||||
|
||||
const { data: body, error } = await safeJson<Record<string, unknown>>(request);
|
||||
if (error) return error;
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
|
||||
if (body.provider !== undefined) {
|
||||
if (body.provider === null || body.provider === "") {
|
||||
updates.aiProvider = null;
|
||||
} else if (typeof body.provider === "string" && ALLOWED.has(body.provider)) {
|
||||
updates.aiProvider = body.provider;
|
||||
} else {
|
||||
throw new HttpError(422, "Invalid provider");
|
||||
}
|
||||
}
|
||||
|
||||
if (body.model !== undefined) {
|
||||
if (body.model === null || body.model === "") {
|
||||
updates.aiModel = null;
|
||||
} else if (typeof body.model === "string") {
|
||||
updates.aiModel = body.model.trim() || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (body.baseUrl !== undefined) {
|
||||
if (body.baseUrl === null || body.baseUrl === "") {
|
||||
updates.aiBaseUrl = null;
|
||||
} else if (typeof body.baseUrl === "string") {
|
||||
const trimmed = body.baseUrl.trim();
|
||||
// SSRF guard: by default reject private/internal hostnames so a
|
||||
// compromised user account cannot point the server at the cloud
|
||||
// metadata endpoint or internal admin panels. Ops can enable local
|
||||
// Ollama / LM Studio on this instance via env flag.
|
||||
const allowPrivate = process.env.ALLOW_LOCAL_AI_PRIVATE_HOSTS === "true";
|
||||
if (!allowPrivate && !isPublicUrl(trimmed)) {
|
||||
return apiError(
|
||||
"Base URL points to an internal/private host. Ops must set ALLOW_LOCAL_AI_PRIVATE_HOSTS=true on this instance to allow it (intended for self-hosted Ollama / LM Studio).",
|
||||
422,
|
||||
);
|
||||
}
|
||||
updates.aiBaseUrl = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
if (body.anthropicKey !== undefined) {
|
||||
if (body.anthropicKey === null || body.anthropicKey === "") {
|
||||
updates.aiAnthropicKeyEncrypted = null;
|
||||
} else if (typeof body.anthropicKey === "string") {
|
||||
updates.aiAnthropicKeyEncrypted = encrypt(body.anthropicKey.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (body.localKey !== undefined) {
|
||||
if (body.localKey === null || body.localKey === "") {
|
||||
updates.aiLocalKeyEncrypted = null;
|
||||
} else if (typeof body.localKey === "string") {
|
||||
updates.aiLocalKeyEncrypted = encrypt(body.localKey.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
throw new HttpError(422, "No valid fields");
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id: user.id }, data: updates });
|
||||
|
||||
return apiSuccess({ updated: true });
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* GET / PUT / DELETE per-user threshold overrides.
|
||||
*
|
||||
* GET returns `{ defaults, overrides, effective }` so the settings UI can
|
||||
* show a side-by-side comparison.
|
||||
* PUT accepts a partial map — only the provided metrics are updated, others
|
||||
* keep their existing override or remain on the default.
|
||||
* DELETE with ?metric=... resets one metric to default.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
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 {
|
||||
getAllEffectiveRanges,
|
||||
type ThresholdOverridesJson,
|
||||
type ThresholdMetric,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
import {
|
||||
thresholdsUpdateSchema,
|
||||
ALL_METRICS,
|
||||
} from "@/lib/validations/thresholds";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
async function loadProfileAndOverrides(userId: string) {
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
heightCm: true,
|
||||
dateOfBirth: true,
|
||||
gender: true,
|
||||
thresholdsJson: true,
|
||||
},
|
||||
});
|
||||
if (!profile) return null;
|
||||
const overrides = (profile.thresholdsJson ?? null) as
|
||||
| ThresholdOverridesJson
|
||||
| null;
|
||||
return { profile, overrides };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.thresholds.get" } });
|
||||
|
||||
const result = await loadProfileAndOverrides(user.id);
|
||||
if (!result) return apiError("User not found", 404);
|
||||
|
||||
const ranges = getAllEffectiveRanges(result.profile, result.overrides);
|
||||
return apiSuccess({
|
||||
effective: ranges,
|
||||
overrides: result.overrides ?? {},
|
||||
});
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.thresholds.update" } });
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = thresholdsUpdateSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { thresholdsJson: true },
|
||||
});
|
||||
const before = (existing?.thresholdsJson ?? {}) as ThresholdOverridesJson;
|
||||
const merged: ThresholdOverridesJson = { ...before, ...parsed.data };
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { thresholdsJson: merged },
|
||||
});
|
||||
|
||||
await auditLog("thresholds.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { before, after: merged },
|
||||
});
|
||||
|
||||
return apiSuccess({ overrides: merged });
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const url = new URL(request.url);
|
||||
const metric = url.searchParams.get("metric") as ThresholdMetric | null;
|
||||
|
||||
annotate({
|
||||
action: { name: "thresholds.reset" },
|
||||
meta: { metric: metric ?? "all" },
|
||||
});
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { thresholdsJson: true },
|
||||
});
|
||||
const before = (existing?.thresholdsJson ?? {}) as ThresholdOverridesJson;
|
||||
|
||||
let after: ThresholdOverridesJson = before;
|
||||
if (metric) {
|
||||
if (!ALL_METRICS.includes(metric)) {
|
||||
return apiError("Unknown metric", 400);
|
||||
}
|
||||
after = { ...before };
|
||||
delete after[metric];
|
||||
} else {
|
||||
after = {};
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
thresholdsJson:
|
||||
Object.keys(after).length === 0 ? Prisma.JsonNull : after,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("thresholds.reset", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { metric: metric ?? "all", before, after },
|
||||
});
|
||||
|
||||
return apiSuccess({ overrides: after });
|
||||
});
|
||||
+84
-56
@@ -2,10 +2,17 @@
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Loader2,
|
||||
Upload,
|
||||
@@ -13,7 +20,8 @@ import {
|
||||
CheckCircle2,
|
||||
ImageIcon,
|
||||
Bug,
|
||||
AlertCircle,
|
||||
GitPullRequest,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -23,9 +31,20 @@ interface BugReportStatus {
|
||||
isAdmin: boolean;
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: "BUG", labelKey: "bugreport.categoryBug" },
|
||||
{ value: "FEATURE_REQUEST", labelKey: "bugreport.categoryFeature" },
|
||||
{ value: "QUESTION", labelKey: "bugreport.categoryQuestion" },
|
||||
{ value: "OTHER", labelKey: "bugreport.categoryOther" },
|
||||
] as const;
|
||||
|
||||
type CategoryValue = (typeof CATEGORIES)[number]["value"];
|
||||
|
||||
export default function BugReportPage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
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);
|
||||
@@ -36,7 +55,8 @@ export default function BugReportPage() {
|
||||
} | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: status, isLoading: statusLoading } = useQuery({
|
||||
// Status is now informational only — submission always works.
|
||||
const { data: status } = useQuery({
|
||||
queryKey: queryKeys.bugreportStatus(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/bugreport/status");
|
||||
@@ -81,18 +101,28 @@ export default function BugReportPage() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/bugreport", {
|
||||
const res = await fetch("/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
category,
|
||||
subject: subject || description.slice(0, 60),
|
||||
description,
|
||||
...(screenshot ? { screenshot } : {}),
|
||||
metadata: {
|
||||
locale,
|
||||
userAgent:
|
||||
typeof navigator !== "undefined" ? navigator.userAgent : null,
|
||||
url:
|
||||
typeof window !== "undefined" ? window.location.pathname : null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
setResult({ type: "success", message: t("bugreport.success") });
|
||||
setSubject("");
|
||||
setDescription("");
|
||||
removeScreenshot();
|
||||
} else {
|
||||
@@ -121,56 +151,6 @@ export default function BugReportPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("bugreport.title")}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">{t("common.loading")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status && !status.configured) {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("bugreport.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("bugreport.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border-border flex gap-3 rounded-xl border-l-4 border-l-orange-500 p-5">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-orange-500" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{t("bugreport.notConfiguredTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{status.isAdmin
|
||||
? t("bugreport.notConfiguredAdmin")
|
||||
: t("bugreport.notConfiguredUser")}
|
||||
</p>
|
||||
{status.isAdmin && (
|
||||
<Button asChild size="sm" variant="outline" className="mt-2">
|
||||
<Link href="/admin#bug-reports">
|
||||
{t("bugreport.openAdminSettings")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl space-y-6">
|
||||
<div>
|
||||
@@ -182,8 +162,56 @@ export default function BugReportPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{status?.configured && (
|
||||
<div className="bg-card border-border flex gap-2 rounded-lg border-l-4 border-l-dracula-cyan p-3 text-sm">
|
||||
<GitPullRequest className="mt-0.5 h-4 w-4 shrink-0 text-dracula-cyan" />
|
||||
<p className="text-muted-foreground">
|
||||
{t("bugreport.githubEscalationNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!status?.configured && (
|
||||
<div className="bg-card border-border flex gap-2 rounded-lg border-l-4 border-l-dracula-purple p-3 text-sm">
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-dracula-purple" />
|
||||
<p className="text-muted-foreground">
|
||||
{t("bugreport.internalOnlyNote")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-card border-border w-full rounded-xl border p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-[200px_1fr]">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">{t("bugreport.category")}</Label>
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={(v) => setCategory(v as CategoryValue)}
|
||||
>
|
||||
<SelectTrigger id="category">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORIES.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>
|
||||
{t(c.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subject">{t("bugreport.bugTitle")}</Label>
|
||||
<Input
|
||||
id="subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder={t("bugreport.bugTitlePlaceholder")}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bug-desc">{t("bugreport.description")}</Label>
|
||||
<textarea
|
||||
@@ -193,7 +221,7 @@ export default function BugReportPage() {
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={5000}
|
||||
rows={9}
|
||||
rows={8}
|
||||
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ export default async function RootLayout({
|
||||
/>
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<Providers>
|
||||
<Providers initialLocale={initialLocale}>
|
||||
<MonitoringBootstrap />
|
||||
<AuthShell>{children}</AuthShell>
|
||||
</Providers>
|
||||
|
||||
+446
-161
@@ -1,9 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Activity, Heart, Percent, Plus, Smile, TrendingUp, Waves } from "lucide-react";
|
||||
import {
|
||||
Activity,
|
||||
Droplet,
|
||||
Footprints,
|
||||
Heart,
|
||||
Moon,
|
||||
Percent,
|
||||
Pill,
|
||||
Plus,
|
||||
Smile,
|
||||
Target,
|
||||
TrendingUp,
|
||||
Waves,
|
||||
} from "lucide-react";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import {
|
||||
resolveDashboardLayout,
|
||||
type DashboardLayout,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import type { DataSummary as DataSummaryType } from "@/lib/analytics/trends";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -56,6 +75,7 @@ import {
|
||||
interface AnalyticsData {
|
||||
summaries: Record<string, DataSummary>;
|
||||
bpInTargetPct: number | null;
|
||||
glucoseByContext?: Record<string, DataSummaryType>;
|
||||
}
|
||||
|
||||
interface RangeDisplayConfig {
|
||||
@@ -148,6 +168,17 @@ export default function DashboardPage() {
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const { data: layoutData } = useQuery({
|
||||
queryKey: ["user", "dashboardWidgets"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/dashboard/widgets");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as DashboardLayout;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const { data: moodData } = useQuery({
|
||||
queryKey: queryKeys.moodAnalytics(),
|
||||
queryFn: async () => {
|
||||
@@ -164,9 +195,52 @@ export default function DashboardPage() {
|
||||
const dia = data?.summaries.BLOOD_PRESSURE_DIA;
|
||||
const p = data?.summaries.PULSE;
|
||||
const bf = data?.summaries.BODY_FAT;
|
||||
const showBodyFatCard = (bf?.count ?? 0) > 0;
|
||||
const sleepSummary = data?.summaries.SLEEP_DURATION;
|
||||
const stepsSummary = data?.summaries.ACTIVITY_STEPS;
|
||||
const moodSummary = moodData?.summary;
|
||||
const showMoodCard = (moodSummary?.count ?? 0) > 0;
|
||||
|
||||
// Resolve full dashboard layout — controls visibility + order of every widget
|
||||
const layout = resolveDashboardLayout(layoutData);
|
||||
const isWidgetVisible = (id: string) =>
|
||||
layout.widgets.find((widget) => widget.id === id)?.visible ?? false;
|
||||
const widgetOrder = (id: string) =>
|
||||
layout.widgets.find((widget) => widget.id === id)?.order ?? 999;
|
||||
|
||||
// Data-floor gates (widget shows iff visible AND has data)
|
||||
const hasWeight = (w?.count ?? 0) > 0;
|
||||
const hasBp = (sys?.count ?? 0) > 0 || (dia?.count ?? 0) > 0;
|
||||
const hasPulse = (p?.count ?? 0) > 0;
|
||||
const hasBodyFat = (bf?.count ?? 0) > 0;
|
||||
const hasMood = (moodSummary?.count ?? 0) > 0;
|
||||
const hasSleep = (sleepSummary?.count ?? 0) > 0;
|
||||
const hasSteps = (stepsSummary?.count ?? 0) > 0;
|
||||
const hasBpInTarget = data?.bpInTargetPct != null;
|
||||
|
||||
const showWeightCard = isWidgetVisible("weight") && hasWeight;
|
||||
const showBpCards = isWidgetVisible("bp") && hasBp;
|
||||
const showPulseCard = isWidgetVisible("pulse") && hasPulse;
|
||||
const showBodyFatCard = isWidgetVisible("bodyFat") && hasBodyFat;
|
||||
const showMoodCard = isWidgetVisible("mood") && hasMood;
|
||||
const showSleepCard = isWidgetVisible("sleep") && hasSleep;
|
||||
const showStepsCard = isWidgetVisible("steps") && hasSteps;
|
||||
const showBpInTargetCard = isWidgetVisible("bpInTarget") && hasBpInTarget;
|
||||
const showMedicationsCard = isWidgetVisible("medications");
|
||||
|
||||
// Glucose widget — visible iff layout enables it AND at least one reading exists.
|
||||
const glucoseWidgetVisible = isWidgetVisible("glucose");
|
||||
const displayGlucoseUnit = resolveGlucoseUnit(user?.glucoseUnit ?? null);
|
||||
const glucoseByContext = data?.glucoseByContext ?? {};
|
||||
const glucoseContexts = ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"] as const;
|
||||
const glucoseSummariesPresent = glucoseContexts.filter(
|
||||
(ctx) => (glucoseByContext[ctx]?.count ?? 0) > 0,
|
||||
);
|
||||
const showGlucoseCards = glucoseWidgetVisible && glucoseSummariesPresent.length > 0;
|
||||
const glucoseLabelKey: Record<string, string> = {
|
||||
FASTING: "targets.glucoseFasting",
|
||||
POSTPRANDIAL: "targets.glucosePostprandial",
|
||||
RANDOM: "targets.glucoseRandom",
|
||||
BEDTIME: "targets.glucoseBedtime",
|
||||
};
|
||||
const bpTargets =
|
||||
user?.dateOfBirth != null ? getBpTargets(new Date(user.dateOfBirth)) : null;
|
||||
const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null);
|
||||
@@ -324,167 +398,378 @@ export default function DashboardPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
|
||||
<TrendCard
|
||||
label={t("dashboard.weight")}
|
||||
latest={w?.latest ?? null}
|
||||
unit="kg"
|
||||
avg7={w?.avg7 ?? null}
|
||||
avg30={w?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(w?.avg7, { range: weightRange })}
|
||||
avg30ColorClass={getRangeColorClass(w?.avg30, { range: weightRange })}
|
||||
avg7Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)}
|
||||
slope30={w?.slope30 ?? null}
|
||||
icon={Activity}
|
||||
/>
|
||||
<TrendCard
|
||||
label={t("dashboard.bloodPressureSys")}
|
||||
latest={sys?.latest ?? null}
|
||||
unit="mmHg"
|
||||
avg7={sys?.avg7 ?? null}
|
||||
avg30={sys?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(sys?.avg7, { range: bpSysRange })}
|
||||
avg30ColorClass={getRangeColorClass(sys?.avg30, {
|
||||
range: bpSysRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)}
|
||||
slope30={sys?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
<TrendCard
|
||||
label={t("dashboard.bloodPressureDia")}
|
||||
latest={dia?.latest ?? null}
|
||||
unit="mmHg"
|
||||
avg7={dia?.avg7 ?? null}
|
||||
avg30={dia?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(dia?.avg7, { range: bpDiaRange })}
|
||||
avg30ColorClass={getRangeColorClass(dia?.avg30, {
|
||||
range: bpDiaRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)}
|
||||
slope30={dia?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
<TrendCard
|
||||
label={t("dashboard.pulse")}
|
||||
latest={p?.latest ?? null}
|
||||
unit="bpm"
|
||||
avg7={p?.avg7 ?? null}
|
||||
avg30={p?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(p?.avg7, {
|
||||
range: pulseDisplayRange,
|
||||
})}
|
||||
avg30ColorClass={getRangeColorClass(p?.avg30, {
|
||||
range: pulseDisplayRange,
|
||||
})}
|
||||
avg7Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)}
|
||||
slope30={p?.slope30 ?? null}
|
||||
icon={TrendingUp}
|
||||
/>
|
||||
{showBodyFatCard ? (
|
||||
<TrendCard
|
||||
label={t("dashboard.bodyFat")}
|
||||
latest={bf?.latest ?? null}
|
||||
unit="%"
|
||||
avg7={bf?.avg7 ?? null}
|
||||
avg30={bf?.avg30 ?? null}
|
||||
slope30={bf?.slope30 ?? null}
|
||||
icon={Percent}
|
||||
/>
|
||||
) : null}
|
||||
{showMoodCard ? (
|
||||
<TrendCard
|
||||
label={t("dashboard.mood")}
|
||||
latest={moodSummary?.latest ?? null}
|
||||
unit="/ 5"
|
||||
avg7={moodSummary?.avg7 ?? null}
|
||||
avg30={moodSummary?.avg30 ?? null}
|
||||
slope30={moodSummary?.slope30 ?? null}
|
||||
icon={Smile}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{(() => {
|
||||
type TrendEntry = { id: string; order: number; node: React.ReactNode };
|
||||
const trendCards: TrendEntry[] = [];
|
||||
|
||||
<HealthChart
|
||||
types={["WEIGHT"]}
|
||||
title={t("dashboard.weight")}
|
||||
colors={["#bd93f9"]}
|
||||
unit="kg"
|
||||
valueBands={weightBands}
|
||||
/>
|
||||
if (showWeightCard) {
|
||||
trendCards.push({
|
||||
id: "weight",
|
||||
order: widgetOrder("weight"),
|
||||
node: (
|
||||
<TrendCard
|
||||
key="weight"
|
||||
label={t("dashboard.weight")}
|
||||
latest={w?.latest ?? null}
|
||||
unit="kg"
|
||||
avg7={w?.avg7 ?? null}
|
||||
avg30={w?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(w?.avg7, { range: weightRange })}
|
||||
avg30ColorClass={getRangeColorClass(w?.avg30, { range: weightRange })}
|
||||
avg7Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("kg", { range: weightRange }, t, fmt.number)}
|
||||
slope30={w?.slope30 ?? null}
|
||||
icon={Activity}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showBpCards) {
|
||||
trendCards.push({
|
||||
id: "bp",
|
||||
order: widgetOrder("bp"),
|
||||
node: (
|
||||
<React.Fragment key="bp">
|
||||
<TrendCard
|
||||
label={t("dashboard.bloodPressureSys")}
|
||||
latest={sys?.latest ?? null}
|
||||
unit="mmHg"
|
||||
avg7={sys?.avg7 ?? null}
|
||||
avg30={sys?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(sys?.avg7, { range: bpSysRange })}
|
||||
avg30ColorClass={getRangeColorClass(sys?.avg30, { range: bpSysRange })}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpSysRange }, t, fmt.number)}
|
||||
slope30={sys?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
<TrendCard
|
||||
label={t("dashboard.bloodPressureDia")}
|
||||
latest={dia?.latest ?? null}
|
||||
unit="mmHg"
|
||||
avg7={dia?.avg7 ?? null}
|
||||
avg30={dia?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(dia?.avg7, { range: bpDiaRange })}
|
||||
avg30ColorClass={getRangeColorClass(dia?.avg30, { range: bpDiaRange })}
|
||||
avg7Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("mmHg", { range: bpDiaRange }, t, fmt.number)}
|
||||
slope30={dia?.slope30 ?? null}
|
||||
icon={Heart}
|
||||
/>
|
||||
</React.Fragment>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showPulseCard) {
|
||||
trendCards.push({
|
||||
id: "pulse",
|
||||
order: widgetOrder("pulse"),
|
||||
node: (
|
||||
<TrendCard
|
||||
key="pulse"
|
||||
label={t("dashboard.pulse")}
|
||||
latest={p?.latest ?? null}
|
||||
unit="bpm"
|
||||
avg7={p?.avg7 ?? null}
|
||||
avg30={p?.avg30 ?? null}
|
||||
avg7ColorClass={getRangeColorClass(p?.avg7, { range: pulseDisplayRange })}
|
||||
avg30ColorClass={getRangeColorClass(p?.avg30, { range: pulseDisplayRange })}
|
||||
avg7Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)}
|
||||
avg30Hint={getRangeHint("bpm", { range: pulseDisplayRange }, t, fmt.number)}
|
||||
slope30={p?.slope30 ?? null}
|
||||
icon={TrendingUp}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showBodyFatCard) {
|
||||
trendCards.push({
|
||||
id: "bodyFat",
|
||||
order: widgetOrder("bodyFat"),
|
||||
node: (
|
||||
<TrendCard
|
||||
key="bodyFat"
|
||||
label={t("dashboard.bodyFat")}
|
||||
latest={bf?.latest ?? null}
|
||||
unit="%"
|
||||
avg7={bf?.avg7 ?? null}
|
||||
avg30={bf?.avg30 ?? null}
|
||||
slope30={bf?.slope30 ?? null}
|
||||
icon={Percent}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showMoodCard) {
|
||||
trendCards.push({
|
||||
id: "mood",
|
||||
order: widgetOrder("mood"),
|
||||
node: (
|
||||
<TrendCard
|
||||
key="mood"
|
||||
label={t("dashboard.mood")}
|
||||
latest={moodSummary?.latest ?? null}
|
||||
unit="/ 5"
|
||||
avg7={moodSummary?.avg7 ?? null}
|
||||
avg30={moodSummary?.avg30 ?? null}
|
||||
slope30={moodSummary?.slope30 ?? null}
|
||||
icon={Smile}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showSleepCard) {
|
||||
trendCards.push({
|
||||
id: "sleep",
|
||||
order: widgetOrder("sleep"),
|
||||
node: (
|
||||
<TrendCard
|
||||
key="sleep"
|
||||
label={t("dashboard.sleep") ?? "Sleep"}
|
||||
latest={sleepSummary?.latest ?? null}
|
||||
unit="h"
|
||||
avg7={sleepSummary?.avg7 ?? null}
|
||||
avg30={sleepSummary?.avg30 ?? null}
|
||||
slope30={sleepSummary?.slope30 ?? null}
|
||||
icon={Moon}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showStepsCard) {
|
||||
trendCards.push({
|
||||
id: "steps",
|
||||
order: widgetOrder("steps"),
|
||||
node: (
|
||||
<TrendCard
|
||||
key="steps"
|
||||
label={t("dashboard.steps") ?? "Steps"}
|
||||
latest={stepsSummary?.latest ?? null}
|
||||
unit=""
|
||||
avg7={stepsSummary?.avg7 ?? null}
|
||||
avg30={stepsSummary?.avg30 ?? null}
|
||||
slope30={stepsSummary?.slope30 ?? null}
|
||||
icon={Footprints}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showBpInTargetCard) {
|
||||
trendCards.push({
|
||||
id: "bpInTarget",
|
||||
order: widgetOrder("bpInTarget"),
|
||||
node: (
|
||||
<TrendCard
|
||||
key="bpInTarget"
|
||||
label={t("dashboard.bpInTarget")}
|
||||
latest={data?.bpInTargetPct ?? null}
|
||||
unit="%"
|
||||
avg7={null}
|
||||
avg30={null}
|
||||
slope30={null}
|
||||
icon={Target}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showGlucoseCards) {
|
||||
const glucoseOrder = widgetOrder("glucose");
|
||||
glucoseSummariesPresent.forEach((ctx, idx) => {
|
||||
const s = glucoseByContext[ctx];
|
||||
trendCards.push({
|
||||
id: `glucose-${ctx}`,
|
||||
// sub-order so all glucose cards stay in a block and order stable
|
||||
order: glucoseOrder + idx / 1000,
|
||||
node: (
|
||||
<TrendCard
|
||||
key={`glucose-${ctx}`}
|
||||
label={t(glucoseLabelKey[ctx])}
|
||||
latest={
|
||||
s.latest != null
|
||||
? convertGlucose(s.latest, displayGlucoseUnit)
|
||||
: null
|
||||
}
|
||||
unit={displayGlucoseUnit}
|
||||
avg7={
|
||||
s.avg7 != null
|
||||
? convertGlucose(s.avg7, displayGlucoseUnit)
|
||||
: null
|
||||
}
|
||||
avg30={
|
||||
s.avg30 != null
|
||||
? convertGlucose(s.avg30, displayGlucoseUnit)
|
||||
: null
|
||||
}
|
||||
slope30={s.slope30 ?? null}
|
||||
icon={Droplet}
|
||||
/>
|
||||
),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
<HealthChart
|
||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||
title={t("dashboard.bloodPressure")}
|
||||
colors={["#ff79c6", "#8be9fd"]}
|
||||
unit="mmHg"
|
||||
yAxisUnit="Hg"
|
||||
targetZones={bpTargetZones}
|
||||
/>
|
||||
trendCards.sort((a, b) => a.order - b.order);
|
||||
|
||||
<HealthChart
|
||||
types={["PULSE"]}
|
||||
title={t("dashboard.pulse")}
|
||||
colors={["#50fa7b"]}
|
||||
unit="bpm"
|
||||
valueBands={pulseBands}
|
||||
/>
|
||||
{user?.heightCm ? (
|
||||
<HealthChart
|
||||
types={["WEIGHT"]}
|
||||
title={t("targets.bmi")}
|
||||
colors={["#f1fa8c"]}
|
||||
unit="kg/m²"
|
||||
valueMode="bmi"
|
||||
valueBands={[
|
||||
{
|
||||
min: 0,
|
||||
max: 17,
|
||||
color: "#ff5555",
|
||||
opacity: 0.16,
|
||||
},
|
||||
{
|
||||
min: 17,
|
||||
max: 18.5,
|
||||
color: "#ffb86c",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: 18.5,
|
||||
max: 24.9,
|
||||
color: "#50fa7b",
|
||||
opacity: 0.2,
|
||||
},
|
||||
{
|
||||
min: 24.9,
|
||||
max: 29.9,
|
||||
color: "#ffb86c",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: 29.9,
|
||||
max: 120,
|
||||
color: "#ff5555",
|
||||
opacity: 0.16,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
type ChartEntry = { id: string; order: number; node: React.ReactNode };
|
||||
const charts: ChartEntry[] = [];
|
||||
if (showWeightCard) {
|
||||
charts.push({
|
||||
id: "weight-chart",
|
||||
order: widgetOrder("weight"),
|
||||
node: (
|
||||
<HealthChart
|
||||
key="weight-chart"
|
||||
types={["WEIGHT"]}
|
||||
title={t("dashboard.weight")}
|
||||
colors={["#bd93f9"]}
|
||||
unit="kg"
|
||||
valueBands={weightBands}
|
||||
/>
|
||||
),
|
||||
});
|
||||
if (user?.heightCm) {
|
||||
charts.push({
|
||||
id: "bmi-chart",
|
||||
order: widgetOrder("weight") + 0.5,
|
||||
node: (
|
||||
<HealthChart
|
||||
key="bmi-chart"
|
||||
types={["WEIGHT"]}
|
||||
title={t("targets.bmi")}
|
||||
colors={["#f1fa8c"]}
|
||||
unit="kg/m²"
|
||||
valueMode="bmi"
|
||||
valueBands={[
|
||||
{ min: 0, max: 17, color: "#ff5555", opacity: 0.16 },
|
||||
{ min: 17, max: 18.5, color: "#ffb86c", opacity: 0.18 },
|
||||
{ min: 18.5, max: 24.9, color: "#50fa7b", opacity: 0.2 },
|
||||
{ min: 24.9, max: 29.9, color: "#ffb86c", opacity: 0.18 },
|
||||
{ min: 29.9, max: 120, color: "#ff5555", opacity: 0.16 },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (showBpCards) {
|
||||
charts.push({
|
||||
id: "bp-chart",
|
||||
order: widgetOrder("bp"),
|
||||
node: (
|
||||
<HealthChart
|
||||
key="bp-chart"
|
||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||
title={t("dashboard.bloodPressure")}
|
||||
colors={["#ff79c6", "#8be9fd"]}
|
||||
unit="mmHg"
|
||||
yAxisUnit="Hg"
|
||||
targetZones={bpTargetZones}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showPulseCard) {
|
||||
charts.push({
|
||||
id: "pulse-chart",
|
||||
order: widgetOrder("pulse"),
|
||||
node: (
|
||||
<HealthChart
|
||||
key="pulse-chart"
|
||||
types={["PULSE"]}
|
||||
title={t("dashboard.pulse")}
|
||||
colors={["#50fa7b"]}
|
||||
unit="bpm"
|
||||
valueBands={pulseBands}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showBodyFatCard) {
|
||||
charts.push({
|
||||
id: "bodyFat-chart",
|
||||
order: widgetOrder("bodyFat"),
|
||||
node: (
|
||||
<HealthChart
|
||||
key="bodyFat-chart"
|
||||
types={["BODY_FAT"]}
|
||||
title={t("dashboard.bodyFat")}
|
||||
colors={["#ffb86c"]}
|
||||
unit="%"
|
||||
valueBands={bodyFatBands}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showMoodCard) {
|
||||
charts.push({
|
||||
id: "mood-chart",
|
||||
order: widgetOrder("mood"),
|
||||
node: <MoodChart key="mood-chart" />,
|
||||
});
|
||||
}
|
||||
if (showSleepCard) {
|
||||
charts.push({
|
||||
id: "sleep-chart",
|
||||
order: widgetOrder("sleep"),
|
||||
node: (
|
||||
<HealthChart
|
||||
key="sleep-chart"
|
||||
types={["SLEEP_DURATION"]}
|
||||
title={t("dashboard.sleep") ?? "Sleep"}
|
||||
colors={["#8be9fd"]}
|
||||
unit="h"
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showStepsCard) {
|
||||
charts.push({
|
||||
id: "steps-chart",
|
||||
order: widgetOrder("steps"),
|
||||
node: (
|
||||
<HealthChart
|
||||
key="steps-chart"
|
||||
types={["ACTIVITY_STEPS"]}
|
||||
title={t("dashboard.steps") ?? "Steps"}
|
||||
colors={["#50fa7b"]}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showMedicationsCard) {
|
||||
charts.push({
|
||||
id: "medications",
|
||||
order: widgetOrder("medications"),
|
||||
node: (
|
||||
<div
|
||||
key="medications"
|
||||
className="bg-card rounded-lg border p-4"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Pill className="h-4 w-4" />
|
||||
<h3 className="text-sm font-medium">
|
||||
{t("dashboard.medications")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.title") ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
{showBodyFatCard && (
|
||||
<HealthChart
|
||||
types={["BODY_FAT"]}
|
||||
title={t("dashboard.bodyFat")}
|
||||
colors={["#ffb86c"]}
|
||||
unit="%"
|
||||
valueBands={bodyFatBands}
|
||||
/>
|
||||
)}
|
||||
{showMoodCard && <MoodChart />}
|
||||
charts.sort((a, b) => a.order - b.order);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5">
|
||||
{trendCards.map((entry) => entry.node)}
|
||||
</div>
|
||||
{charts.map((entry) => entry.node)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,8 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
|
||||
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
|
||||
import { describePasskeyError } from "@/lib/passkey-errors";
|
||||
import { ThresholdsSection } from "@/components/settings/thresholds-section";
|
||||
import { DashboardLayoutSection } from "@/components/settings/dashboard-layout-section";
|
||||
|
||||
function PasswordInput(props: React.ComponentProps<typeof Input>) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -624,6 +626,17 @@ export default function SettingsPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section
|
||||
id="section-personalization"
|
||||
className="scroll-mt-28 space-y-3"
|
||||
>
|
||||
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||
{t("settings.categoryPersonalization")}
|
||||
</h2>
|
||||
<DashboardLayoutSection id="dashboard-layout" />
|
||||
<ThresholdsSection id="thresholds" />
|
||||
</section>
|
||||
|
||||
<section id="section-integration" className="scroll-mt-28 space-y-3">
|
||||
<h2 className="text-muted-foreground text-sm font-semibold tracking-wider uppercase">
|
||||
{t("settings.categoryIntegration")}
|
||||
@@ -2884,7 +2897,247 @@ function InsightsSettingsSection({
|
||||
{t("settings.regenerateInsights")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Multi-provider AI selection (per-user override) */}
|
||||
<UserAIProviderSubsection />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────── User AI Provider Override ─────────────────────── */
|
||||
|
||||
function UserAIProviderSubsection() {
|
||||
const queryClient = useQueryClient();
|
||||
const [provider, setProvider] = useState<string>("");
|
||||
const [model, setModel] = useState<string>("");
|
||||
const [baseUrl, setBaseUrl] = useState<string>("");
|
||||
const [anthropicKey, setAnthropicKey] = useState<string>("");
|
||||
const [localKey, setLocalKey] = useState<string>("");
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saveOk, setSaveOk] = useState<boolean>(false);
|
||||
const [testMsg, setTestMsg] = useState<string | null>(null);
|
||||
const [testOk, setTestOk] = useState<boolean>(false);
|
||||
const [testing, setTesting] = useState<boolean>(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["user", "ai-provider"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/user/ai-provider");
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json.data as {
|
||||
provider: string | null;
|
||||
model: string | null;
|
||||
baseUrl: string | null;
|
||||
hasAnthropicKey: boolean;
|
||||
anthropicKeyPreview: string | null;
|
||||
hasLocalKey: boolean;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setProvider(data.provider ?? "");
|
||||
setModel(data.model ?? "");
|
||||
setBaseUrl(data.baseUrl ?? "");
|
||||
}, [data]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body: Record<string, unknown> = {
|
||||
provider: provider || null,
|
||||
model: model || null,
|
||||
baseUrl: baseUrl || null,
|
||||
};
|
||||
if (anthropicKey.trim()) body.anthropicKey = anthropicKey.trim();
|
||||
if (localKey.trim()) body.localKey = localKey.trim();
|
||||
const res = await fetch("/api/user/ai-provider", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error || "Save failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSaveMsg("Gespeichert");
|
||||
setSaveOk(true);
|
||||
setAnthropicKey("");
|
||||
setLocalKey("");
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "ai-provider"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
},
|
||||
onError: (e) => {
|
||||
setSaveMsg(e instanceof Error ? e.message : "Fehler");
|
||||
setSaveOk(false);
|
||||
},
|
||||
});
|
||||
|
||||
async function runTest() {
|
||||
setTesting(true);
|
||||
setTestMsg(null);
|
||||
try {
|
||||
const res = await fetch("/api/ai/test", { method: "POST" });
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
setTestMsg(json.error || `HTTP ${res.status}`);
|
||||
setTestOk(false);
|
||||
return;
|
||||
}
|
||||
setTestMsg(
|
||||
`OK — ${json.data.providerType} (${json.data.model})${
|
||||
json.data.tokensUsed ? `, ${json.data.tokensUsed} tokens` : ""
|
||||
}`,
|
||||
);
|
||||
setTestOk(true);
|
||||
} catch (e) {
|
||||
setTestMsg(e instanceof Error ? e.message : "Test fehlgeschlagen");
|
||||
setTestOk(false);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 mt-2 rounded-lg p-4">
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-medium">KI-Provider (persönlich)</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Eigener KI-Anbieter überschreibt die Admin-Einstellung. Leer lassen
|
||||
für Standard.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="ai-provider-select">Provider</Label>
|
||||
<select
|
||||
id="ai-provider-select"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
className="bg-background border-input mt-1 h-9 w-full rounded-md border px-2 text-sm"
|
||||
>
|
||||
<option value="">— Standard (Admin/Codex) —</option>
|
||||
<option value="OPENAI">OpenAI</option>
|
||||
<option value="ANTHROPIC">Anthropic (Claude)</option>
|
||||
<option value="LOCAL">Lokal (OpenAI-kompatibel)</option>
|
||||
<option value="CHATGPT_OAUTH">ChatGPT OAuth</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="ai-model-input">Modell</Label>
|
||||
<Input
|
||||
id="ai-model-input"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder={
|
||||
provider === "ANTHROPIC"
|
||||
? "claude-3-5-sonnet-latest"
|
||||
: provider === "LOCAL"
|
||||
? "llama3:8b"
|
||||
: "gpt-4o-mini"
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{provider === "ANTHROPIC" && (
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="ai-anthropic-key">
|
||||
Anthropic API Key
|
||||
{data?.hasAnthropicKey && (
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
(gespeichert {data.anthropicKeyPreview})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="ai-anthropic-key"
|
||||
value={anthropicKey}
|
||||
onChange={(e) => setAnthropicKey(e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === "LOCAL" && (
|
||||
<>
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="ai-base-url">Base URL</Label>
|
||||
<Input
|
||||
id="ai-base-url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="http://localhost:11434/v1"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="ai-local-key">API Key (optional)</Label>
|
||||
<PasswordInput
|
||||
id="ai-local-key"
|
||||
value={localKey}
|
||||
onChange={(e) => setLocalKey(e.target.value)}
|
||||
placeholder={data?.hasLocalKey ? "(gespeichert)" : ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Speichern
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={runTest}
|
||||
disabled={testing}
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Verbindung testen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{saveMsg && (
|
||||
<p
|
||||
className={`mt-2 text-xs ${
|
||||
saveOk ? "text-dracula-green" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{saveMsg}
|
||||
</p>
|
||||
)}
|
||||
{testMsg && (
|
||||
<p
|
||||
className={`mt-2 text-xs ${
|
||||
testOk ? "text-dracula-green" : "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{testMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
AlertCircle,
|
||||
ExternalLink,
|
||||
Smile,
|
||||
Droplet,
|
||||
} from "lucide-react";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
|
||||
interface TargetData {
|
||||
type: string;
|
||||
@@ -58,6 +60,7 @@ interface TargetsResponse {
|
||||
heightCm: number | null;
|
||||
age: number | null;
|
||||
gender: string | null;
|
||||
glucoseUnit?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,6 +74,10 @@ const TYPE_ICONS: Record<string, typeof Scale> = {
|
||||
BMI: Scale,
|
||||
MOOD_SCORE: Smile,
|
||||
MOOD_STABILITY: Smile,
|
||||
BLOOD_GLUCOSE_FASTING: Droplet,
|
||||
BLOOD_GLUCOSE_POSTPRANDIAL: Droplet,
|
||||
BLOOD_GLUCOSE_RANDOM: Droplet,
|
||||
BLOOD_GLUCOSE_BEDTIME: Droplet,
|
||||
};
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
@@ -83,8 +90,19 @@ const TYPE_COLORS: Record<string, string> = {
|
||||
BMI: "text-dracula-yellow",
|
||||
MOOD_SCORE: "text-dracula-lavender",
|
||||
MOOD_STABILITY: "text-dracula-lavender",
|
||||
BLOOD_GLUCOSE_FASTING: "text-dracula-red",
|
||||
BLOOD_GLUCOSE_POSTPRANDIAL: "text-dracula-red",
|
||||
BLOOD_GLUCOSE_RANDOM: "text-dracula-red",
|
||||
BLOOD_GLUCOSE_BEDTIME: "text-dracula-red",
|
||||
};
|
||||
|
||||
const GLUCOSE_TYPES = new Set([
|
||||
"BLOOD_GLUCOSE_FASTING",
|
||||
"BLOOD_GLUCOSE_POSTPRANDIAL",
|
||||
"BLOOD_GLUCOSE_RANDOM",
|
||||
"BLOOD_GLUCOSE_BEDTIME",
|
||||
]);
|
||||
|
||||
function getTargetSourceLink(target: TargetData): string | null {
|
||||
if (target.type === "WEIGHT" || target.type === "BMI") {
|
||||
return "https://www.who.int/news-room/fact-sheets/detail/obesity-and-overweight";
|
||||
@@ -503,9 +521,32 @@ export default function TargetsPage() {
|
||||
}
|
||||
|
||||
const profileIncomplete = !data.profile.heightCm || !data.profile.age;
|
||||
const visibleTargets = data.targets.filter(
|
||||
(target) => target.current != null,
|
||||
const displayGlucoseUnit = resolveGlucoseUnit(
|
||||
data.profile.glucoseUnit ?? null,
|
||||
);
|
||||
const visibleTargets = data.targets
|
||||
.filter((target) => target.current != null)
|
||||
.map((target) => {
|
||||
if (!GLUCOSE_TYPES.has(target.type)) return target;
|
||||
// Server label is an i18n key for glucose; resolve here.
|
||||
const label = t(target.label);
|
||||
// Convert mg/dL canonical values to the user's display unit.
|
||||
const convert = (v: number | null) =>
|
||||
v == null ? null : convertGlucose(v, displayGlucoseUnit);
|
||||
return {
|
||||
...target,
|
||||
label,
|
||||
unit: displayGlucoseUnit,
|
||||
current: convert(target.current),
|
||||
average30: convert(target.average30),
|
||||
range: target.range
|
||||
? {
|
||||
min: convertGlucose(target.range.min, displayGlucoseUnit),
|
||||
max: convertGlucose(target.range.max, displayGlucoseUnit),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
|
||||
@@ -105,6 +105,7 @@ const BASE_TYPE_LABEL_KEYS: Record<string, string> = {
|
||||
BODY_FAT: "charts.bodyFat",
|
||||
SLEEP_DURATION: "charts.sleep",
|
||||
ACTIVITY_STEPS: "charts.steps",
|
||||
BLOOD_GLUCOSE: "measurements.typeBloodGlucose",
|
||||
};
|
||||
|
||||
function getTypeLabel(
|
||||
|
||||
@@ -61,8 +61,23 @@ const MEASUREMENT_TYPES = [
|
||||
unitKey: "measurements.unitSteps",
|
||||
placeholder: "8000",
|
||||
},
|
||||
{
|
||||
value: "BLOOD_GLUCOSE",
|
||||
labelKey: "measurements.typeBloodGlucose",
|
||||
unit: "mg/dL",
|
||||
placeholder: "95",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const GLUCOSE_CONTEXTS = [
|
||||
{ value: "FASTING", labelKey: "measurements.glucoseContextFasting" },
|
||||
{ value: "POSTPRANDIAL", labelKey: "measurements.glucoseContextPostprandial" },
|
||||
{ value: "RANDOM", labelKey: "measurements.glucoseContextRandom" },
|
||||
{ value: "BEDTIME", labelKey: "measurements.glucoseContextBedtime" },
|
||||
] as const;
|
||||
|
||||
type GlucoseContextValue = (typeof GLUCOSE_CONTEXTS)[number]["value"];
|
||||
|
||||
interface MeasurementFormProps {
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
@@ -97,11 +112,14 @@ export function MeasurementForm({
|
||||
const [pulse, setPulse] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [measuredAt, setMeasuredAt] = useState(getDefaultMeasuredAtValue);
|
||||
const [glucoseContext, setGlucoseContext] =
|
||||
useState<GlucoseContextValue>("FASTING");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const typeInfo = MEASUREMENT_TYPES.find((t) => t.value === type);
|
||||
const isBpMode = type === "BLOOD_PRESSURE";
|
||||
const isGlucoseMode = type === "BLOOD_GLUCOSE";
|
||||
|
||||
function resetForm() {
|
||||
setType(normalizedDefault || "BLOOD_PRESSURE");
|
||||
@@ -175,6 +193,7 @@ export function MeasurementForm({
|
||||
value: parseFloat(value),
|
||||
measuredAt: timestamp,
|
||||
notes: notes || undefined,
|
||||
...(isGlucoseMode ? { glucoseContext } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -293,6 +312,29 @@ export function MeasurementForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGlucoseMode && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="glucose-context">
|
||||
{t("measurements.glucoseContext")}
|
||||
</Label>
|
||||
<Select
|
||||
value={glucoseContext}
|
||||
onValueChange={(v) => setGlucoseContext(v as GlucoseContextValue)}
|
||||
>
|
||||
<SelectTrigger id="glucose-context">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GLUCOSE_CONTEXTS.map((ctx) => (
|
||||
<SelectItem key={ctx.value} value={ctx.value}>
|
||||
{t(ctx.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="measuredAt">{t("measurements.timestamp")}</Label>
|
||||
<Input
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
// ── Theme Context ────────────────────────────────────
|
||||
@@ -100,7 +101,13 @@ function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// ── Root Providers ───────────────────────────────────
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
export function Providers({
|
||||
children,
|
||||
initialLocale,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
initialLocale?: Locale;
|
||||
}) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
@@ -118,7 +125,7 @@ export function Providers({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<I18nProvider initialLocale={initialLocale}>
|
||||
{children}
|
||||
<Toaster position="bottom-right" richColors />
|
||||
</I18nProvider>
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
type DashboardLayout,
|
||||
type DashboardWidgetId,
|
||||
DEFAULT_DASHBOARD_LAYOUT,
|
||||
} from "@/lib/dashboard-layout";
|
||||
|
||||
const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
|
||||
weight: "dashboard.weight",
|
||||
bp: "dashboard.bloodPressure",
|
||||
pulse: "dashboard.pulse",
|
||||
bodyFat: "dashboard.bodyFat",
|
||||
mood: "dashboard.mood",
|
||||
medications: "dashboard.medications",
|
||||
sleep: "measurements.typeSleep",
|
||||
steps: "measurements.typeSteps",
|
||||
glucose: "measurements.typeBloodGlucose",
|
||||
bpInTarget: "dashboard.bpInTarget",
|
||||
};
|
||||
|
||||
export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: remote, isLoading } = useQuery({
|
||||
queryKey: ["user", "dashboardWidgets"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/dashboard/widgets");
|
||||
if (!res.ok) throw new Error("failed");
|
||||
const json = await res.json();
|
||||
return json.data as DashboardLayout;
|
||||
},
|
||||
});
|
||||
|
||||
// Local draft state — null means "use server copy". User edits create the
|
||||
// draft so reordering/toggling doesn't fire a network call per click; Save
|
||||
// flushes it, Cancel clears it. Avoids a setState-in-effect (eslint
|
||||
// react-hooks/set-state-in-effect is strict in this repo).
|
||||
const [draft, setDraft] = useState<DashboardLayout | null>(null);
|
||||
const layout = draft ?? remote ?? null;
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (next: DashboardLayout) => {
|
||||
const res = await fetch("/api/dashboard/widgets", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(next),
|
||||
});
|
||||
if (!res.ok) throw new Error("save failed");
|
||||
return (await res.json()).data as DashboardLayout;
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(["user", "dashboardWidgets"], saved);
|
||||
setDraft(null);
|
||||
toast.success(t("dashboard.layoutSaveSuccess"));
|
||||
},
|
||||
onError: () => toast.error(t("dashboard.layoutSaveError")),
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/dashboard/widgets", { method: "DELETE" });
|
||||
if (!res.ok) throw new Error("reset failed");
|
||||
return (await res.json()).data as DashboardLayout;
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(["user", "dashboardWidgets"], saved);
|
||||
setDraft(null);
|
||||
toast.success(t("dashboard.layoutResetSuccess"));
|
||||
},
|
||||
});
|
||||
|
||||
function toggle(widgetId: DashboardWidgetId, visible: boolean) {
|
||||
if (!layout) return;
|
||||
setDraft({
|
||||
...layout,
|
||||
widgets: layout.widgets.map((w) =>
|
||||
w.id === widgetId ? { ...w, visible } : w,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function move(widgetId: DashboardWidgetId, delta: -1 | 1) {
|
||||
if (!layout) return;
|
||||
const sorted = [...layout.widgets].sort((a, b) => a.order - b.order);
|
||||
const idx = sorted.findIndex((w) => w.id === widgetId);
|
||||
const targetIdx = idx + delta;
|
||||
if (idx < 0 || targetIdx < 0 || targetIdx >= sorted.length) return;
|
||||
[sorted[idx], sorted[targetIdx]] = [sorted[targetIdx], sorted[idx]];
|
||||
setDraft({
|
||||
...layout,
|
||||
widgets: sorted.map((w, i) => ({ ...w, order: i })),
|
||||
});
|
||||
}
|
||||
|
||||
// Presence of a draft implies dirty — no JSON comparison needed.
|
||||
const dirty = draft !== null && layout !== null;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6 space-y-5"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutDashboard className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("dashboard.customizeTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={resetMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||
{t("dashboard.layoutReset")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("dashboard.customizeSubtitle")}
|
||||
</p>
|
||||
|
||||
{isLoading || !layout ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{[...layout.widgets]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{dirty && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDraft(null)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => layout && saveMutation.mutate(layout)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending && (
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!dirty && remote && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{layout &&
|
||||
JSON.stringify(layout.widgets) ===
|
||||
JSON.stringify(DEFAULT_DASHBOARD_LAYOUT.widgets)
|
||||
? t("dashboard.layoutUsingDefaults")
|
||||
: t("dashboard.layoutCustomized")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { SlidersHorizontal, RotateCcw, Loader2, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import {
|
||||
METRIC_BOUNDS,
|
||||
type ThresholdMetric,
|
||||
type EffectiveRange,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
|
||||
interface ThresholdsApiResponse {
|
||||
effective: Record<ThresholdMetric, EffectiveRange>;
|
||||
overrides: Partial<Record<ThresholdMetric, { min: number; max: number }>>;
|
||||
}
|
||||
|
||||
const METRIC_ORDER: ThresholdMetric[] = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"BLOOD_GLUCOSE_FASTING",
|
||||
"BLOOD_GLUCOSE_POSTPRANDIAL",
|
||||
"BLOOD_GLUCOSE_RANDOM",
|
||||
"BLOOD_GLUCOSE_BEDTIME",
|
||||
];
|
||||
|
||||
const METRIC_LABEL_KEYS: Record<ThresholdMetric, string> = {
|
||||
WEIGHT: "thresholds.metricWeight",
|
||||
BLOOD_PRESSURE_SYS: "thresholds.metricBpSys",
|
||||
BLOOD_PRESSURE_DIA: "thresholds.metricBpDia",
|
||||
PULSE: "thresholds.metricPulse",
|
||||
BODY_FAT: "thresholds.metricBodyFat",
|
||||
SLEEP_DURATION: "thresholds.metricSleep",
|
||||
ACTIVITY_STEPS: "thresholds.metricSteps",
|
||||
BLOOD_GLUCOSE_FASTING: "thresholds.metricGlucoseFasting",
|
||||
BLOOD_GLUCOSE_POSTPRANDIAL: "thresholds.metricGlucosePostprandial",
|
||||
BLOOD_GLUCOSE_RANDOM: "thresholds.metricGlucoseRandom",
|
||||
BLOOD_GLUCOSE_BEDTIME: "thresholds.metricGlucoseBedtime",
|
||||
};
|
||||
|
||||
export function ThresholdsSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["user", "thresholds"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/user/thresholds");
|
||||
if (!res.ok) throw new Error("failed");
|
||||
const json = await res.json();
|
||||
return json.data as ThresholdsApiResponse;
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (
|
||||
payload: Partial<Record<ThresholdMetric, { min: number; max: number }>>,
|
||||
) => {
|
||||
const res = await fetch("/api/user/thresholds", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null);
|
||||
throw new Error(err?.error ?? "save failed");
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
|
||||
// Every chart/band depends on these thresholds — invalidate everything.
|
||||
queryClient.invalidateQueries({ queryKey: ["analytics"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
toast.success(t("thresholds.saveSuccess"));
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : t("thresholds.saveError")),
|
||||
});
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: async (metric: ThresholdMetric | null) => {
|
||||
const url = metric
|
||||
? `/api/user/thresholds?metric=${metric}`
|
||||
: "/api/user/thresholds";
|
||||
const res = await fetch(url, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error("reset failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "thresholds"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["analytics"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["insights"] });
|
||||
toast.success(t("thresholds.resetSuccess"));
|
||||
},
|
||||
onError: () => toast.error(t("thresholds.saveError")),
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="bg-card border-border scroll-mt-28 rounded-xl border p-6 space-y-5"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<SlidersHorizontal className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">{t("thresholds.title")}</h2>
|
||||
</div>
|
||||
{data && Object.keys(data.overrides).length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate(null)}
|
||||
disabled={resetMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="mr-2 h-3.5 w-3.5" />
|
||||
{t("thresholds.resetAllAction")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("thresholds.subtitle")}
|
||||
</p>
|
||||
|
||||
{isLoading || !data ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{METRIC_ORDER.map((metric) => (
|
||||
<MetricRow
|
||||
key={metric}
|
||||
metric={metric}
|
||||
effective={data.effective[metric]}
|
||||
override={data.overrides[metric] ?? null}
|
||||
onSave={(range) => updateMutation.mutate({ [metric]: range })}
|
||||
onReset={() => resetMutation.mutate(metric)}
|
||||
busy={updateMutation.isPending || resetMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MetricRowProps {
|
||||
metric: ThresholdMetric;
|
||||
effective: EffectiveRange | undefined;
|
||||
override: { min: number; max: number } | null;
|
||||
onSave: (range: { min: number; max: number }) => void;
|
||||
onReset: () => void;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
function MetricRow({
|
||||
metric,
|
||||
effective,
|
||||
override,
|
||||
onSave,
|
||||
onReset,
|
||||
busy,
|
||||
}: MetricRowProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const bounds = METRIC_BOUNDS[metric];
|
||||
const hasOverride = override !== null;
|
||||
const [overrideMode, setOverrideMode] = useState(hasOverride);
|
||||
const [minStr, setMinStr] = useState(
|
||||
String(override?.min ?? effective?.default?.greenMin ?? bounds.min),
|
||||
);
|
||||
const [maxStr, setMaxStr] = useState(
|
||||
String(override?.max ?? effective?.default?.greenMax ?? bounds.max),
|
||||
);
|
||||
|
||||
const minNum = parseFloat(minStr);
|
||||
const maxNum = parseFloat(maxStr);
|
||||
const valid =
|
||||
Number.isFinite(minNum) &&
|
||||
Number.isFinite(maxNum) &&
|
||||
minNum >= bounds.min &&
|
||||
maxNum <= bounds.max &&
|
||||
minNum < maxNum;
|
||||
|
||||
const defaultRange = effective?.default;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{t(METRIC_LABEL_KEYS[metric])}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{defaultRange
|
||||
? `${t("thresholds.defaultLabel")}: ${fmt.number(defaultRange.greenMin, 1)}–${fmt.number(defaultRange.greenMax, 1)} ${bounds.unit}`
|
||||
: t("thresholds.unsetExplanation")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Label htmlFor={`override-${metric}`} className="text-xs">
|
||||
{overrideMode
|
||||
? t("thresholds.overrideModeLabel")
|
||||
: t("thresholds.autoModeLabel")}
|
||||
</Label>
|
||||
<Switch
|
||||
id={`override-${metric}`}
|
||||
checked={overrideMode}
|
||||
onCheckedChange={(next) => {
|
||||
setOverrideMode(next);
|
||||
if (!next && hasOverride) onReset();
|
||||
}}
|
||||
disabled={busy}
|
||||
/>
|
||||
{hasOverride && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("thresholds.sourceOverride")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{overrideMode && (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_1fr_auto]">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`min-${metric}`} className="text-xs">
|
||||
{t("thresholds.minLabel")}{" "}
|
||||
{t("thresholds.unitSuffix", { unit: bounds.unit })}
|
||||
</Label>
|
||||
<Input
|
||||
id={`min-${metric}`}
|
||||
type="number"
|
||||
step={metric === "ACTIVITY_STEPS" ? 100 : 0.1}
|
||||
min={bounds.min}
|
||||
max={bounds.max}
|
||||
value={minStr}
|
||||
onChange={(e) => setMinStr(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`max-${metric}`} className="text-xs">
|
||||
{t("thresholds.maxLabel")}{" "}
|
||||
{t("thresholds.unitSuffix", { unit: bounds.unit })}
|
||||
</Label>
|
||||
<Input
|
||||
id={`max-${metric}`}
|
||||
type="number"
|
||||
step={metric === "ACTIVITY_STEPS" ? 100 : 0.1}
|
||||
min={bounds.min}
|
||||
max={bounds.max}
|
||||
value={maxStr}
|
||||
onChange={(e) => setMaxStr(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<Button
|
||||
onClick={() => valid && onSave({ min: minNum, max: maxNum })}
|
||||
disabled={busy || !valid}
|
||||
size="sm"
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
{hasOverride && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("thresholds.resetAction")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("thresholds.outOfBoundsHint", {
|
||||
min: bounds.min,
|
||||
max: bounds.max,
|
||||
unit: bounds.unit,
|
||||
})}
|
||||
</p>
|
||||
{hasOverride &&
|
||||
defaultRange &&
|
||||
(override!.min < defaultRange.greenMin * 0.7 ||
|
||||
override!.max > defaultRange.greenMax * 1.3) && (
|
||||
<div className="flex items-start gap-2 rounded-md border-l-2 border-dracula-orange/50 bg-dracula-orange/5 p-2 text-xs">
|
||||
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-dracula-orange" />
|
||||
<span>{t("thresholds.overrideWarning")}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { translateZxcvbn } from "@/lib/zxcvbn-de";
|
||||
import { getZxcvbnTranslations } from "@/lib/zxcvbn-i18n";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface PasswordStrengthProps {
|
||||
@@ -22,7 +22,11 @@ export function PasswordStrength({
|
||||
password,
|
||||
minLength = 12,
|
||||
}: PasswordStrengthProps) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const { translate } = useMemo(
|
||||
() => getZxcvbnTranslations(locale),
|
||||
[locale],
|
||||
);
|
||||
const [result, setResult] = useState<{
|
||||
score: number;
|
||||
feedback: { warning: string; suggestions: string[] };
|
||||
@@ -70,10 +74,10 @@ export function PasswordStrength({
|
||||
feedback.push(t("passwordStrength.minLength", { count: minLength }));
|
||||
}
|
||||
if (result?.feedback.warning) {
|
||||
feedback.push(translateZxcvbn(result.feedback.warning));
|
||||
feedback.push(translate(result.feedback.warning));
|
||||
}
|
||||
if (result?.feedback.suggestions) {
|
||||
feedback.push(...result.feedback.suggestions.map(translateZxcvbn));
|
||||
feedback.push(...result.feedback.suggestions.map(translate));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface AuthUser {
|
||||
timezone: string;
|
||||
onboardingCompletedAt: string | null;
|
||||
gravatarUrl: string | null;
|
||||
glucoseUnit: string | null;
|
||||
}
|
||||
|
||||
async function fetchMe(): Promise<AuthUser> {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
mgdlToMmol,
|
||||
mmolToMgdl,
|
||||
convertGlucose,
|
||||
resolveGlucoseUnit,
|
||||
thresholdMetricForContext,
|
||||
} from "../glucose";
|
||||
|
||||
describe("glucose conversion", () => {
|
||||
it("converts mg/dL to mmol/L with 1 decimal", () => {
|
||||
expect(mgdlToMmol(100)).toBe(5.5);
|
||||
expect(mgdlToMmol(126)).toBeCloseTo(7.0, 1); // diabetes threshold
|
||||
expect(mgdlToMmol(70)).toBeCloseTo(3.9, 1); // hypoglycemia threshold
|
||||
});
|
||||
|
||||
it("converts mmol/L to mg/dL rounded to integer", () => {
|
||||
expect(mmolToMgdl(5.5)).toBe(99);
|
||||
expect(mmolToMgdl(7.0)).toBe(126);
|
||||
});
|
||||
|
||||
it("roundtrips within 1 mg/dL", () => {
|
||||
for (const mgdl of [70, 99, 100, 126, 140, 200, 300]) {
|
||||
const back = mmolToMgdl(mgdlToMmol(mgdl));
|
||||
expect(Math.abs(back - mgdl)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("convertGlucose dispatches to the right unit", () => {
|
||||
expect(convertGlucose(100, "mg/dL")).toBe(100);
|
||||
expect(convertGlucose(100, "mmol/L")).toBe(5.5);
|
||||
});
|
||||
|
||||
it("resolveGlucoseUnit defaults to mg/dL", () => {
|
||||
expect(resolveGlucoseUnit(null)).toBe("mg/dL");
|
||||
expect(resolveGlucoseUnit(undefined)).toBe("mg/dL");
|
||||
expect(resolveGlucoseUnit("mmol/L")).toBe("mmol/L");
|
||||
expect(resolveGlucoseUnit("mg/dL")).toBe("mg/dL");
|
||||
expect(resolveGlucoseUnit("random-garbage")).toBe("mg/dL");
|
||||
});
|
||||
|
||||
it("thresholdMetricForContext maps every context", () => {
|
||||
expect(thresholdMetricForContext("FASTING")).toBe("BLOOD_GLUCOSE_FASTING");
|
||||
expect(thresholdMetricForContext("POSTPRANDIAL")).toBe(
|
||||
"BLOOD_GLUCOSE_POSTPRANDIAL",
|
||||
);
|
||||
expect(thresholdMetricForContext("RANDOM")).toBe("BLOOD_GLUCOSE_RANDOM");
|
||||
expect(thresholdMetricForContext("BEDTIME")).toBe("BLOOD_GLUCOSE_BEDTIME");
|
||||
});
|
||||
|
||||
// Display-unit logic guard for the targets/dashboard surfaces:
|
||||
// canonical mg/dL stays integer; mmol/L stays 1-decimal; ranges convert too.
|
||||
it("display unit conversion preserves integer/decimal precision", () => {
|
||||
const fastingMin = 70; // mg/dL
|
||||
const fastingMax = 99;
|
||||
const ada126 = 126; // diabetes threshold
|
||||
|
||||
expect(convertGlucose(fastingMin, "mg/dL")).toBe(70);
|
||||
expect(convertGlucose(fastingMax, "mg/dL")).toBe(99);
|
||||
expect(convertGlucose(ada126, "mg/dL")).toBe(126);
|
||||
|
||||
expect(convertGlucose(fastingMin, "mmol/L")).toBe(3.9);
|
||||
expect(convertGlucose(fastingMax, "mmol/L")).toBe(5.5);
|
||||
expect(convertGlucose(ada126, "mmol/L")).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getServerTranslator } from "../i18n/server-translator";
|
||||
|
||||
describe("getServerTranslator", () => {
|
||||
it("returns the German string for a known key when locale=de", () => {
|
||||
const { t } = getServerTranslator("de");
|
||||
expect(t("telegram.cancelled")).toBe("Abgebrochen.");
|
||||
});
|
||||
|
||||
it("returns the English string for a known key when locale=en", () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
expect(t("telegram.cancelled")).toBe("Cancelled.");
|
||||
});
|
||||
|
||||
it("interpolates {param} placeholders", () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
expect(t("telegram.snoozedFor", { name: "Ramipril", duration: "1 hour" })).toBe(
|
||||
"Ramipril snoozed for 1 hour.",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to English when a key is missing in the German bundle", () => {
|
||||
// Sanity: every telegram.* key currently exists in both bundles, so
|
||||
// simulate a missing key via a deeply nested path.
|
||||
const { t } = getServerTranslator("de");
|
||||
expect(t("telegram.__definitely_missing__")).toBe(
|
||||
"telegram.__definitely_missing__",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the raw key when missing in both locales", () => {
|
||||
const { t } = getServerTranslator("en");
|
||||
expect(t("nope.also.missing")).toBe("nope.also.missing");
|
||||
});
|
||||
|
||||
it("exposes the active locale on the returned translator", () => {
|
||||
expect(getServerTranslator("de").locale).toBe("de");
|
||||
expect(getServerTranslator("en").locale).toBe("en");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { AnthropicClient } from "../anthropic-client";
|
||||
|
||||
describe("AnthropicClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("sends correct request shape with required headers", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
content: [{ type: "text", text: '{"summary":"ok"}' }],
|
||||
usage: { input_tokens: 30, output_tokens: 12 },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new AnthropicClient({
|
||||
apiKey: "sk-ant-test-key",
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
});
|
||||
|
||||
const result = await client.generateCompletion({
|
||||
systemPrompt: "You are a doctor.",
|
||||
userPrompt: "Analyze this data.",
|
||||
temperature: 0.4,
|
||||
maxTokens: 800,
|
||||
});
|
||||
|
||||
expect(result.content).toBe('{"summary":"ok"}');
|
||||
expect(result.tokensUsed).toBe(42);
|
||||
expect(result.model).toBe("claude-3-5-sonnet-latest");
|
||||
expect(result.providerType).toBe("anthropic");
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe("https://api.anthropic.com/v1/messages");
|
||||
|
||||
expect(init.headers["x-api-key"]).toBe("sk-ant-test-key");
|
||||
expect(init.headers["anthropic-version"]).toBe("2023-06-01");
|
||||
expect(init.headers["Content-Type"]).toBe("application/json");
|
||||
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.model).toBe("claude-3-5-sonnet-latest");
|
||||
expect(body.max_tokens).toBe(800);
|
||||
expect(body.temperature).toBe(0.4);
|
||||
// System prompt is a separate top-level field, NOT a message.
|
||||
expect(body.system).toBe("You are a doctor.");
|
||||
expect(body.messages).toEqual([
|
||||
expect.objectContaining({ role: "user" }),
|
||||
]);
|
||||
// User message should be wrapped to coerce JSON output.
|
||||
expect(body.messages[0].content).toContain("Analyze this data.");
|
||||
expect(body.messages[0].content.toLowerCase()).toContain("json");
|
||||
});
|
||||
|
||||
it("uses custom baseUrl when provided", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
content: [{ type: "text", text: "{}" }],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new AnthropicClient({
|
||||
apiKey: "sk-ant-x",
|
||||
model: "claude-3-haiku-20240307",
|
||||
baseUrl: "https://api.anthropic.example/v1/",
|
||||
});
|
||||
|
||||
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
|
||||
|
||||
expect(mockFetch.mock.calls[0][0]).toBe(
|
||||
"https://api.anthropic.example/v1/messages",
|
||||
);
|
||||
});
|
||||
|
||||
it("captures structured error fields and redacts the API key on failure", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 529,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
'overloaded; debug=sk-ant-supersecretkey1234567890 leaked',
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new AnthropicClient({
|
||||
apiKey: "sk-ant-test",
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
});
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await client.generateCompletion({
|
||||
systemPrompt: "s",
|
||||
userPrompt: "u",
|
||||
});
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
const err = caught as Error & {
|
||||
httpStatus?: number;
|
||||
upstream?: string;
|
||||
bodyExcerpt?: string;
|
||||
model?: string;
|
||||
};
|
||||
expect(err.message).toBe("Anthropic request failed (529)");
|
||||
expect(err.httpStatus).toBe(529);
|
||||
expect(err.upstream).toBe("anthropic");
|
||||
expect(err.model).toBe("claude-3-5-sonnet-latest");
|
||||
expect(err.bodyExcerpt).toContain("sk-ant-***redacted***");
|
||||
expect(err.bodyExcerpt).not.toContain("supersecretkey");
|
||||
});
|
||||
|
||||
it("throws when content is empty", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ content: [], usage: {} }),
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new AnthropicClient({
|
||||
apiKey: "sk-ant-x",
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.generateCompletion({ systemPrompt: "s", userPrompt: "u" }),
|
||||
).rejects.toThrow("Anthropic returned empty content");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { LocalOpenAICompatibleClient } from "../local-client";
|
||||
|
||||
describe("LocalOpenAICompatibleClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("targets the configured baseUrl + /chat/completions", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: '{"summary":"local"}' } }],
|
||||
usage: { total_tokens: 17 },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new LocalOpenAICompatibleClient({
|
||||
apiKey: null,
|
||||
model: "llama3:8b",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
});
|
||||
|
||||
const result = await client.generateCompletion({
|
||||
systemPrompt: "system",
|
||||
userPrompt: "user-input",
|
||||
});
|
||||
|
||||
expect(result.content).toBe('{"summary":"local"}');
|
||||
expect(result.tokensUsed).toBe(17);
|
||||
expect(result.model).toBe("llama3:8b");
|
||||
expect(result.providerType).toBe("local");
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe("http://localhost:11434/v1/chat/completions");
|
||||
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.model).toBe("llama3:8b");
|
||||
expect(body.messages).toHaveLength(2);
|
||||
expect(body.messages[0]).toEqual({ role: "system", content: "system" });
|
||||
// User message should be prefixed with the strict-JSON instruction.
|
||||
expect(body.messages[1].role).toBe("user");
|
||||
expect(body.messages[1].content).toContain("user-input");
|
||||
expect(body.messages[1].content.toLowerCase()).toContain("json");
|
||||
|
||||
// CRUCIAL: do not send response_format because many local servers reject it.
|
||||
expect(body).not.toHaveProperty("response_format");
|
||||
});
|
||||
|
||||
it("omits Authorization header when no API key is provided", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: "{}" } }],
|
||||
usage: { total_tokens: 1 },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new LocalOpenAICompatibleClient({
|
||||
apiKey: "",
|
||||
model: "phi3",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
});
|
||||
|
||||
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
|
||||
|
||||
const init = mockFetch.mock.calls[0][1];
|
||||
expect(init.headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends Bearer Authorization when an API key is provided", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: "{}" } }],
|
||||
usage: { total_tokens: 1 },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const client = new LocalOpenAICompatibleClient({
|
||||
apiKey: "lm-studio-secret",
|
||||
model: "phi3",
|
||||
baseUrl: "http://192.168.1.20:1234/v1",
|
||||
});
|
||||
|
||||
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
|
||||
|
||||
const init = mockFetch.mock.calls[0][1];
|
||||
expect(init.headers.Authorization).toBe("Bearer lm-studio-secret");
|
||||
});
|
||||
|
||||
it("captures structured error fields and redacts bearer tokens on failure", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
'upstream rejected: Bearer sk-localabcdef12345 hit /v1',
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new LocalOpenAICompatibleClient({
|
||||
apiKey: "sk-local-test",
|
||||
model: "llama3",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
});
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await client.generateCompletion({ systemPrompt: "s", userPrompt: "u" });
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
const err = caught as Error & {
|
||||
httpStatus?: number;
|
||||
upstream?: string;
|
||||
bodyExcerpt?: string;
|
||||
baseUrl?: string;
|
||||
};
|
||||
expect(err.message).toBe("Local AI request failed (500)");
|
||||
expect(err.httpStatus).toBe(500);
|
||||
expect(err.upstream).toBe("local");
|
||||
expect(err.baseUrl).toBe("http://localhost:11434/v1");
|
||||
expect(err.bodyExcerpt).toContain("Bearer ***redacted***");
|
||||
expect(err.bodyExcerpt).not.toContain("sk-localabcdef12345");
|
||||
});
|
||||
|
||||
it("throws when content is empty", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({ choices: [{ message: { content: "" } }] }),
|
||||
}),
|
||||
);
|
||||
|
||||
const client = new LocalOpenAICompatibleClient({
|
||||
apiKey: null,
|
||||
model: "llama3",
|
||||
baseUrl: "http://localhost:11434/v1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.generateCompletion({ systemPrompt: "s", userPrompt: "u" }),
|
||||
).rejects.toThrow("Local AI returned empty content");
|
||||
});
|
||||
});
|
||||
@@ -12,43 +12,85 @@ vi.mock("@/lib/crypto", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/ai/codex-oauth", () => ({
|
||||
refreshAccessToken: vi.fn(),
|
||||
encryptTokens: vi.fn((t: { accessToken: string; refreshToken: string }) => ({ accessEncrypted: `enc:${t.accessToken}`, refreshEncrypted: `enc:${t.refreshToken}` })),
|
||||
encryptTokens: vi.fn((t: { accessToken: string; refreshToken: string }) => ({
|
||||
accessEncrypted: `enc:${t.accessToken}`,
|
||||
refreshEncrypted: `enc:${t.refreshToken}`,
|
||||
})),
|
||||
decryptTokens: vi.fn(),
|
||||
}));
|
||||
|
||||
import { resolveProvider } from "../provider";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptTokens } from "@/lib/ai/codex-oauth";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
decryptTokens,
|
||||
encryptTokens,
|
||||
} from "@/lib/ai/codex-oauth";
|
||||
|
||||
/**
|
||||
* resolveProvider() makes up to TWO findUnique calls per invocation:
|
||||
* 1. Read user-level AI config (aiProvider/...).
|
||||
* 2. If falling back to codex, read codex token fields.
|
||||
* We use mockResolvedValueOnce in order so each call returns the right shape.
|
||||
*/
|
||||
|
||||
describe("resolveProvider", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset mocks AND mockResolvedValueOnce queues, then re-establish
|
||||
// the deterministic factory implementations for crypto/oauth helpers.
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(decrypt).mockImplementation((v: string) => `decrypted:${v}`);
|
||||
vi.mocked(encrypt).mockImplementation((v: string) => `encrypted:${v}`);
|
||||
vi.mocked(encryptTokens).mockImplementation(
|
||||
(t: { accessToken: string; refreshToken: string }) => ({
|
||||
accessEncrypted: `enc:${t.accessToken}`,
|
||||
refreshEncrypted: `enc:${t.refreshToken}`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns codex provider when user has valid tokens", async () => {
|
||||
it("returns codex provider when user has valid tokens (no explicit choice)", async () => {
|
||||
vi.mocked(decryptTokens).mockReturnValue({
|
||||
accessToken: "decrypted-access",
|
||||
refreshToken: "decrypted-refresh",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
codexAccessTokenEncrypted: "enc-access",
|
||||
codexRefreshTokenEncrypted: "enc-refresh",
|
||||
codexTokenExpiresAt: new Date(Date.now() + 3600000),
|
||||
codexConnectionStatus: "connected",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
// 1st call: user AI config — none selected
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
} as never)
|
||||
// 2nd call: codex tokens
|
||||
.mockResolvedValueOnce({
|
||||
codexAccessTokenEncrypted: "enc-access",
|
||||
codexRefreshTokenEncrypted: "enc-refresh",
|
||||
codexTokenExpiresAt: new Date(Date.now() + 3600000),
|
||||
codexConnectionStatus: "connected",
|
||||
} as never);
|
||||
|
||||
const provider = await resolveProvider("user-123");
|
||||
expect(provider.type).toBe("codex");
|
||||
});
|
||||
|
||||
it("returns admin-key when user has no codex but admin key exists", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexRefreshTokenEncrypted: null,
|
||||
codexTokenExpiresAt: null,
|
||||
codexConnectionStatus: "disconnected",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexRefreshTokenEncrypted: null,
|
||||
codexTokenExpiresAt: null,
|
||||
codexConnectionStatus: "disconnected",
|
||||
} as never);
|
||||
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc-admin-key",
|
||||
@@ -61,10 +103,20 @@ describe("resolveProvider", () => {
|
||||
});
|
||||
|
||||
it("returns none when nothing is configured", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexConnectionStatus: "disconnected",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexRefreshTokenEncrypted: null,
|
||||
codexTokenExpiresAt: null,
|
||||
codexConnectionStatus: "disconnected",
|
||||
} as never);
|
||||
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: null,
|
||||
@@ -75,12 +127,20 @@ describe("resolveProvider", () => {
|
||||
});
|
||||
|
||||
it("falls back to admin-key when codex status is expired", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
codexAccessTokenEncrypted: "enc-access",
|
||||
codexRefreshTokenEncrypted: "enc-refresh",
|
||||
codexTokenExpiresAt: new Date(Date.now() - 1000),
|
||||
codexConnectionStatus: "expired",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
codexAccessTokenEncrypted: "enc-access",
|
||||
codexRefreshTokenEncrypted: "enc-refresh",
|
||||
codexTokenExpiresAt: new Date(Date.now() - 1000),
|
||||
codexConnectionStatus: "expired",
|
||||
} as never);
|
||||
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc-admin",
|
||||
@@ -91,4 +151,101 @@ describe("resolveProvider", () => {
|
||||
const provider = await resolveProvider("user-123");
|
||||
expect(provider.type).toBe("admin-key");
|
||||
});
|
||||
|
||||
it("returns Anthropic provider when user picks ANTHROPIC and has key", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
aiProvider: "ANTHROPIC",
|
||||
aiModel: "claude-3-5-sonnet-latest",
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: "enc-anthropic-key",
|
||||
aiLocalKeyEncrypted: null,
|
||||
} as never);
|
||||
|
||||
const provider = await resolveProvider("user-123");
|
||||
expect(provider.type).toBe("anthropic");
|
||||
// Codex/admin lookups must NOT happen when user provider resolved.
|
||||
expect(prisma.appSettings.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back when user picks ANTHROPIC but has no key", async () => {
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: "ANTHROPIC",
|
||||
aiModel: "claude-3-5-sonnet-latest",
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
codexAccessTokenEncrypted: null,
|
||||
codexConnectionStatus: "disconnected",
|
||||
} as never);
|
||||
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc-admin",
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
} as never);
|
||||
|
||||
// ANTHROPIC explicit choice ≠ CHATGPT_OAUTH/null → codex branch is skipped.
|
||||
const provider = await resolveProvider("user-123");
|
||||
expect(provider.type).toBe("admin-key");
|
||||
});
|
||||
|
||||
it("returns Local provider when user picks LOCAL with baseUrl", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
aiProvider: "LOCAL",
|
||||
aiModel: "llama3:8b",
|
||||
aiBaseUrl: "http://localhost:11434/v1",
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
} as never);
|
||||
|
||||
const provider = await resolveProvider("user-123");
|
||||
expect(provider.type).toBe("local");
|
||||
});
|
||||
|
||||
it("OPENAI selection routes through admin OpenAI key", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
aiProvider: "OPENAI",
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
} as never);
|
||||
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
adminAiKeyEncrypted: "enc-admin",
|
||||
adminAiModel: "gpt-4o-mini",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
} as never);
|
||||
|
||||
const provider = await resolveProvider("user-123");
|
||||
expect(provider.type).toBe("admin-key");
|
||||
});
|
||||
|
||||
it("CHATGPT_OAUTH selection routes through Codex when connected", async () => {
|
||||
vi.mocked(decryptTokens).mockReturnValue({
|
||||
accessToken: "decrypted-access",
|
||||
refreshToken: "decrypted-refresh",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
.mockResolvedValueOnce({
|
||||
aiProvider: "CHATGPT_OAUTH",
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
} as never)
|
||||
.mockResolvedValueOnce({
|
||||
codexAccessTokenEncrypted: "enc-access",
|
||||
codexRefreshTokenEncrypted: "enc-refresh",
|
||||
codexTokenExpiresAt: new Date(Date.now() + 3600000),
|
||||
codexConnectionStatus: "connected",
|
||||
} as never);
|
||||
|
||||
const provider = await resolveProvider("user-123");
|
||||
expect(provider.type).toBe("codex");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
|
||||
|
||||
interface AnthropicClientConfig {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_BASE_URL = "https://api.anthropic.com/v1";
|
||||
const ANTHROPIC_VERSION = "2023-06-01";
|
||||
|
||||
/**
|
||||
* Wrap the user prompt so Claude reliably emits valid JSON.
|
||||
* Anthropic's Messages API has no `response_format: json_object` knob; the
|
||||
* documented best-practice is an explicit instruction in the user message.
|
||||
*/
|
||||
function wrapForJson(userPrompt: string): string {
|
||||
return `${userPrompt}\n\nRespond only with valid JSON matching the requested schema. Do not include any prose, markdown fences, or explanation outside the JSON object.`;
|
||||
}
|
||||
|
||||
export class AnthropicClient implements AIProvider {
|
||||
readonly type = "anthropic" as const;
|
||||
private config: AnthropicClientConfig;
|
||||
|
||||
constructor(config: AnthropicClientConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async generateCompletion(params: CompletionParams): Promise<CompletionResult> {
|
||||
const baseUrl = (this.config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
||||
const url = `${baseUrl}/messages`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": this.config.apiKey,
|
||||
"anthropic-version": ANTHROPIC_VERSION,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
max_tokens: params.maxTokens ?? 1000,
|
||||
temperature: params.temperature ?? 0.3,
|
||||
system: params.systemPrompt,
|
||||
messages: [
|
||||
{ role: "user", content: wrapForJson(params.userPrompt) },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Mirror the openai-client body-capture so 4xx/5xx upstream incidents
|
||||
// (model-not-found, overloaded, rate limit) are diagnosable from logs
|
||||
// instead of surfacing as an opaque "Anthropic request failed (5xx)".
|
||||
// Strip anything that looks like an Anthropic API key from the excerpt
|
||||
// before logging.
|
||||
const rawBody = await res.text().catch(() => "");
|
||||
const bodyExcerpt = rawBody
|
||||
.slice(0, 500)
|
||||
.replace(/sk-ant-[A-Za-z0-9_-]{8,}/g, "sk-ant-***redacted***")
|
||||
.replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***");
|
||||
const err = new Error(`Anthropic request failed (${res.status})`);
|
||||
Object.assign(err, {
|
||||
httpStatus: res.status,
|
||||
upstream: "anthropic",
|
||||
model: this.config.model,
|
||||
bodyExcerpt,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
usage?: { input_tokens?: number; output_tokens?: number };
|
||||
};
|
||||
|
||||
const textBlock = json.content?.find((c) => c.type === "text");
|
||||
const content = textBlock?.text;
|
||||
|
||||
if (!content) {
|
||||
throw new Error("Anthropic returned empty content");
|
||||
}
|
||||
|
||||
const inputTokens = json.usage?.input_tokens ?? 0;
|
||||
const outputTokens = json.usage?.output_tokens ?? 0;
|
||||
const tokensUsed = inputTokens + outputTokens || null;
|
||||
|
||||
return {
|
||||
content,
|
||||
tokensUsed,
|
||||
model: this.config.model,
|
||||
providerType: "anthropic",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
|
||||
|
||||
interface LocalClientConfig {
|
||||
apiKey?: string | null;
|
||||
model: string;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Talks to OpenAI-compatible local servers (Ollama, LocalAI, LM Studio,
|
||||
* vLLM, …). Same wire format as OpenAI but without
|
||||
* `response_format: { type: "json_object" }` — many local models reject the
|
||||
* field outright. Instead we prepend a strict-JSON instruction to the user
|
||||
* message, which is what most local model templates respect.
|
||||
*/
|
||||
export class LocalOpenAICompatibleClient implements AIProvider {
|
||||
readonly type = "local" as const;
|
||||
private config: LocalClientConfig;
|
||||
|
||||
constructor(config: LocalClientConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async generateCompletion(params: CompletionParams): Promise<CompletionResult> {
|
||||
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
// Some local servers (LM Studio with auth, vLLM behind a proxy) require a
|
||||
// bearer; Ollama does not. Send the header only when an actual key is set.
|
||||
const trimmedKey = this.config.apiKey?.trim();
|
||||
if (trimmedKey) {
|
||||
headers.Authorization = `Bearer ${trimmedKey}`;
|
||||
}
|
||||
|
||||
const userPrompt = `Return strict JSON only, no markdown, no commentary.\n\n${params.userPrompt}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
messages: [
|
||||
{ role: "system", content: params.systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
temperature: params.temperature ?? 0.3,
|
||||
max_tokens: params.maxTokens ?? 1000,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Mirror the openai-client structured-error pattern. Local endpoints
|
||||
// also commonly leak bearer tokens in error envelopes (proxies), so
|
||||
// strip anything that looks like a key before logging.
|
||||
const rawBody = await res.text().catch(() => "");
|
||||
const bodyExcerpt = rawBody
|
||||
.slice(0, 500)
|
||||
.replace(/sk-[A-Za-z0-9_-]{8,}/g, "sk-***redacted***")
|
||||
.replace(/Bearer\s+[A-Za-z0-9_.-]+/gi, "Bearer ***redacted***");
|
||||
const err = new Error(`Local AI request failed (${res.status})`);
|
||||
Object.assign(err, {
|
||||
httpStatus: res.status,
|
||||
upstream: "local",
|
||||
model: this.config.model,
|
||||
baseUrl: this.config.baseUrl,
|
||||
bodyExcerpt,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
usage?: { total_tokens?: number };
|
||||
};
|
||||
|
||||
const content = json.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error("Local AI returned empty content");
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
tokensUsed: json.usage?.total_tokens ?? null,
|
||||
model: this.config.model,
|
||||
providerType: "local",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export const BASE_SYSTEM_PROMPT = `Du bist ein persönlicher Gesundheitsanalyst, der die Daten dieses Nutzers kennt. Deine Expertise umfasst Innere Medizin und Präventivmedizin. Deine Analysen basieren auf aktuellen medizinischen Leitlinien (ESC/ESH 2023, WHO, DGE, DEGAM), aber du beziehst dich immer auf die individuellen Werte und die persönliche Baseline des Nutzers.
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
const BASE_SYSTEM_PROMPT_DE = `Du bist ein persönlicher Gesundheitsanalyst, der die Daten dieses Nutzers kennt. Deine Expertise umfasst Innere Medizin und Präventivmedizin. Deine Analysen basieren auf aktuellen medizinischen Leitlinien (ESC/ESH 2023, WHO, DGE, DEGAM), aber du beziehst dich immer auf die individuellen Werte und die persönliche Baseline des Nutzers.
|
||||
|
||||
TONALITÄT UND ANSPRACHE:
|
||||
- Verwende die zweite Person: "dein Blutdruck", "deine Werte", "dein Gewicht".
|
||||
@@ -19,7 +21,7 @@ GRUNDREGELN:
|
||||
* < 5 Messpunkte: "Noch nicht genügend Daten für eine fundierte Aussage."
|
||||
* Große Lücken (avgDaysBetween > 7): Hinweis auf eingeschränkte Belastbarkeit.
|
||||
* Neueste Messung > 7 Tage alt: "Daten möglicherweise nicht aktuell."
|
||||
- Sprache: Deutsch, medizinisch präzise aber allgemeinverständlich.
|
||||
- Sprache: Antworte auf Deutsch, medizinisch präzise aber allgemeinverständlich.
|
||||
- Disclaimer: Immer den Standardhinweis verwenden.
|
||||
- Alter/Geschlecht: Falls in context.ageYears und context.gender vorhanden, alters- und geschlechtsspezifische Referenzwerte verwenden.
|
||||
|
||||
@@ -61,16 +63,100 @@ TEMPORALE SCHICHTEN:
|
||||
- avg7 vs avg30 zeigt aktuelle Tendenz, avg90/allTime zeigt Langzeit-Baseline
|
||||
- Abweichungen von der Langzeit-Baseline sind klinisch aussagekräftiger als kurzfristige Schwankungen
|
||||
|
||||
AUSGABEFORMAT: Antworte ausschließlich mit validem JSON im folgenden Schema:
|
||||
AUSGABEFORMAT: Antworte ausschließlich mit validem JSON im folgenden Schema. Die Felder "classification" und "confidence" müssen exakt eine der englischen Enum-Bezeichnungen ("optimal|gut|grenzwertig|erhoht|kritisch" bzw. "hoch|mittel|gering"/"niedrig") sein — diese sind stabile Vertragsschlüssel und werden NICHT übersetzt. Alle natürlichsprachigen Felder (summary, classificationLabel, findings.label/value/assessment guideline, recommendations, etc.) MÜSSEN auf Deutsch sein.
|
||||
{
|
||||
"insightType": "blood_pressure|weight|pulse|mood|bmi|medication_compliance|general",
|
||||
"summary": "2-3 Sätze Gesamtbewertung (in zweiter Person, positiv zuerst)",
|
||||
"summary": "2-3 Sätze Gesamtbewertung auf Deutsch (in zweiter Person, positiv zuerst)",
|
||||
"classification": "optimal|gut|grenzwertig|erhoht|kritisch",
|
||||
"classificationLabel": "Menschenlesbare deutsche Bezeichnung (z.B. 'Adipositas Grad II', 'Hochnormal', 'Bradykardie')",
|
||||
"findings": [{"label": "...", "value": "...", "assessment": "positive|neutral|attention|warning", "guideline": "..."}],
|
||||
"correlations": [{"factor": "...", "effect": "...", "confidence": "hoch|mittel|gering"}],
|
||||
"primaryRecommendation": "DIE eine wichtigste Handlungsempfehlung (max 20 Wörter)",
|
||||
"recommendations": ["2-3 ergänzende Vorschläge"],
|
||||
"primaryRecommendation": "DIE eine wichtigste Handlungsempfehlung auf Deutsch (max 20 Wörter)",
|
||||
"recommendations": ["2-3 ergänzende Vorschläge auf Deutsch"],
|
||||
"dataQuality": {"coverage": "...", "gaps": ["..."], "confidence": "hoch|mittel|gering"},
|
||||
"disclaimer": "Diese Analyse ersetzt keine ärztliche Beratung. Bei Beschwerden oder auffälligen Werten konsultiere deinen Arzt."
|
||||
}`;
|
||||
|
||||
const BASE_SYSTEM_PROMPT_EN = `You are a personal health analyst who knows this user's data. Your expertise covers internal medicine and preventive medicine. Your analyses follow current medical guidelines (ESC/ESH 2023, WHO, DGE, DEGAM), but you always relate findings to the user's individual values and personal baseline.
|
||||
|
||||
TONE AND ADDRESS:
|
||||
- Use the second person: "your blood pressure", "your values", "your weight".
|
||||
- Open with positive findings before raising concerns.
|
||||
- Refer to the user's own baseline rather than population norms (e.g. "your systolic value is 5 mmHg below your 90-day average" instead of "below the population average").
|
||||
- Formulate one central call to action ("One Thing") as the single most important next step.
|
||||
|
||||
REASONING STEPS (apply internally, do not show in output):
|
||||
1. What changed? (Compare 7d vs. 30d vs. 90d vs. allTime.)
|
||||
2. Why? (Correlations, medication, mood, seasonal patterns.)
|
||||
3. What to do? (One primary recommendation plus supporting suggestions.)
|
||||
|
||||
GROUND RULES:
|
||||
- Evidence-based: Cite thresholds and guidelines explicitly when assessing values.
|
||||
- Pattern recognition: Identify time-of-day, weekday and seasonal trends.
|
||||
- Correlations: Name interactions between medication, vital signs and mood.
|
||||
- Data quality: Judge informativeness from sample count, density and recency.
|
||||
* < 5 measurement points: "Not yet enough data for a reliable statement."
|
||||
* Large gaps (avgDaysBetween > 7): Note that conclusions are limited.
|
||||
* Newest measurement > 7 days old: "Data may be out of date."
|
||||
- Language: Reply in English, medically precise but accessible.
|
||||
- Disclaimer: Always include the standard disclaimer.
|
||||
- Age/sex: If context.ageYears and context.gender are provided, apply age- and sex-specific reference values.
|
||||
|
||||
CORRELATION ANALYSIS:
|
||||
- You receive pre-computed Pearson correlations between metrics.
|
||||
- Mention them only if the correlation (r-value) is present in the snapshot and |r| > 0.4.
|
||||
- If the field is missing from the snapshot, do not interpret or invent a correlation.
|
||||
- r > 0.7: strong correlation — clinically relevant, comment in detail.
|
||||
- r 0.4-0.7: moderate correlation — mention, interpret cautiously.
|
||||
- r < 0.4: weak / no correlation — do not mention.
|
||||
- Correlation ≠ causation: always phrase as "association", never as "cause".
|
||||
|
||||
ADVANCED METRICS:
|
||||
- ratePressureProduct.rpp30: pulse × systolic BP. Normal: 7,000-10,000. > 12,000: elevated myocardial oxygen demand.
|
||||
* If both rise ("Double Jeopardy"): highest risk.
|
||||
* If only pulse rises (BP stable): stress / deconditioning.
|
||||
* If only BP rises (pulse stable): vascular resistance.
|
||||
- bodyCompositionDivergence.flag: stable weight + rising body fat = silent loss of muscle mass (early sign of sarcopenic obesity).
|
||||
- moodAdherenceRisk: mood ≤ 2.5 over 7 days and falling = adherence drop within the next 5 days likely. Address proactively.
|
||||
- seasonalVariation: winter-summer delta of systolic BP. > 5 mmHg is physiologically normal. Reassure the user — this is not a deterioration.
|
||||
- sleep: target ≥ 7h/night (ESC). < 6h: risk factor for hypertension and weight gain.
|
||||
- activity: WHO target ≥ 8,000 steps/day.
|
||||
|
||||
HISTORICAL COMPARISON:
|
||||
- Compare current 7-day values against the previous 30-day average.
|
||||
- Call out clinically relevant changes:
|
||||
* Weight: ±2 kg relevant.
|
||||
* Systolic: ±5 mmHg relevant.
|
||||
* Diastolic: ±3 mmHg relevant.
|
||||
* Pulse: ±5 bpm relevant.
|
||||
|
||||
MOOD DATA:
|
||||
- Treat mood (1-5 scale: 1=AWFUL, 2=BAD, 3=OKAY, 4=GOOD, 5=GREAT) as a contextual factor.
|
||||
- Mention correlations with vital signs only if present in the snapshot and |r| > 0.4.
|
||||
- Stress / mood demonstrably affect blood pressure and heart rate.
|
||||
|
||||
TEMPORAL LAYERS:
|
||||
- Compare short term (7d) vs. mid term (30d) vs. long term (90d / allTime).
|
||||
- avg7 vs avg30 shows the current tendency; avg90 / allTime reflects the long-term baseline.
|
||||
- Deviations from the long-term baseline are clinically more meaningful than short-term swings.
|
||||
|
||||
OUTPUT FORMAT: Reply with valid JSON only, matching the schema below. The "classification" and "confidence" fields must contain exactly one of the stable English-style enum keys ("optimal|gut|grenzwertig|erhoht|kritisch" and "hoch|mittel|gering"/"niedrig") — these are stable contract keys and MUST NOT be translated. All natural-language fields (summary, classificationLabel, findings.label/value/assessment guideline, recommendations, etc.) MUST be in English.
|
||||
{
|
||||
"insightType": "blood_pressure|weight|pulse|mood|bmi|medication_compliance|general",
|
||||
"summary": "2-3 sentence overall assessment in English (second person, positives first)",
|
||||
"classification": "optimal|gut|grenzwertig|erhoht|kritisch",
|
||||
"classificationLabel": "Human-readable English label (e.g. 'Obesity class II', 'High-normal', 'Bradycardia')",
|
||||
"findings": [{"label": "...", "value": "...", "assessment": "positive|neutral|attention|warning", "guideline": "..."}],
|
||||
"correlations": [{"factor": "...", "effect": "...", "confidence": "hoch|mittel|gering"}],
|
||||
"primaryRecommendation": "THE single most important call to action in English (max 20 words)",
|
||||
"recommendations": ["2-3 supporting suggestions in English"],
|
||||
"dataQuality": {"coverage": "...", "gaps": ["..."], "confidence": "hoch|mittel|gering"},
|
||||
"disclaimer": "This analysis is no substitute for medical advice. If you have symptoms or unusual values, consult your doctor."
|
||||
}`;
|
||||
|
||||
export function getBaseSystemPrompt(locale: Locale): string {
|
||||
return locale === "en" ? BASE_SYSTEM_PROMPT_EN : BASE_SYSTEM_PROMPT_DE;
|
||||
}
|
||||
|
||||
/** @deprecated Use getBaseSystemPrompt(locale) instead. Kept for backwards compatibility. */
|
||||
export const BASE_SYSTEM_PROMPT = BASE_SYSTEM_PROMPT_DE;
|
||||
@@ -1,9 +1,7 @@
|
||||
import { BASE_SYSTEM_PROMPT } from "./base-system";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { getBaseSystemPrompt } from "./base-system";
|
||||
|
||||
export function getBloodPressureSystemPrompt(): string {
|
||||
return `${BASE_SYSTEM_PROMPT}
|
||||
|
||||
FACHSPEZIFISCH — BLUTDRUCK:
|
||||
const BP_SECTION_DE = `FACHSPEZIFISCH — BLUTDRUCK:
|
||||
- Klassifikation nach ESC/ESH 2023:
|
||||
* Optimal: < 120/80 mmHg
|
||||
* Normal: 120-129/80-84 mmHg
|
||||
@@ -30,9 +28,55 @@ FACHSPEZIFISCH — BLUTDRUCK:
|
||||
- Rate-Pressure Product: Wenn ratePressureProduct.rpp30 > 12.000: "Erhöhter kardialer Sauerstoffbedarf" mit assessment "warning" bewerten.
|
||||
- Saisonale Variation: Falls seasonalVariation vorhanden und delta > 5 mmHg: "Physiologisch normale saisonale Schwankung — kein Grund zur Sorge, ggf. Winter-Dosisanpassung besprechen."
|
||||
- Salz-Signal: Akuter Gewichtsanstieg ≥ 1 kg in 3 Tagen + systolischer Anstieg ≥ 5 mmHg = mögliche erhöhte Natriumzufuhr.`;
|
||||
|
||||
const BP_SECTION_EN = `DOMAIN — BLOOD PRESSURE:
|
||||
- ESC/ESH 2023 classification:
|
||||
* Optimal: < 120/80 mmHg
|
||||
* Normal: 120-129/80-84 mmHg
|
||||
* High-normal: 130-139/85-89 mmHg
|
||||
* Hypertension grade 1: 140-159/90-99 mmHg
|
||||
* Hypertension grade 2: 160-179/100-109 mmHg
|
||||
* Hypertension grade 3: ≥ 180/≥ 110 mmHg
|
||||
* Isolated systolic hypertension: ≥ 140/< 90 mmHg
|
||||
- Targets: For uncomplicated hypertension aim for < 130/80 mmHg (age 18-69), < 140/80 mmHg (age ≥ 70).
|
||||
- Morning surge: Identify a morning systolic rise > 20 mmHg as a risk factor.
|
||||
- Pulse pressure: (systolic - diastolic) > 60 mmHg flagged as a marker of arterial stiffness.
|
||||
- Medication correlation: Correlate antihypertensive adherence with blood-pressure trajectory.
|
||||
- Weight correlation: Compare weight trend with BP trajectory (~1 mmHg drop expected per kg lost).
|
||||
- Mood correlation: Mention only if moodVsSystolicCorrelation is present in the snapshot and |r| > 0.4. Otherwise do not interpret a correlation.
|
||||
- weightVsSystolic correlation: Analyse only if present and |r| > 0.4. Roughly 1 mmHg systolic drop per kg lost.
|
||||
- weightVsDiastolic correlation: Analyse only if present and |r| > 0.4.
|
||||
- Compare avgSys30 and avgSys90 with allTimeAvg to surface long-term trends.
|
||||
- Use historicalComparison.systolic and historicalComparison.diastolic: rate ≥5 mmHg systolic or ≥3 mmHg diastolic deltas clinically.
|
||||
- Morning risk ladder (J-HOP study):
|
||||
* Morning BP 135-144: stroke HR 2.45
|
||||
* Morning BP 145-154: HR 2.80
|
||||
* Morning BP 155-164: HR 3.58
|
||||
* Morning BP ≥ 165: HR 6.52
|
||||
- Rate-pressure product: If ratePressureProduct.rpp30 > 12,000, label "Elevated cardiac oxygen demand" with assessment "warning".
|
||||
- Seasonal variation: If seasonalVariation is present and delta > 5 mmHg: "Physiologically normal seasonal swing — no cause for concern, optionally discuss a winter dose adjustment."
|
||||
- Salt signal: Acute weight gain ≥ 1 kg over 3 days plus systolic rise ≥ 5 mmHg = possible elevated sodium intake.`;
|
||||
|
||||
export function getBloodPressureSystemPrompt(locale: Locale): string {
|
||||
const section = locale === "en" ? BP_SECTION_EN : BP_SECTION_DE;
|
||||
return `${getBaseSystemPrompt(locale)}
|
||||
|
||||
${section}`;
|
||||
}
|
||||
|
||||
export function getBloodPressureUserPrompt(snapshotJson: string, todayKey: string): string {
|
||||
export function getBloodPressureUserPrompt(
|
||||
snapshotJson: string,
|
||||
todayKey: string,
|
||||
locale: Locale,
|
||||
): string {
|
||||
if (locale === "en") {
|
||||
return `Date: ${todayKey} (Europe/Berlin)
|
||||
Analyse the following blood-pressure data with focus on trends, target attainment and medication effectiveness.
|
||||
Account for measurement timing and density when judging confidence.
|
||||
Use the precomputed correlations and historicalComparison for a sound temporal assessment.
|
||||
|
||||
${snapshotJson}`;
|
||||
}
|
||||
return `Datum: ${todayKey} (Europe/Berlin)
|
||||
Analysiere die folgenden Blutdruck-Daten mit Fokus auf Trends, Zielwerterreichung und Medikamentenwirksamkeit.
|
||||
Berücksichtige die Messzeiträume und Datendichte für die Konfidenzeinschätzung.
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { BASE_SYSTEM_PROMPT } from "./base-system";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { getBaseSystemPrompt } from "./base-system";
|
||||
|
||||
export function getBmiSystemPrompt(): string {
|
||||
return `${BASE_SYSTEM_PROMPT}
|
||||
|
||||
FACHSPEZIFISCH — BMI:
|
||||
const BMI_SECTION_DE = `FACHSPEZIFISCH — BMI:
|
||||
- WHO-Klassifikation strikt anwenden:
|
||||
* Untergewicht: < 18.5
|
||||
* Normalgewicht: 18.5 - 24.9
|
||||
@@ -16,9 +14,39 @@ FACHSPEZIFISCH — BMI:
|
||||
- Trend wichtiger als Momentaufnahme: BMI-Entwicklung über 30/90 Tage gewichten.
|
||||
- Limitationen des BMI: Kein Maß für Körperzusammensetzung. Bei Sportlern oder muskulösen Personen eingeschränkt aussagekräftig — darauf hinweisen.
|
||||
- Zusammenhang mit Blutdruck und Puls: Übergewicht als Risikofaktor für Hypertonie einordnen.`;
|
||||
|
||||
const BMI_SECTION_EN = `DOMAIN — BMI:
|
||||
- Apply WHO classification strictly:
|
||||
* Underweight: < 18.5
|
||||
* Normal weight: 18.5 - 24.9
|
||||
* Overweight (pre-obesity): 25.0 - 29.9
|
||||
* Obesity class I: 30.0 - 34.9
|
||||
* Obesity class II: 35.0 - 39.9
|
||||
* Obesity class III: ≥ 40.0
|
||||
- Age adjustment: From age 65+, BMI 22-27 counts as optimal (DEGAM).
|
||||
- Sex context: When sex is known, factor in different risk profiles.
|
||||
- Trend over snapshot: Weight 30- and 90-day BMI trajectories more heavily than a single value.
|
||||
- BMI limitations: Not a measure of body composition. Less informative for athletes or very muscular people — point this out.
|
||||
- Relation to blood pressure and pulse: Treat overweight as a risk factor for hypertension.`;
|
||||
|
||||
export function getBmiSystemPrompt(locale: Locale): string {
|
||||
const section = locale === "en" ? BMI_SECTION_EN : BMI_SECTION_DE;
|
||||
return `${getBaseSystemPrompt(locale)}
|
||||
|
||||
${section}`;
|
||||
}
|
||||
|
||||
export function getBmiUserPrompt(snapshotJson: string, todayKey: string): string {
|
||||
export function getBmiUserPrompt(
|
||||
snapshotJson: string,
|
||||
todayKey: string,
|
||||
locale: Locale,
|
||||
): string {
|
||||
if (locale === "en") {
|
||||
return `Date: ${todayKey} (Europe/Berlin)
|
||||
Analyse the BMI trajectory taking age, sex and weight trend into account. Place the WHO classification.
|
||||
|
||||
${snapshotJson}`;
|
||||
}
|
||||
return `Datum: ${todayKey} (Europe/Berlin)
|
||||
Analysiere den BMI-Verlauf unter Berücksichtigung von Alter, Geschlecht und Gewichtstrend. Ordne die Klassifikation nach WHO ein.
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { BASE_SYSTEM_PROMPT } from "./base-system";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { getBaseSystemPrompt } from "./base-system";
|
||||
|
||||
export function getGeneralStatusSystemPrompt(): string {
|
||||
return `${BASE_SYSTEM_PROMPT}
|
||||
|
||||
FACHSPEZIFISCH — GESAMTBEWERTUNG:
|
||||
const GENERAL_SECTION_DE = `FACHSPEZIFISCH — GESAMTBEWERTUNG:
|
||||
- Integriere alle verfügbaren Vitalparameter zu einem kohärenten Gesamtbild.
|
||||
- Priorisiere nach klinischer Relevanz: Kritische Abweichungen > moderate Trends > stabile Parameter.
|
||||
- Identifiziere die wichtigste einzelne Handlungsempfehlung ("One Thing").
|
||||
@@ -19,9 +17,43 @@ FACHSPEZIFISCH — GESAMTBEWERTUNG:
|
||||
- Rate-Pressure Product: Falls ratePressureProduct vorhanden, als Indikator für kardiale Belastung in die Risikostratifizierung einbeziehen. > 12.000 = erhöhter myokardialer Sauerstoffbedarf.
|
||||
- Body-Composition-Divergenz: Falls bodyCompositionDivergence.flag = true, als Frühzeichen für sarkopenische Adipositas in die Gesamtbewertung aufnehmen.
|
||||
- Saisonale Variation: Falls seasonalVariation vorhanden, saisonale Blutdruckschwankungen kontextualisieren und ggf. beruhigend einordnen.`;
|
||||
|
||||
const GENERAL_SECTION_EN = `DOMAIN — OVERALL ASSESSMENT:
|
||||
- Integrate every available vital parameter into a single coherent picture.
|
||||
- Prioritise by clinical relevance: critical deviations > moderate trends > stable parameters.
|
||||
- Identify the single most important call to action ("One Thing").
|
||||
- Risk stratification: Assess overall cardiovascular risk from the combination of all factors.
|
||||
- Medication adherence: Score total adherence and link it to vital-sign movement.
|
||||
- Mood: If present, include as a contextual factor (do not over-weight).
|
||||
- Highlight positive developments explicitly — motivation is therapeutically relevant.
|
||||
- Mention correlations only when the relevant r-value is present in the snapshot and |r| > 0.4. If a correlation field is missing, do not interpret or invent one. Compare current 7-day values to long-term averages (avg90, allTime).
|
||||
- Use historicalComparison to anchor current changes against the established baseline.
|
||||
- If age and sex are known, apply age- and sex-specific risk assessment.
|
||||
- Sleep: When sleep data is present, fold sleep quality into the overall picture. < 6h/night = risk factor for hypertension and weight gain.
|
||||
- Activity: When activity data is present, evaluate step count (WHO target ≥ 8,000/day). Tie back to pulse and weight trends.
|
||||
- Rate-pressure product: When ratePressureProduct is present, include it as a cardiac-load indicator. > 12,000 = elevated myocardial oxygen demand.
|
||||
- Body-composition divergence: If bodyCompositionDivergence.flag = true, treat as an early sign of sarcopenic obesity.
|
||||
- Seasonal variation: When seasonalVariation is present, contextualise seasonal BP swings and reassure where appropriate.`;
|
||||
|
||||
export function getGeneralStatusSystemPrompt(locale: Locale): string {
|
||||
const section = locale === "en" ? GENERAL_SECTION_EN : GENERAL_SECTION_DE;
|
||||
return `${getBaseSystemPrompt(locale)}
|
||||
|
||||
${section}`;
|
||||
}
|
||||
|
||||
export function getGeneralStatusUserPrompt(snapshotJson: string, todayKey: string): string {
|
||||
export function getGeneralStatusUserPrompt(
|
||||
snapshotJson: string,
|
||||
todayKey: string,
|
||||
locale: Locale,
|
||||
): string {
|
||||
if (locale === "en") {
|
||||
return `Date: ${todayKey} (Europe/Berlin)
|
||||
Produce a comprehensive overall assessment across every available health metric.
|
||||
Focus on the interplay between parameters and identify the single most important call to action.
|
||||
|
||||
${snapshotJson}`;
|
||||
}
|
||||
return `Datum: ${todayKey} (Europe/Berlin)
|
||||
Erstelle eine umfassende Gesamtbewertung aller verfügbaren Gesundheitsdaten.
|
||||
Fokussiere auf das Zusammenspiel der verschiedenen Parameter und identifiziere die wichtigste Handlungsempfehlung.
|
||||
@@ -30,13 +62,35 @@ ${snapshotJson}`;
|
||||
}
|
||||
|
||||
// Backward-compatible export for insights/generate route (Task 8 uses this)
|
||||
export function buildGeneratePrompts(featuresJson: string, privacyMode: string): { systemPrompt: string; userPrompt: string } {
|
||||
const modeLabel = privacyMode === "raw"
|
||||
? "Aggregierte Daten + Rohdaten (gesamter verfügbarer Zeitraum, anonymisiert)"
|
||||
: "Nur aggregierte Daten (keine exakten Zeitstempel oder Rohwerte)";
|
||||
export function buildGeneratePrompts(
|
||||
featuresJson: string,
|
||||
privacyMode: string,
|
||||
locale: Locale,
|
||||
): { systemPrompt: string; userPrompt: string } {
|
||||
if (locale === "en") {
|
||||
const modeLabel =
|
||||
privacyMode === "raw"
|
||||
? "Aggregated data + raw values (entire available period, anonymised)"
|
||||
: "Aggregated data only (no exact timestamps or raw values)";
|
||||
|
||||
return {
|
||||
systemPrompt: getGeneralStatusSystemPrompt(locale),
|
||||
userPrompt: `Analyse the following health data.
|
||||
Data mode: ${modeLabel}
|
||||
Use each metric's coverage object for measurement frequency and time spans.
|
||||
Use correlations for cross-metric analysis and historicalComparison for temporal context.
|
||||
|
||||
${featuresJson}`,
|
||||
};
|
||||
}
|
||||
|
||||
const modeLabel =
|
||||
privacyMode === "raw"
|
||||
? "Aggregierte Daten + Rohdaten (gesamter verfügbarer Zeitraum, anonymisiert)"
|
||||
: "Nur aggregierte Daten (keine exakten Zeitstempel oder Rohwerte)";
|
||||
|
||||
return {
|
||||
systemPrompt: getGeneralStatusSystemPrompt(),
|
||||
systemPrompt: getGeneralStatusSystemPrompt(locale),
|
||||
userPrompt: `Analysiere die folgenden Gesundheitsdaten.
|
||||
Datenmodus: ${modeLabel}
|
||||
Beachte die coverage-Objekte jeder Metrik für Informationen zu Messhäufigkeit und Zeiträumen.
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { BASE_SYSTEM_PROMPT } from "./base-system";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { getBaseSystemPrompt } from "./base-system";
|
||||
|
||||
export function getMedicationComplianceSystemPrompt(): string {
|
||||
return `${BASE_SYSTEM_PROMPT}
|
||||
|
||||
FACHSPEZIFISCH — MEDIKAMENTEN-ADHÄRENZ:
|
||||
const MEDCO_SECTION_DE = `FACHSPEZIFISCH — MEDIKAMENTEN-ADHÄRENZ:
|
||||
- Einnahmetreue-Bewertung:
|
||||
* ≥ 90%: Ausgezeichnete Adhärenz
|
||||
* 80-89%: Gute Adhärenz
|
||||
@@ -22,9 +20,46 @@ FACHSPEZIFISCH — MEDIKAMENTEN-ADHÄRENZ:
|
||||
- Falls Stimmungsdaten verfügbar und Korrelation |r| > 0.4: Prüfe ob niedrige Adhärenz mit schlechterer Stimmung korreliert.
|
||||
- Chronotherapie-Hinweis: Falls ein Blutdruck-Medikament vorhanden ist UND Compliance > 90% ABER BP nicht im Zielbereich: "Einnahme-Zeitpunkt mit dem Arzt besprechen — abendliche Einnahme kann bei einigen Patienten die nächtliche Blutdruckkontrolle verbessern."
|
||||
- Mood-Adhärenz-Risiko: Falls moodAdherenceRisk = true: "Deine Stimmung war in den letzten Tagen niedrig. Erfahrungsgemäß kann das die Einnahmetreue in den kommenden Tagen beeinflussen. Tipp: Lege die Medikamente abends schon bereit."`;
|
||||
|
||||
const MEDCO_SECTION_EN = `DOMAIN — MEDICATION ADHERENCE:
|
||||
- Adherence rating:
|
||||
* ≥ 90%: Excellent adherence
|
||||
* 80-89%: Good adherence
|
||||
* 70-79%: Moderate adherence — therapeutic effect possibly reduced
|
||||
* < 70%: Insufficient adherence — likely subtherapeutic effect
|
||||
- Pattern analysis: Score morning vs. evening doses separately (evening doses are missed more often).
|
||||
- Streak tracking: Identify and highlight the longest uninterrupted intake streak.
|
||||
- Weekend effect: Compare weekend vs. weekday adherence.
|
||||
- Effectiveness correlation: Draw a direct link between adherence and vital-sign movement.
|
||||
* Example: "In weeks with > 90% adherence the mean systolic BP was 5 mmHg lower."
|
||||
- Missed doses: Differentiate consequences (antihypertensive rebound vs. statins = less time-critical).
|
||||
- Motivational interviewing: Prefer positive framing, acknowledge progress.
|
||||
- Compare periods of high adherence (>90%) with the corresponding vitals. Be concrete: "In weeks with >90% adherence systolic BP was X mmHg lower."
|
||||
- Mention correlations only if the r-value is present in the snapshot and |r| > 0.4. If the field is missing, do not interpret or invent a correlation.
|
||||
- Use historicalComparison to quantify how adherence affects blood pressure and pulse, where data is available.
|
||||
- If mood data is available and the correlation |r| > 0.4: Check whether low adherence aligns with worse mood.
|
||||
- Chronotherapy note: If a BP medication exists AND compliance > 90% BUT BP is off target: "Discuss timing with your doctor — for some patients, evening dosing improves nocturnal BP control."
|
||||
- Mood-adherence risk: If moodAdherenceRisk = true: "Your mood has been low recently. Experience shows this can affect adherence over the next few days. Tip: prepare your medication the evening before."`;
|
||||
|
||||
export function getMedicationComplianceSystemPrompt(locale: Locale): string {
|
||||
const section = locale === "en" ? MEDCO_SECTION_EN : MEDCO_SECTION_DE;
|
||||
return `${getBaseSystemPrompt(locale)}
|
||||
|
||||
${section}`;
|
||||
}
|
||||
|
||||
export function getMedicationComplianceUserPrompt(snapshotJson: string, todayKey: string): string {
|
||||
export function getMedicationComplianceUserPrompt(
|
||||
snapshotJson: string,
|
||||
todayKey: string,
|
||||
locale: Locale,
|
||||
): string {
|
||||
if (locale === "en") {
|
||||
return `Date: ${todayKey} (Europe/Berlin)
|
||||
Analyse medication adherence with focus on patterns, effectiveness correlation and concrete suggestions for improvement.
|
||||
Use the correlation data and historical comparison to back up the link between adherence and vital signs.
|
||||
|
||||
${snapshotJson}`;
|
||||
}
|
||||
return `Datum: ${todayKey} (Europe/Berlin)
|
||||
Analysiere die Medikamenten-Einnahmetreue mit Fokus auf Muster, Wirksamkeitskorrelation und konkrete Verbesserungsvorschläge.
|
||||
Nutze die Korrelationsdaten und den historischen Vergleich um den Zusammenhang zwischen Adhärenz und Vitalwerten zu belegen.
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { BASE_SYSTEM_PROMPT } from "./base-system";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { getBaseSystemPrompt } from "./base-system";
|
||||
|
||||
export function getMoodSystemPrompt(): string {
|
||||
return `${BASE_SYSTEM_PROMPT}
|
||||
|
||||
FACHSPEZIFISCH -- STIMMUNG/WOHLBEFINDEN:
|
||||
const MOOD_SECTION_DE = `FACHSPEZIFISCH -- STIMMUNG/WOHLBEFINDEN:
|
||||
- Stimmungsskala: 1 (sehr schlecht) bis 5 (sehr gut), Tagesmittelwerte.
|
||||
- Trend-Stabilitat: Schwankungen < 0.5 Punkte uber 7 Tage sind normal.
|
||||
- Anhaltende Phasen: Mehr als 3 Tage unter 2.5 oder uber 4.5 sind auffallig.
|
||||
@@ -11,9 +9,34 @@ FACHSPEZIFISCH -- STIMMUNG/WOHLBEFINDEN:
|
||||
- Tags: Falls Stimmungs-Tags vorhanden sind, prufe ob bestimmte Tags mit Stimmungslevels korrelieren.
|
||||
- Cross-Metrik: Korrelationen zu Gewicht, Blutdruck oder Puls nur erwähnen wenn der jeweilige r-Wert im Snapshot vorhanden und |r| > 0.4 ist. Falls das Feld nicht im Snapshot vorhanden ist, keine Korrelation interpretieren oder erfinden.
|
||||
- Erzwinge keine Querverweise wenn kein klares Muster erkennbar ist.`;
|
||||
|
||||
const MOOD_SECTION_EN = `DOMAIN — MOOD / WELL-BEING:
|
||||
- Mood scale: 1 (very bad) to 5 (very good), daily means.
|
||||
- Trend stability: Swings < 0.5 points over 7 days are normal.
|
||||
- Persistent phases: More than 3 days below 2.5 or above 4.5 are notable.
|
||||
- Associations: Mood often correlates with sleep, activity, blood pressure and medication compliance.
|
||||
- Tags: If mood tags are present, check whether specific tags align with mood levels.
|
||||
- Cross-metric: Mention correlations to weight, blood pressure or pulse only when the relevant r-value is present in the snapshot and |r| > 0.4. If the field is missing, do not interpret or invent a correlation.
|
||||
- Do not force cross-links when no clear pattern is visible.`;
|
||||
|
||||
export function getMoodSystemPrompt(locale: Locale): string {
|
||||
const section = locale === "en" ? MOOD_SECTION_EN : MOOD_SECTION_DE;
|
||||
return `${getBaseSystemPrompt(locale)}
|
||||
|
||||
${section}`;
|
||||
}
|
||||
|
||||
export function getMoodUserPrompt(snapshotJson: string, todayKey: string): string {
|
||||
export function getMoodUserPrompt(
|
||||
snapshotJson: string,
|
||||
todayKey: string,
|
||||
locale: Locale,
|
||||
): string {
|
||||
if (locale === "en") {
|
||||
return `Date: ${todayKey} (Europe/Berlin)
|
||||
Analyse the mood data with focus on trend, stability and links to other health metrics.
|
||||
|
||||
${snapshotJson}`;
|
||||
}
|
||||
return `Datum: ${todayKey} (Europe/Berlin)
|
||||
Analysiere die Stimmungsdaten mit Fokus auf Trend, Stabilitat und Zusammenhange mit anderen Gesundheitsmetriken.
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { BASE_SYSTEM_PROMPT } from "./base-system";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { getBaseSystemPrompt } from "./base-system";
|
||||
|
||||
export function getPulseSystemPrompt(): string {
|
||||
return `${BASE_SYSTEM_PROMPT}
|
||||
|
||||
FACHSPEZIFISCH — PULS/HERZFREQUENZ:
|
||||
const PULSE_SECTION_DE = `FACHSPEZIFISCH — PULS/HERZFREQUENZ:
|
||||
- Ruhepuls-Zonen:
|
||||
* Bradykardie: < 50 bpm (bei Sportlern normal, sonst abklärungsbedürftig)
|
||||
* Athletisch: 50-60 bpm
|
||||
@@ -25,9 +23,49 @@ FACHSPEZIFISCH — PULS/HERZFREQUENZ:
|
||||
* slope30 > +0.2 bpm/Tag MIT moodVsPulse r > 0.4: Stressbedingte Pulserhöhung
|
||||
- Risikoband 80-100 bpm: Technisch "normal", aber erhöhtes CV-Risiko (+6-9% Mortalität pro 10 bpm, Meta-Analyse).
|
||||
- Resting HR > 90 bpm: Unabhängiger Mortalitätsprädiktor — als eigenes Finding mit assessment "warning" erfassen.`;
|
||||
|
||||
const PULSE_SECTION_EN = `DOMAIN — PULSE / HEART RATE:
|
||||
- Resting-pulse zones:
|
||||
* Bradycardia: < 50 bpm (normal in athletes, otherwise needs evaluation)
|
||||
* Athletic: 50-60 bpm
|
||||
* Normal: 60-100 bpm
|
||||
* Tachycardia: > 100 bpm (needs evaluation)
|
||||
- Trend stability: Resting-pulse variability < 5 bpm over 7 days is normal.
|
||||
- Stress indicators: A rising resting heart rate can point to stress, sleep loss or overtraining.
|
||||
- Recovery: A falling resting pulse over weeks suggests improved cardiovascular fitness.
|
||||
- Medication influence: Beta-blockers lower heart rate — check the compliance correlation.
|
||||
- BP correlation: Pulse × systolic = rate-pressure product as a cardiac-load indicator.
|
||||
- Mood correlation: Chronic stress can elevate resting pulse. Mention only if moodVsPulse correlation is present and |r| > 0.4.
|
||||
- moodVsPulse correlation: Analyse only if present and |r| > 0.4. Otherwise do not interpret.
|
||||
- pulseVsSystolic correlation: Analyse only if present and |r| > 0.4. Score haemodynamic coupling.
|
||||
- Compare avg7 vs avg30 vs avg90 vs allTimeAvg to detect short-term deviations from the long-term baseline.
|
||||
- Use historicalComparison.pulse: Score deltas ≥ 5 bpm against the baseline clinically.
|
||||
- Fitness interpretation:
|
||||
* slope30 < -0.2 bpm/day (≈ -6 bpm/month): positive fitness signal, cardiovascular improvement
|
||||
* slope30 > +0.2 bpm/day (≈ +6 bpm/month) WITHOUT mood drop: possible deconditioning
|
||||
* slope30 > +0.2 bpm/day WITH moodVsPulse r > 0.4: stress-driven pulse rise
|
||||
- Risk band 80-100 bpm: Technically "normal", but elevated CV risk (+6-9% mortality per 10 bpm, meta-analysis).
|
||||
- Resting HR > 90 bpm: Independent mortality predictor — capture as its own finding with assessment "warning".`;
|
||||
|
||||
export function getPulseSystemPrompt(locale: Locale): string {
|
||||
const section = locale === "en" ? PULSE_SECTION_EN : PULSE_SECTION_DE;
|
||||
return `${getBaseSystemPrompt(locale)}
|
||||
|
||||
${section}`;
|
||||
}
|
||||
|
||||
export function getPulseUserPrompt(snapshotJson: string, todayKey: string): string {
|
||||
export function getPulseUserPrompt(
|
||||
snapshotJson: string,
|
||||
todayKey: string,
|
||||
locale: Locale,
|
||||
): string {
|
||||
if (locale === "en") {
|
||||
return `Date: ${todayKey} (Europe/Berlin)
|
||||
Analyse the pulse / heart-rate data with focus on resting-pulse trend, variability and links to medication and mood.
|
||||
Use the precomputed correlations and historical comparison for a sound analysis.
|
||||
|
||||
${snapshotJson}`;
|
||||
}
|
||||
return `Datum: ${todayKey} (Europe/Berlin)
|
||||
Analysiere die Puls-/Herzfrequenzdaten mit Fokus auf Ruhepulstrend, Variabilität und Zusammenhänge mit Medikation und Stimmung.
|
||||
Nutze die vorberechneten Korrelationen und den historischen Vergleich für eine fundierte Analyse.
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { BASE_SYSTEM_PROMPT } from "./base-system";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { getBaseSystemPrompt } from "./base-system";
|
||||
|
||||
export function getWeightSystemPrompt(): string {
|
||||
return `${BASE_SYSTEM_PROMPT}
|
||||
|
||||
FACHSPEZIFISCH — GEWICHT:
|
||||
const WEIGHT_SECTION_DE = `FACHSPEZIFISCH — GEWICHT:
|
||||
- BMI-Kontext immer mitbewerten (Größe aus Profil, falls verfügbar).
|
||||
- WHO BMI-Klassifikation: Untergewicht < 18.5, Normalgewicht 18.5-24.9, Übergewicht 25.0-29.9, Adipositas I 30.0-34.9, II 35.0-39.9, III ≥ 40.0.
|
||||
- Trend-Analyse: 7-Tage, 30-Tage und 90-Tage gleitende Durchschnitte vergleichen.
|
||||
@@ -22,9 +20,46 @@ FACHSPEZIFISCH — GEWICHT:
|
||||
- Zu schneller Verlust: > 1 kg/Woche über > 2 Wochen = Risiko für Muskelmasseverlust, nicht nachhaltig
|
||||
- Body-Composition-Divergenz: Falls bodyCompositionDivergence.flag = true: "Gewicht stabil, aber Körperfettanteil steigend — möglicher Muskelmasseverlust. Krafttraining empfehlen."
|
||||
- Schlaf-Gewicht-Verbindung: Falls sleep.avg7 < 6h UND weight.slope30 > 0: "Schlafmangel stört metabolische Regulation und kann Gewichtszunahme begünstigen."`;
|
||||
|
||||
const WEIGHT_SECTION_EN = `DOMAIN — WEIGHT:
|
||||
- Always include BMI context (height from profile, if available).
|
||||
- WHO BMI classification: Underweight < 18.5, Normal 18.5-24.9, Overweight 25.0-29.9, Obesity I 30.0-34.9, II 35.0-39.9, III ≥ 40.0.
|
||||
- Trend analysis: Compare 7-day, 30-day and 90-day moving averages.
|
||||
- Plateau detection: Flag a weight change < ±0.5 kg over > 14 days as a plateau.
|
||||
- Realistic target projection: At most 0.5-1.0 kg/week is sustainable weight loss (DGE recommendation).
|
||||
- Weight-BP correlation: Mention only if weightVsSystolic is present in the snapshot and |r| > 0.4. Roughly 1 mmHg systolic drop per kg lost.
|
||||
- Medication influence: Identify weight-relevant medications (e.g. beta-blockers, cortisone).
|
||||
- Time-of-day swings: Differentiate morning vs. evening readings (1-2 kg normal).
|
||||
- Compare the current 7-day mean (historicalComparison.weight.current7dAvg) with the 30-day baseline (previous30dAvg). For deltas > 2 kg: assess clinically and discuss likely causes.
|
||||
- Plateau definition: < 0.5 kg change over > 14 days (check whether avg7 ≈ avg30 ≈ avg90).
|
||||
- Use allTimeMin/allTimeMax/allTimeAvg to anchor the current value historically.
|
||||
- Weight milestones (clinically significant):
|
||||
* 5% loss from starting weight (allTimeMax): metabolic benefit, CV risk reduction
|
||||
* 10% loss: substantial clinical benefit
|
||||
* If reached: acknowledge explicitly and give clinical context
|
||||
- Too rapid loss: > 1 kg/week sustained > 2 weeks = risk of muscle-mass loss, not sustainable
|
||||
- Body-composition divergence: If bodyCompositionDivergence.flag = true: "Weight stable but body-fat percentage rising — possible muscle-mass loss. Recommend strength training."
|
||||
- Sleep-weight link: If sleep.avg7 < 6h AND weight.slope30 > 0: "Sleep loss disrupts metabolic regulation and can favour weight gain."`;
|
||||
|
||||
export function getWeightSystemPrompt(locale: Locale): string {
|
||||
const section = locale === "en" ? WEIGHT_SECTION_EN : WEIGHT_SECTION_DE;
|
||||
return `${getBaseSystemPrompt(locale)}
|
||||
|
||||
${section}`;
|
||||
}
|
||||
|
||||
export function getWeightUserPrompt(snapshotJson: string, todayKey: string): string {
|
||||
export function getWeightUserPrompt(
|
||||
snapshotJson: string,
|
||||
todayKey: string,
|
||||
locale: Locale,
|
||||
): string {
|
||||
if (locale === "en") {
|
||||
return `Date: ${todayKey} (Europe/Berlin)
|
||||
Analyse the weight trajectory with focus on trends, BMI classification and links to other vital signs.
|
||||
Use the temporal layers (avg7, avg30, avg90, allTime) and the historical comparison for a nuanced assessment.
|
||||
|
||||
${snapshotJson}`;
|
||||
}
|
||||
return `Datum: ${todayKey} (Europe/Berlin)
|
||||
Analysiere die Gewichtsentwicklung mit Fokus auf Trends, BMI-Klassifikation und Zusammenhang mit anderen Vitalwerten.
|
||||
Nutze die temporalen Schichten (avg7, avg30, avg90, allTime) und den historischen Vergleich für eine differenzierte Bewertung.
|
||||
|
||||
+171
-77
@@ -3,6 +3,8 @@ import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { CodexClient } from "./codex-client";
|
||||
import { OpenAIClient } from "./openai-client";
|
||||
import { AnthropicClient } from "./anthropic-client";
|
||||
import { LocalOpenAICompatibleClient } from "./local-client";
|
||||
import { refreshAccessToken, encryptTokens, decryptTokens } from "./codex-oauth";
|
||||
|
||||
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
|
||||
@@ -12,11 +14,62 @@ class NoProvider implements AIProvider {
|
||||
|
||||
async generateCompletion(_params: CompletionParams): Promise<CompletionResult> {
|
||||
throw new Error(
|
||||
"No AI provider configured. Connect Codex or set an admin API key.",
|
||||
"No AI provider configured. Connect ChatGPT or set an API key in settings.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
type UserAIRow = {
|
||||
aiProvider: string | null;
|
||||
aiModel: string | null;
|
||||
aiBaseUrl: string | null;
|
||||
aiAnthropicKeyEncrypted: string | null;
|
||||
aiLocalKeyEncrypted: string | null;
|
||||
// legacy admin-key fallback (OpenAI uses adminAiKeyEncrypted via app_settings)
|
||||
// We store user-level OpenAI key in aiAnthropicKeyEncrypted? No — for OPENAI
|
||||
// selection at user-level we read app-settings admin key. The user override
|
||||
// for OpenAI is intentionally minimal: provider+model+baseUrl. Personal keys
|
||||
// are scoped to Anthropic/Local for now (matches the migration columns).
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a provider from a user-level config row. Returns null if the row does
|
||||
* not select a usable per-user provider (caller falls back to admin/codex).
|
||||
*/
|
||||
function buildUserProvider(row: UserAIRow): AIProvider | null {
|
||||
const choice = row.aiProvider?.toUpperCase();
|
||||
if (!choice) return null;
|
||||
|
||||
switch (choice) {
|
||||
case "ANTHROPIC": {
|
||||
if (!row.aiAnthropicKeyEncrypted) return null;
|
||||
return new AnthropicClient({
|
||||
apiKey: decrypt(row.aiAnthropicKeyEncrypted),
|
||||
model: row.aiModel ?? "claude-3-5-sonnet-latest",
|
||||
baseUrl: row.aiBaseUrl ?? undefined,
|
||||
});
|
||||
}
|
||||
case "LOCAL": {
|
||||
if (!row.aiBaseUrl) return null;
|
||||
return new LocalOpenAICompatibleClient({
|
||||
apiKey: row.aiLocalKeyEncrypted ? decrypt(row.aiLocalKeyEncrypted) : null,
|
||||
model: row.aiModel ?? "local-model",
|
||||
baseUrl: row.aiBaseUrl,
|
||||
});
|
||||
}
|
||||
case "OPENAI":
|
||||
// User selected OPENAI but personal keys live in admin app_settings;
|
||||
// signal "use admin OpenAI" by returning null and letting caller fall
|
||||
// through to resolveAdminProvider().
|
||||
return null;
|
||||
case "CHATGPT_OAUTH":
|
||||
// Caller handles Codex OAuth via the dedicated branch; signal here.
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAdminProvider(): Promise<AIProvider> {
|
||||
const settings = await prisma.appSettings.findUnique({ where: { id: "singleton" } });
|
||||
|
||||
@@ -31,7 +84,7 @@ async function resolveAdminProvider(): Promise<AIProvider> {
|
||||
return new NoProvider();
|
||||
}
|
||||
|
||||
export async function resolveProvider(userId: string): Promise<AIProvider> {
|
||||
async function resolveCodexProvider(userId: string): Promise<AIProvider | null> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
@@ -43,83 +96,124 @@ export async function resolveProvider(userId: string): Promise<AIProvider> {
|
||||
});
|
||||
|
||||
if (
|
||||
user?.codexConnectionStatus === "connected" &&
|
||||
user.codexAccessTokenEncrypted &&
|
||||
user.codexRefreshTokenEncrypted
|
||||
user?.codexConnectionStatus !== "connected" ||
|
||||
!user.codexAccessTokenEncrypted ||
|
||||
!user.codexRefreshTokenEncrypted
|
||||
) {
|
||||
const { accessToken, refreshToken } = decryptTokens({
|
||||
accessEncrypted: user.codexAccessTokenEncrypted,
|
||||
refreshEncrypted: user.codexRefreshTokenEncrypted,
|
||||
});
|
||||
|
||||
let currentAccessToken = accessToken;
|
||||
|
||||
// Proactive refresh if token expires within 5 minutes
|
||||
const expiresAt = user.codexTokenExpiresAt?.getTime() ?? 0;
|
||||
if (expiresAt < Date.now() + TOKEN_REFRESH_BUFFER_MS) {
|
||||
try {
|
||||
const newTokens = await refreshAccessToken(refreshToken);
|
||||
currentAccessToken = newTokens.access_token;
|
||||
|
||||
const encrypted = encryptTokens({
|
||||
accessToken: newTokens.access_token,
|
||||
refreshToken: newTokens.refresh_token,
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
codexAccessTokenEncrypted: encrypted.accessEncrypted,
|
||||
codexRefreshTokenEncrypted: encrypted.refreshEncrypted,
|
||||
codexTokenExpiresAt: new Date(
|
||||
Date.now() + newTokens.expires_in * 1000,
|
||||
),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// If proactive refresh fails, still try with current token
|
||||
// CodexClient will retry via onTokenRefresh on 401
|
||||
}
|
||||
}
|
||||
|
||||
return new CodexClient({
|
||||
accessToken: currentAccessToken,
|
||||
onTokenRefresh: async () => {
|
||||
const freshUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
codexRefreshTokenEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!freshUser?.codexRefreshTokenEncrypted) {
|
||||
throw new Error("No refresh token available");
|
||||
}
|
||||
|
||||
const currentRefreshToken = decrypt(freshUser.codexRefreshTokenEncrypted);
|
||||
|
||||
const newTokens = await refreshAccessToken(currentRefreshToken);
|
||||
const encrypted = encryptTokens({
|
||||
accessToken: newTokens.access_token,
|
||||
refreshToken: newTokens.refresh_token,
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
codexAccessTokenEncrypted: encrypted.accessEncrypted,
|
||||
codexRefreshTokenEncrypted: encrypted.refreshEncrypted,
|
||||
codexTokenExpiresAt: new Date(
|
||||
Date.now() + newTokens.expires_in * 1000,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
return newTokens.access_token;
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Codex not connected (disconnected, expired, or error) — fall back to admin key
|
||||
const { accessToken, refreshToken } = decryptTokens({
|
||||
accessEncrypted: user.codexAccessTokenEncrypted,
|
||||
refreshEncrypted: user.codexRefreshTokenEncrypted,
|
||||
});
|
||||
|
||||
let currentAccessToken = accessToken;
|
||||
|
||||
// Proactive refresh if token expires within 5 minutes
|
||||
const expiresAt = user.codexTokenExpiresAt?.getTime() ?? 0;
|
||||
if (expiresAt < Date.now() + TOKEN_REFRESH_BUFFER_MS) {
|
||||
try {
|
||||
const newTokens = await refreshAccessToken(refreshToken);
|
||||
currentAccessToken = newTokens.access_token;
|
||||
|
||||
const encrypted = encryptTokens({
|
||||
accessToken: newTokens.access_token,
|
||||
refreshToken: newTokens.refresh_token,
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
codexAccessTokenEncrypted: encrypted.accessEncrypted,
|
||||
codexRefreshTokenEncrypted: encrypted.refreshEncrypted,
|
||||
codexTokenExpiresAt: new Date(
|
||||
Date.now() + newTokens.expires_in * 1000,
|
||||
),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// If proactive refresh fails, still try with current token
|
||||
// CodexClient will retry via onTokenRefresh on 401
|
||||
}
|
||||
}
|
||||
|
||||
return new CodexClient({
|
||||
accessToken: currentAccessToken,
|
||||
onTokenRefresh: async () => {
|
||||
const freshUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
codexRefreshTokenEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!freshUser?.codexRefreshTokenEncrypted) {
|
||||
throw new Error("No refresh token available");
|
||||
}
|
||||
|
||||
const currentRefreshToken = decrypt(freshUser.codexRefreshTokenEncrypted);
|
||||
|
||||
const newTokens = await refreshAccessToken(currentRefreshToken);
|
||||
const encrypted = encryptTokens({
|
||||
accessToken: newTokens.access_token,
|
||||
refreshToken: newTokens.refresh_token,
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
codexAccessTokenEncrypted: encrypted.accessEncrypted,
|
||||
codexRefreshTokenEncrypted: encrypted.refreshEncrypted,
|
||||
codexTokenExpiresAt: new Date(
|
||||
Date.now() + newTokens.expires_in * 1000,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
return newTokens.access_token;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the AI provider for a given user.
|
||||
*
|
||||
* Priority:
|
||||
* 1. User selected ANTHROPIC / LOCAL with valid creds → that provider.
|
||||
* 2. User selected CHATGPT_OAUTH (or no explicit choice but Codex tokens
|
||||
* are connected) → Codex.
|
||||
* 3. User selected OPENAI (or no creds for the chosen provider) → admin
|
||||
* OpenAI key from app_settings.
|
||||
* 4. Nothing configured → NoProvider().
|
||||
*/
|
||||
export async function resolveProvider(userId: string): Promise<AIProvider> {
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
aiProvider: true,
|
||||
aiModel: true,
|
||||
aiBaseUrl: true,
|
||||
aiAnthropicKeyEncrypted: true,
|
||||
aiLocalKeyEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 1. Per-user Anthropic / Local
|
||||
if (userRow) {
|
||||
const userProvider = buildUserProvider(userRow);
|
||||
if (userProvider) return userProvider;
|
||||
}
|
||||
|
||||
// 2. Codex OAuth (either explicitly selected or implicit fallback)
|
||||
const explicitChoice = userRow?.aiProvider?.toUpperCase();
|
||||
const tryCodex =
|
||||
explicitChoice === "CHATGPT_OAUTH" || !explicitChoice;
|
||||
if (tryCodex) {
|
||||
const codex = await resolveCodexProvider(userId);
|
||||
if (codex) return codex;
|
||||
}
|
||||
|
||||
// 3. Admin OpenAI key (also acts as fallback for user-OPENAI selection)
|
||||
return resolveAdminProvider();
|
||||
}
|
||||
+1
-1
@@ -39,7 +39,7 @@ export type InsightFinding = z.infer<typeof insightFindingSchema>;
|
||||
|
||||
// ─── Provider Types ────────────────────────────────────────
|
||||
|
||||
export type ProviderType = "codex" | "admin-key" | "none";
|
||||
export type ProviderType = "codex" | "admin-key" | "anthropic" | "local" | "none";
|
||||
|
||||
export interface AIProvider {
|
||||
type: ProviderType;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
getAllEffectiveRanges,
|
||||
METRIC_BOUNDS,
|
||||
type UserProfileForRange,
|
||||
type ThresholdOverridesJson,
|
||||
} from "../effective-range";
|
||||
|
||||
const baseProfile: UserProfileForRange = {
|
||||
heightCm: 180,
|
||||
dateOfBirth: new Date("1985-01-01"),
|
||||
gender: "MALE",
|
||||
};
|
||||
|
||||
describe("getEffectiveRange", () => {
|
||||
it("returns computed default when no override is set", () => {
|
||||
const result = getEffectiveRange("WEIGHT", baseProfile, null);
|
||||
expect(result.isOverride).toBe(false);
|
||||
expect(result.range).not.toBeNull();
|
||||
// BMI 18.5..24.9 at 1.80m ≈ 59.94..80.68
|
||||
expect(result.range!.greenMin).toBeCloseTo(59.94, 1);
|
||||
expect(result.range!.greenMax).toBeCloseTo(80.68, 1);
|
||||
});
|
||||
|
||||
it("returns null range when a prerequisite profile field is missing", () => {
|
||||
const result = getEffectiveRange(
|
||||
"WEIGHT",
|
||||
{ heightCm: null, dateOfBirth: null, gender: null },
|
||||
null,
|
||||
);
|
||||
expect(result.range).toBeNull();
|
||||
expect(result.default).toBeNull();
|
||||
});
|
||||
|
||||
it("uses override when present", () => {
|
||||
const overrides: ThresholdOverridesJson = {
|
||||
WEIGHT: { min: 70, max: 85 },
|
||||
};
|
||||
const result = getEffectiveRange("WEIGHT", baseProfile, overrides);
|
||||
expect(result.isOverride).toBe(true);
|
||||
expect(result.range!.greenMin).toBe(70);
|
||||
expect(result.range!.greenMax).toBe(85);
|
||||
// Orange wings should be non-degenerate
|
||||
expect(result.range!.orangeMin).toBeLessThan(70);
|
||||
expect(result.range!.orangeMax).toBeGreaterThan(85);
|
||||
});
|
||||
|
||||
it("returns the pristine default alongside the override", () => {
|
||||
const overrides: ThresholdOverridesJson = {
|
||||
WEIGHT: { min: 70, max: 85 },
|
||||
};
|
||||
const result = getEffectiveRange("WEIGHT", baseProfile, overrides);
|
||||
expect(result.default).not.toBeNull();
|
||||
expect(result.default!.greenMin).toBeCloseTo(59.94, 1);
|
||||
});
|
||||
|
||||
it("has bounds for every supported metric", () => {
|
||||
for (const metric of Object.keys(METRIC_BOUNDS) as Array<
|
||||
keyof typeof METRIC_BOUNDS
|
||||
>) {
|
||||
const result = getEffectiveRange(metric, baseProfile, null);
|
||||
expect(result.bounds).toEqual(METRIC_BOUNDS[metric]);
|
||||
}
|
||||
});
|
||||
|
||||
it("has sensible blood glucose defaults in fasting context", () => {
|
||||
const result = getEffectiveRange(
|
||||
"BLOOD_GLUCOSE_FASTING",
|
||||
baseProfile,
|
||||
null,
|
||||
);
|
||||
expect(result.range!.greenMin).toBe(70);
|
||||
expect(result.range!.greenMax).toBe(99);
|
||||
expect(result.range!.orangeMax).toBe(125); // pre-diabetes upper
|
||||
});
|
||||
|
||||
it("has different defaults for postprandial vs fasting", () => {
|
||||
const fasting = getEffectiveRange("BLOOD_GLUCOSE_FASTING", baseProfile, null);
|
||||
const post = getEffectiveRange("BLOOD_GLUCOSE_POSTPRANDIAL", baseProfile, null);
|
||||
expect(post.range!.greenMax).toBeGreaterThan(fasting.range!.greenMax);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllEffectiveRanges", () => {
|
||||
it("returns an entry for every metric", () => {
|
||||
const ranges = getAllEffectiveRanges(baseProfile, null);
|
||||
const keys = Object.keys(ranges);
|
||||
expect(keys).toContain("WEIGHT");
|
||||
expect(keys).toContain("BLOOD_GLUCOSE_FASTING");
|
||||
expect(keys.length).toBeGreaterThanOrEqual(11);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Effective-range resolver — the single entry point for "what's the target
|
||||
* range for THIS user on THIS metric, right now?"
|
||||
*
|
||||
* Layering:
|
||||
* 1. Evidence-based default computed from the user profile (age/height/
|
||||
* gender/gender-neutral) via the existing analytics helpers.
|
||||
* 2. Optional user override stored in User.thresholdsJson.
|
||||
* 3. For blood glucose the range also depends on the measurement context
|
||||
* (fasting / postprandial / random / bedtime).
|
||||
*
|
||||
* Every consumer — /targets page, insight status prompts, doctor-report PDF,
|
||||
* chart value-bands — should call `getEffectiveRange()` instead of the raw
|
||||
* default computers directly. That way a user-override affects every surface
|
||||
* consistently.
|
||||
*/
|
||||
|
||||
import { getBpTargets } from "./bp-targets";
|
||||
import {
|
||||
getAgeFromDateOfBirth,
|
||||
getPersonalizedPulseTarget,
|
||||
} from "./pulse-targets";
|
||||
import {
|
||||
buildWeightRangeFromHeight,
|
||||
getBodyFatTargetRange,
|
||||
type TrafficRange,
|
||||
} from "./value-bands";
|
||||
|
||||
export type ThresholdMetric =
|
||||
| "WEIGHT"
|
||||
| "BLOOD_PRESSURE_SYS"
|
||||
| "BLOOD_PRESSURE_DIA"
|
||||
| "PULSE"
|
||||
| "BODY_FAT"
|
||||
| "SLEEP_DURATION"
|
||||
| "ACTIVITY_STEPS"
|
||||
| "BLOOD_GLUCOSE_FASTING"
|
||||
| "BLOOD_GLUCOSE_POSTPRANDIAL"
|
||||
| "BLOOD_GLUCOSE_RANDOM"
|
||||
| "BLOOD_GLUCOSE_BEDTIME";
|
||||
|
||||
export interface ThresholdOverride {
|
||||
min: number;
|
||||
max: number;
|
||||
/** Set true when the value was entered by the user (not the computed default). */
|
||||
isOverride?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape persisted in User.thresholdsJson. Any metric missing = use default.
|
||||
*/
|
||||
export type ThresholdOverridesJson = Partial<
|
||||
Record<ThresholdMetric, { min: number; max: number }>
|
||||
>;
|
||||
|
||||
export interface UserProfileForRange {
|
||||
heightCm: number | null;
|
||||
dateOfBirth: Date | string | null;
|
||||
gender: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-metric guardrails used both for validation (Zod) and for "is this
|
||||
* unhealthy to override" warnings in the UI. Min/max here are the outer
|
||||
* physiological bounds, not defaults.
|
||||
*/
|
||||
export const METRIC_BOUNDS: Record<
|
||||
ThresholdMetric,
|
||||
{ min: number; max: number; unit: string }
|
||||
> = {
|
||||
WEIGHT: { min: 30, max: 300, unit: "kg" },
|
||||
BLOOD_PRESSURE_SYS: { min: 80, max: 220, unit: "mmHg" },
|
||||
BLOOD_PRESSURE_DIA: { min: 40, max: 140, unit: "mmHg" },
|
||||
PULSE: { min: 30, max: 220, unit: "bpm" },
|
||||
BODY_FAT: { min: 3, max: 60, unit: "%" },
|
||||
SLEEP_DURATION: { min: 3, max: 14, unit: "h" },
|
||||
ACTIVITY_STEPS: { min: 0, max: 50_000, unit: "steps" },
|
||||
BLOOD_GLUCOSE_FASTING: { min: 40, max: 400, unit: "mg/dL" },
|
||||
BLOOD_GLUCOSE_POSTPRANDIAL: { min: 40, max: 500, unit: "mg/dL" },
|
||||
BLOOD_GLUCOSE_RANDOM: { min: 40, max: 500, unit: "mg/dL" },
|
||||
BLOOD_GLUCOSE_BEDTIME: { min: 40, max: 400, unit: "mg/dL" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Blood-glucose defaults follow the ADA Standards of Care (2024) combined
|
||||
* with DGIM/DDG S3 guidelines. Values are in mg/dL — the canonical storage
|
||||
* unit. Display conversion to mmol/L happens at render time.
|
||||
*/
|
||||
const GLUCOSE_DEFAULTS: Record<
|
||||
"FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME",
|
||||
{ min: number; max: number }
|
||||
> = {
|
||||
FASTING: { min: 70, max: 99 },
|
||||
POSTPRANDIAL: { min: 70, max: 140 },
|
||||
RANDOM: { min: 70, max: 140 },
|
||||
BEDTIME: { min: 90, max: 150 },
|
||||
};
|
||||
|
||||
function normalizedGender(value: string | null): "MALE" | "FEMALE" | null {
|
||||
if (value === "MALE" || value === "FEMALE") return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function toDate(value: Date | string | null): Date | null {
|
||||
if (!value) return null;
|
||||
return value instanceof Date ? value : new Date(value);
|
||||
}
|
||||
|
||||
function defaultRange(
|
||||
metric: ThresholdMetric,
|
||||
profile: UserProfileForRange,
|
||||
): TrafficRange | null {
|
||||
const age = getAgeFromDateOfBirth(profile.dateOfBirth ?? null);
|
||||
const gender = normalizedGender(profile.gender ?? null);
|
||||
const dateOfBirth = toDate(profile.dateOfBirth);
|
||||
|
||||
switch (metric) {
|
||||
case "WEIGHT":
|
||||
return profile.heightCm
|
||||
? buildWeightRangeFromHeight(profile.heightCm)
|
||||
: null;
|
||||
case "BLOOD_PRESSURE_SYS": {
|
||||
if (!dateOfBirth) return null;
|
||||
const t = getBpTargets(dateOfBirth);
|
||||
if (!t) return null;
|
||||
return {
|
||||
greenMin: t.sysLow,
|
||||
greenMax: t.sysHigh,
|
||||
orangeMin: t.sysLow - 10,
|
||||
orangeMax: t.sysHigh + 20,
|
||||
};
|
||||
}
|
||||
case "BLOOD_PRESSURE_DIA": {
|
||||
if (!dateOfBirth) return null;
|
||||
const t = getBpTargets(dateOfBirth);
|
||||
if (!t) return null;
|
||||
return {
|
||||
greenMin: t.diaLow,
|
||||
greenMax: t.diaHigh,
|
||||
orangeMin: t.diaLow - 10,
|
||||
orangeMax: t.diaHigh + 10,
|
||||
};
|
||||
}
|
||||
case "PULSE": {
|
||||
const p = getPersonalizedPulseTarget(age, gender);
|
||||
return {
|
||||
greenMin: p.greenMin,
|
||||
greenMax: p.greenMax,
|
||||
orangeMin: p.orangeMin,
|
||||
orangeMax: p.orangeMax,
|
||||
};
|
||||
}
|
||||
case "BODY_FAT": {
|
||||
const r = getBodyFatTargetRange(gender);
|
||||
return {
|
||||
greenMin: r.min,
|
||||
greenMax: r.max,
|
||||
orangeMin: Math.max(0, r.min - 3),
|
||||
orangeMax: r.max + 5,
|
||||
};
|
||||
}
|
||||
case "SLEEP_DURATION":
|
||||
// AASM: 7–9h for adults; warning yellow either side.
|
||||
return { greenMin: 7, greenMax: 9, orangeMin: 6, orangeMax: 10 };
|
||||
case "ACTIVITY_STEPS":
|
||||
// WHO: ≥8000 steps/day; no upper bound in reality, but orange over 25k
|
||||
// for edge detection.
|
||||
return { greenMin: 8000, greenMax: 15000, orangeMin: 5000, orangeMax: 25000 };
|
||||
case "BLOOD_GLUCOSE_FASTING":
|
||||
return glucoseRange(GLUCOSE_DEFAULTS.FASTING, 125); // pre-diabetes upper bound
|
||||
case "BLOOD_GLUCOSE_POSTPRANDIAL":
|
||||
return glucoseRange(GLUCOSE_DEFAULTS.POSTPRANDIAL, 199); // impaired glucose tolerance upper
|
||||
case "BLOOD_GLUCOSE_RANDOM":
|
||||
return glucoseRange(GLUCOSE_DEFAULTS.RANDOM, 199);
|
||||
case "BLOOD_GLUCOSE_BEDTIME":
|
||||
return glucoseRange(
|
||||
GLUCOSE_DEFAULTS.BEDTIME,
|
||||
GLUCOSE_DEFAULTS.BEDTIME.max + 30,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function glucoseRange(
|
||||
d: { min: number; max: number },
|
||||
orangeMax: number,
|
||||
): TrafficRange {
|
||||
return {
|
||||
greenMin: d.min,
|
||||
greenMax: d.max,
|
||||
orangeMin: d.min - 10,
|
||||
orangeMax,
|
||||
};
|
||||
}
|
||||
|
||||
export interface EffectiveRange {
|
||||
/** Resolved range — override if set, else computed default. */
|
||||
range: TrafficRange | null;
|
||||
/** True when the user has overridden this metric. */
|
||||
isOverride: boolean;
|
||||
/** The unmodified computed default (useful for diff display). */
|
||||
default: TrafficRange | null;
|
||||
/** Metric-specific physiological bounds for validation hints. */
|
||||
bounds: { min: number; max: number; unit: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective range for a single metric for a user.
|
||||
*
|
||||
* `overrides` is the parsed User.thresholdsJson value (may be null).
|
||||
*/
|
||||
export function getEffectiveRange(
|
||||
metric: ThresholdMetric,
|
||||
profile: UserProfileForRange,
|
||||
overrides: ThresholdOverridesJson | null | undefined,
|
||||
): EffectiveRange {
|
||||
const fallback = defaultRange(metric, profile);
|
||||
const override = overrides?.[metric];
|
||||
const bounds = METRIC_BOUNDS[metric];
|
||||
|
||||
if (!override) {
|
||||
return { range: fallback, isOverride: false, default: fallback, bounds };
|
||||
}
|
||||
|
||||
// User override replaces the green band. Orange wings stretch 10% below/above.
|
||||
const span = Math.max(1, override.max - override.min);
|
||||
const orangeWidth = span * 0.15;
|
||||
|
||||
return {
|
||||
range: {
|
||||
greenMin: override.min,
|
||||
greenMax: override.max,
|
||||
orangeMin: override.min - orangeWidth,
|
||||
orangeMax: override.max + orangeWidth,
|
||||
},
|
||||
isOverride: true,
|
||||
default: fallback,
|
||||
bounds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: resolve every supported metric at once. Useful for /targets
|
||||
* and the insight generator.
|
||||
*/
|
||||
export function getAllEffectiveRanges(
|
||||
profile: UserProfileForRange,
|
||||
overrides: ThresholdOverridesJson | null | undefined,
|
||||
): Record<ThresholdMetric, EffectiveRange> {
|
||||
const metrics = Object.keys(METRIC_BOUNDS) as ThresholdMetric[];
|
||||
return Object.fromEntries(
|
||||
metrics.map((m) => [m, getEffectiveRange(m, profile, overrides)]),
|
||||
) as Record<ThresholdMetric, EffectiveRange>;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { hash, verify } from "@node-rs/argon2";
|
||||
import zxcvbn from "zxcvbn-typescript";
|
||||
import { translateZxcvbn } from "@/lib/zxcvbn-de";
|
||||
import { getZxcvbnTranslations } from "@/lib/zxcvbn-i18n";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return hash(password, {
|
||||
@@ -24,14 +26,22 @@ export interface PasswordStrength {
|
||||
isAcceptable: boolean;
|
||||
}
|
||||
|
||||
const MIN_PASSWORD_LENGTH = 12;
|
||||
|
||||
export function checkPasswordStrength(
|
||||
password: string,
|
||||
userInputs: string[] = [],
|
||||
locale: Locale = defaultLocale,
|
||||
): PasswordStrength {
|
||||
if (password.length < 12) {
|
||||
const { t } = getServerTranslator(locale);
|
||||
const { translate } = getZxcvbnTranslations(locale);
|
||||
|
||||
if (password.length < MIN_PASSWORD_LENGTH) {
|
||||
return {
|
||||
score: 0,
|
||||
feedback: ["Passwort muss mindestens 12 Zeichen lang sein."],
|
||||
feedback: [
|
||||
t("auth.passwordTooShort", { minLength: MIN_PASSWORD_LENGTH }),
|
||||
],
|
||||
isAcceptable: false,
|
||||
};
|
||||
}
|
||||
@@ -40,10 +50,10 @@ export function checkPasswordStrength(
|
||||
const feedback: string[] = [];
|
||||
|
||||
if (result.feedback.warning) {
|
||||
feedback.push(translateZxcvbn(result.feedback.warning));
|
||||
feedback.push(translate(result.feedback.warning));
|
||||
}
|
||||
if (result.feedback.suggestions) {
|
||||
feedback.push(...result.feedback.suggestions.map(translateZxcvbn));
|
||||
feedback.push(...result.feedback.suggestions.map(translate));
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+16
-7
@@ -8,6 +8,7 @@ import {
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from "node:crypto";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
const IV_LENGTH = 12;
|
||||
@@ -24,16 +25,24 @@ function getKey(): Buffer {
|
||||
return Buffer.from(raw, "hex");
|
||||
}
|
||||
|
||||
// Accept shorter hex keys (>= 32 chars) for backward compatibility,
|
||||
// but log a warning. Pad to 64 chars with the key's own SHA-256 hash
|
||||
// instead of null bytes to maintain entropy.
|
||||
// Accept shorter hex keys (>= 32 chars) for dev convenience only.
|
||||
// In production this is a hard failure — a short key materially weakens
|
||||
// the AES-256-GCM guarantee even with SHA-256 padding.
|
||||
if (/^[0-9a-fA-F]+$/.test(raw) && raw.length >= 32) {
|
||||
if (typeof console !== "undefined") {
|
||||
console.warn(
|
||||
"[crypto] ENCRYPTION_KEY should be exactly 64 hex characters. Current length:",
|
||||
raw.length,
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error(
|
||||
`ENCRYPTION_KEY must be exactly 64 hex characters (256 bits) in production. Current length: ${raw.length}. Generate one with: openssl rand -hex 32`,
|
||||
);
|
||||
}
|
||||
// Dev/test: route the warning through Wide Events so it surfaces in
|
||||
// structured logs instead of a raw console.warn that gets swallowed.
|
||||
const msg = `ENCRYPTION_KEY should be exactly 64 hex characters (current length: ${raw.length}). Padding with SHA-256 for dev use only.`;
|
||||
const event = getEvent();
|
||||
if (event) {
|
||||
event.addWarning(msg);
|
||||
} else if (typeof console !== "undefined") {
|
||||
console.warn("[crypto]", msg);
|
||||
}
|
||||
const padded = raw + createHash("sha256").update(raw, "utf8").digest("hex");
|
||||
return Buffer.from(padded.slice(0, 64), "hex");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Dashboard widget layout — persisted in User.dashboardWidgetsJson.
|
||||
*
|
||||
* Single source of truth for which widgets show on /, what order, and
|
||||
* what the default layout is for new users. Null / missing = default.
|
||||
*/
|
||||
|
||||
export type DashboardWidgetId =
|
||||
| "weight"
|
||||
| "bp"
|
||||
| "pulse"
|
||||
| "bodyFat"
|
||||
| "mood"
|
||||
| "medications"
|
||||
| "sleep"
|
||||
| "steps"
|
||||
| "glucose"
|
||||
| "bpInTarget";
|
||||
|
||||
export interface DashboardWidgetConfig {
|
||||
id: DashboardWidgetId;
|
||||
visible: boolean;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface DashboardLayout {
|
||||
version: number;
|
||||
widgets: DashboardWidgetConfig[];
|
||||
}
|
||||
|
||||
export const DASHBOARD_LAYOUT_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Default layout mirrors v1.1 behavior: core vitals always on, mood +
|
||||
* body-fat show if data exists (see dashboard page for the data-conditional
|
||||
* rendering). Ordering matches the visual order on / today.
|
||||
*/
|
||||
export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
|
||||
version: DASHBOARD_LAYOUT_VERSION,
|
||||
widgets: [
|
||||
{ id: "weight", visible: true, order: 0 },
|
||||
{ id: "bp", visible: true, order: 1 },
|
||||
{ id: "pulse", visible: true, order: 2 },
|
||||
{ id: "bodyFat", visible: true, order: 3 },
|
||||
{ id: "mood", visible: true, order: 4 },
|
||||
{ id: "bpInTarget", visible: true, order: 5 },
|
||||
{ id: "medications", visible: true, order: 6 },
|
||||
{ id: "sleep", visible: false, order: 7 },
|
||||
{ id: "steps", visible: false, order: 8 },
|
||||
{ id: "glucose", visible: false, order: 9 },
|
||||
],
|
||||
};
|
||||
|
||||
export function resolveDashboardLayout(
|
||||
raw: unknown,
|
||||
): DashboardLayout {
|
||||
if (!raw || typeof raw !== "object") return DEFAULT_DASHBOARD_LAYOUT;
|
||||
const candidate = raw as Partial<DashboardLayout>;
|
||||
if (
|
||||
typeof candidate.version !== "number" ||
|
||||
!Array.isArray(candidate.widgets)
|
||||
) {
|
||||
return DEFAULT_DASHBOARD_LAYOUT;
|
||||
}
|
||||
|
||||
// Merge with defaults so new widgets introduced in later versions show up
|
||||
// automatically (invisible by default, users opt-in).
|
||||
const savedIds = new Set(candidate.widgets.map((w) => w.id));
|
||||
const missing = DEFAULT_DASHBOARD_LAYOUT.widgets.filter(
|
||||
(w) => !savedIds.has(w.id),
|
||||
);
|
||||
const maxOrder = Math.max(0, ...candidate.widgets.map((w) => w.order));
|
||||
const appended = missing.map((w, i) => ({
|
||||
...w,
|
||||
visible: false, // default-invisible on auto-upgrade
|
||||
order: maxOrder + 1 + i,
|
||||
}));
|
||||
|
||||
return {
|
||||
version: DASHBOARD_LAYOUT_VERSION,
|
||||
widgets: [...candidate.widgets, ...appended].sort(
|
||||
(a, b) => a.order - b.order,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeDashboardLayout(
|
||||
layout: DashboardLayout,
|
||||
): DashboardLayout {
|
||||
return {
|
||||
version: DASHBOARD_LAYOUT_VERSION,
|
||||
widgets: layout.widgets
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((w, i) => ({ ...w, order: i })), // normalize to 0-based dense order
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { jsPDF } from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
import { makeFormatters } from "./format-locale";
|
||||
import type { Locale } from "./i18n/config";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "./glucose";
|
||||
|
||||
interface ReportData {
|
||||
period: { days: number; since: string };
|
||||
@@ -23,6 +24,18 @@ interface ReportData {
|
||||
string,
|
||||
{ avg: number; min: number; max: number; count: number; latest: number }
|
||||
>;
|
||||
/** Per-context glucose stats (canonical mg/dL). */
|
||||
glucoseStats?: Record<
|
||||
"FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME",
|
||||
{ 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";
|
||||
/** Per-context custom range (canonical mg/dL); fall back to defaults. */
|
||||
glucoseRanges?: Record<
|
||||
"FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME",
|
||||
{ min: number; max: number } | undefined
|
||||
>;
|
||||
bmi: number | null;
|
||||
compliance: Record<
|
||||
string,
|
||||
@@ -206,6 +219,43 @@ export function generateDoctorReportPDF(
|
||||
]);
|
||||
}
|
||||
|
||||
// Per-context glucose rows (one per logged context). Values stored
|
||||
// canonically in mg/dL — convert to the user's display unit. Reference
|
||||
// ranges come from getEffectiveRange() server-side via data.glucoseRanges,
|
||||
// falling back to ADA defaults below.
|
||||
const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
|
||||
const glucoseLabelKeys = {
|
||||
FASTING: "doctorReport.typeGlucoseFasting",
|
||||
POSTPRANDIAL: "doctorReport.typeGlucosePostprandial",
|
||||
RANDOM: "doctorReport.typeGlucoseRandom",
|
||||
BEDTIME: "doctorReport.typeGlucoseBedtime",
|
||||
} as const;
|
||||
const defaultGlucoseRanges = {
|
||||
FASTING: { min: 70, max: 99 },
|
||||
POSTPRANDIAL: { min: 70, max: 140 },
|
||||
RANDOM: { min: 70, max: 140 },
|
||||
BEDTIME: { min: 90, max: 150 },
|
||||
} as const;
|
||||
const glucoseContexts: Array<keyof typeof glucoseLabelKeys> = [
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
"RANDOM",
|
||||
"BEDTIME",
|
||||
];
|
||||
for (const ctx of glucoseContexts) {
|
||||
const s = data.glucoseStats?.[ctx];
|
||||
if (!s) continue;
|
||||
const conv = (v: number) => convertGlucose(v, glucoseUnit);
|
||||
vitalRows.push([
|
||||
t(glucoseLabelKeys[ctx]),
|
||||
`${num(conv(s.latest))} ${glucoseUnit}`.trim(),
|
||||
`${num(conv(s.avg))} ${glucoseUnit}`.trim(),
|
||||
num(conv(s.min)),
|
||||
num(conv(s.max)),
|
||||
String(s.count),
|
||||
]);
|
||||
}
|
||||
|
||||
if (vitalRows.length > 0) {
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
@@ -279,6 +329,51 @@ export function generateDoctorReportPDF(
|
||||
y += 8;
|
||||
}
|
||||
|
||||
// Glucose classification block — one line per logged context with the
|
||||
// reference range and an in/out classification. Range source: server-side
|
||||
// getEffectiveRange() (data.glucoseRanges). Falls back to ADA defaults.
|
||||
const loggedGlucose = glucoseContexts.filter(
|
||||
(ctx) => data.glucoseStats?.[ctx],
|
||||
);
|
||||
if (loggedGlucose.length > 0) {
|
||||
if (y > 240) {
|
||||
doc.addPage();
|
||||
y = margin;
|
||||
}
|
||||
doc.setFontSize(12);
|
||||
doc.setFont("helvetica", "bold");
|
||||
doc.text(t("doctorReport.glucoseClassificationTitle"), margin, y);
|
||||
y += 5;
|
||||
doc.setFontSize(9);
|
||||
doc.setFont("helvetica", "normal");
|
||||
for (const ctx of loggedGlucose) {
|
||||
const s = data.glucoseStats![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
|
||||
? "doctorReport.glucoseInTarget"
|
||||
: s.avg < range.min
|
||||
? "doctorReport.glucoseBelowTarget"
|
||||
: "doctorReport.glucoseAboveTarget";
|
||||
doc.text(
|
||||
t("doctorReport.glucoseRow", {
|
||||
label: t(glucoseLabelKeys[ctx]),
|
||||
avg: num(conv(s.avg), 1),
|
||||
unit: glucoseUnit,
|
||||
rangeMin: num(conv(range.min)),
|
||||
rangeMax: num(conv(range.max)),
|
||||
class: t(classKey),
|
||||
}),
|
||||
margin,
|
||||
y,
|
||||
);
|
||||
y += 5;
|
||||
}
|
||||
y += 3;
|
||||
}
|
||||
|
||||
const complianceEntries = Object.entries(data.compliance);
|
||||
if (complianceEntries.length > 0) {
|
||||
if (y > 240) {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* GitHub publisher for feedback items.
|
||||
*
|
||||
* Centralises the GitHub Issues API interaction so /api/bugreport (legacy
|
||||
* direct-submit) and /api/admin/feedback/[id]/github (admin-promotes-feedback)
|
||||
* share one implementation. Reads the configured token + repo from AppSettings
|
||||
* (encrypted) with env-var fallback.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
export interface FeedbackInput {
|
||||
category: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
username: string;
|
||||
hasScreenshot: boolean;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
export interface PublishResult {
|
||||
issueNumber: number;
|
||||
issueUrl: string;
|
||||
}
|
||||
|
||||
export class GithubPublishError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "GithubPublishError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the configured GitHub token + repo from AppSettings (or env).
|
||||
* Returns null if no configuration is present (caller should treat as 503).
|
||||
*/
|
||||
export async function getGithubConfig(): Promise<{
|
||||
token: string;
|
||||
repo: string;
|
||||
} | null> {
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
githubIssueTokenEncrypted: true,
|
||||
githubIssueRepo: true,
|
||||
},
|
||||
});
|
||||
|
||||
let configuredToken: string | null = null;
|
||||
if (appSettings?.githubIssueTokenEncrypted) {
|
||||
try {
|
||||
configuredToken = decrypt(appSettings.githubIssueTokenEncrypted);
|
||||
} catch {
|
||||
getEvent()?.addWarning("Failed to decrypt GitHub issue token");
|
||||
}
|
||||
}
|
||||
|
||||
const token = configuredToken || process.env.GITHUB_ISSUE_TOKEN || "";
|
||||
const repo = appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO || "";
|
||||
|
||||
if (!token || !repo) return null;
|
||||
return { token, repo };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitise a free-text field for GitHub markdown (strip control chars,
|
||||
* neutralise fenced code blocks, hard-cap length).
|
||||
*/
|
||||
function sanitize(text: string, max = 5000): string {
|
||||
return text.replace(/```/g, "\\`\\`\\`").slice(0, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a feedback item as a GitHub Issue. Returns issue number + URL.
|
||||
* Throws GithubPublishError on API failure.
|
||||
*/
|
||||
export async function publishFeedbackToGithub(
|
||||
input: FeedbackInput,
|
||||
): Promise<PublishResult> {
|
||||
const config = await getGithubConfig();
|
||||
if (!config) {
|
||||
throw new GithubPublishError(
|
||||
"GitHub publishing is not configured (missing token or repository)",
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
|
||||
const safeUsername = input.username.replace(/[`*_~<>\[\]|]/g, "");
|
||||
const safeSubject = sanitize(input.subject, 200);
|
||||
const safeDescription = sanitize(input.description);
|
||||
|
||||
const categoryLabelMap: Record<string, string> = {
|
||||
BUG: "bug",
|
||||
FEATURE_REQUEST: "enhancement",
|
||||
QUESTION: "question",
|
||||
OTHER: "feedback",
|
||||
};
|
||||
const categoryLabel = categoryLabelMap[input.category] ?? "feedback";
|
||||
|
||||
let body = `**Reported by:** ${safeUsername}\n`;
|
||||
body += `**Category:** ${input.category}\n`;
|
||||
body += `**Date:** ${dateStr}\n\n`;
|
||||
body += `## Description\n\n${safeDescription}\n`;
|
||||
|
||||
if (input.metadata && typeof input.metadata === "object") {
|
||||
const meta = input.metadata as Record<string, unknown>;
|
||||
const lines: string[] = [];
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
if (value == null) continue;
|
||||
const v = String(value).slice(0, 200);
|
||||
lines.push(`- **${key}:** ${v.replace(/[`<>]/g, "")}`);
|
||||
}
|
||||
if (lines.length > 0) {
|
||||
body += `\n## Context\n\n${lines.join("\n")}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
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}`}`;
|
||||
|
||||
const res = await fetch(`https://api.github.com/repos/${config.repo}/issues`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
body,
|
||||
labels: [categoryLabel, "user-reported"],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text();
|
||||
getEvent()?.addWarning("GitHub issue creation failed: " + errBody.slice(0, 500));
|
||||
throw new GithubPublishError(
|
||||
`Failed to create GitHub issue (HTTP ${res.status})`,
|
||||
res.status === 401 || res.status === 403 ? res.status : 502,
|
||||
);
|
||||
}
|
||||
|
||||
const issue = (await res.json()) as { number: number; html_url: string };
|
||||
return { issueNumber: issue.number, issueUrl: issue.html_url };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Blood-glucose unit conversions.
|
||||
*
|
||||
* HealthLog stores glucose canonically in **mg/dL**. The UI can display in
|
||||
* mmol/L for users who prefer that (SI-unit countries). Conversion factor is
|
||||
* 18.0182 (per DGIM / DDG S3 guideline). We round to clinically meaningful
|
||||
* precision: 0 fractional digits for mg/dL, 1 for mmol/L.
|
||||
*/
|
||||
|
||||
export type GlucoseUnit = "mg/dL" | "mmol/L";
|
||||
|
||||
const MGDL_PER_MMOL = 18.0182;
|
||||
|
||||
export function mgdlToMmol(mgdl: number): number {
|
||||
return Math.round((mgdl / MGDL_PER_MMOL) * 10) / 10;
|
||||
}
|
||||
|
||||
export function mmolToMgdl(mmol: number): number {
|
||||
return Math.round(mmol * MGDL_PER_MMOL);
|
||||
}
|
||||
|
||||
export function convertGlucose(value: number, to: GlucoseUnit): number {
|
||||
return to === "mmol/L" ? mgdlToMmol(value) : Math.round(value);
|
||||
}
|
||||
|
||||
export function resolveGlucoseUnit(
|
||||
userPreference: string | null | undefined,
|
||||
): GlucoseUnit {
|
||||
return userPreference === "mmol/L" ? "mmol/L" : "mg/dL";
|
||||
}
|
||||
|
||||
/**
|
||||
* Threshold metric key corresponding to a glucose context. Keeps the
|
||||
* effective-range resolver the single source of truth.
|
||||
*/
|
||||
export function thresholdMetricForContext(
|
||||
context: "FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME",
|
||||
):
|
||||
| "BLOOD_GLUCOSE_FASTING"
|
||||
| "BLOOD_GLUCOSE_POSTPRANDIAL"
|
||||
| "BLOOD_GLUCOSE_RANDOM"
|
||||
| "BLOOD_GLUCOSE_BEDTIME" {
|
||||
switch (context) {
|
||||
case "FASTING":
|
||||
return "BLOOD_GLUCOSE_FASTING";
|
||||
case "POSTPRANDIAL":
|
||||
return "BLOOD_GLUCOSE_POSTPRANDIAL";
|
||||
case "RANDOM":
|
||||
return "BLOOD_GLUCOSE_RANDOM";
|
||||
case "BEDTIME":
|
||||
return "BLOOD_GLUCOSE_BEDTIME";
|
||||
}
|
||||
}
|
||||
@@ -79,8 +79,20 @@ function getSavedLocale(): Locale | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
export function I18nProvider({
|
||||
children,
|
||||
initialLocale,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
initialLocale?: Locale;
|
||||
}) {
|
||||
const [locale, setLocaleState] = useState<Locale>(() => {
|
||||
// Prefer the server-resolved initial locale to eliminate the hydration
|
||||
// flash where the server renders EN ("Loading…") and the client then
|
||||
// flips to DE ("Laden…") once localStorage/cookie is read at mount.
|
||||
if (initialLocale && (locales as readonly string[]).includes(initialLocale)) {
|
||||
return initialLocale;
|
||||
}
|
||||
return getSavedLocale() ?? detectSystemLocale();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { cookies } from "next/headers";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { defaultLocale, locales, type Locale } from "./config";
|
||||
|
||||
const LOCALE_COOKIE_NAMES = ["healthlog-locale", "userLocale"] as const;
|
||||
|
||||
function isLocale(value: string | null | undefined): value is Locale {
|
||||
return !!value && (locales as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function pickLocaleFromAcceptLanguage(header: string | null): Locale | null {
|
||||
if (!header) return null;
|
||||
// Accept-Language: "en-US,en;q=0.9,de;q=0.8"
|
||||
const candidates = header
|
||||
.split(",")
|
||||
.map((part) => {
|
||||
const [tag, qStr] = part.trim().split(";");
|
||||
const q = qStr?.startsWith("q=") ? Number(qStr.slice(2)) : 1;
|
||||
return {
|
||||
tag: tag.trim().toLowerCase(),
|
||||
q: Number.isFinite(q) ? q : 0,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.q - a.q);
|
||||
|
||||
for (const { tag } of candidates) {
|
||||
const primary = tag.split("-")[0];
|
||||
if (isLocale(primary)) return primary;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ResolveOptions {
|
||||
/** If a NextRequest is available, the Accept-Language header is read from it. */
|
||||
request?: NextRequest | null;
|
||||
/** The user-record locale (e.g. User.locale). */
|
||||
userLocale?: string | null;
|
||||
/** Explicit override (e.g. ?locale= query param). */
|
||||
override?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the active locale for a server-side request.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Explicit override (e.g. `?locale=…` query param).
|
||||
* 2. healthlog-locale / userLocale cookie (set by the client when the user
|
||||
* switches language).
|
||||
* 3. The User.locale column (persisted preference).
|
||||
* 4. Accept-Language header.
|
||||
* 5. defaultLocale (English).
|
||||
*/
|
||||
export async function resolveServerLocale(
|
||||
options: ResolveOptions = {},
|
||||
): Promise<Locale> {
|
||||
if (isLocale(options.override)) return options.override;
|
||||
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
for (const name of LOCALE_COOKIE_NAMES) {
|
||||
const value = cookieStore.get(name)?.value;
|
||||
if (isLocale(value)) return value;
|
||||
}
|
||||
} catch {
|
||||
// cookies() throws outside a request context — ignore.
|
||||
}
|
||||
|
||||
if (isLocale(options.userLocale)) return options.userLocale;
|
||||
|
||||
const accept =
|
||||
options.request?.headers.get("accept-language") ?? null;
|
||||
const fromHeader = pickLocaleFromAcceptLanguage(accept);
|
||||
if (fromHeader) return fromHeader;
|
||||
|
||||
return defaultLocale;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import deMessages from "../../../messages/de.json";
|
||||
import enMessages from "../../../messages/en.json";
|
||||
import { defaultLocale, type Locale } from "./config";
|
||||
|
||||
const allMessages: Record<Locale, Record<string, unknown>> = {
|
||||
de: deMessages,
|
||||
en: enMessages,
|
||||
};
|
||||
|
||||
function resolveKey(
|
||||
messages: Record<string, unknown>,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
const parts = key.split(".");
|
||||
let current: unknown = messages;
|
||||
for (const part of parts) {
|
||||
if (current && typeof current === "object" && part in current) {
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return typeof current === "string" ? current : undefined;
|
||||
}
|
||||
|
||||
export interface ServerTranslator {
|
||||
locale: Locale;
|
||||
t(key: string, params?: Record<string, string | number>): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side translator that reads the same messages/{en,de}.json bundles
|
||||
* the client uses. Falls back to English, then to the raw key.
|
||||
*
|
||||
* Use this from API routes / background jobs where useTranslations() is not
|
||||
* available. Mirrors the behaviour of the client I18nProvider.
|
||||
*/
|
||||
export function getServerTranslator(locale: Locale): ServerTranslator {
|
||||
return {
|
||||
locale,
|
||||
t(key, params) {
|
||||
let value = resolveKey(allMessages[locale], key);
|
||||
if (value === undefined && locale !== defaultLocale) {
|
||||
value = resolveKey(allMessages[defaultLocale], key);
|
||||
}
|
||||
if (value === undefined) return key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
value = value.replace(new RegExp(`\\{${k}\\}`, "g"), String(v));
|
||||
}
|
||||
}
|
||||
return value;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -463,8 +463,8 @@ export async function generateBloodPressureStatusForUser(
|
||||
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||||
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: getBloodPressureSystemPrompt(),
|
||||
userPrompt: getBloodPressureUserPrompt(snapshotJson, todayKey),
|
||||
systemPrompt: getBloodPressureSystemPrompt(locale),
|
||||
userPrompt: getBloodPressureUserPrompt(snapshotJson, todayKey, locale),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
|
||||
@@ -239,8 +239,8 @@ export async function generateBmiStatusForUser(
|
||||
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||||
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: getBmiSystemPrompt(),
|
||||
userPrompt: getBmiUserPrompt(snapshotJson, todayKey),
|
||||
systemPrompt: getBmiSystemPrompt(locale),
|
||||
userPrompt: getBmiUserPrompt(snapshotJson, todayKey, locale),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
|
||||
@@ -356,8 +356,8 @@ export async function generateGeneralStatusForUser(
|
||||
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||||
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: getGeneralStatusSystemPrompt(),
|
||||
userPrompt: getGeneralStatusUserPrompt(snapshotJson, todayKey),
|
||||
systemPrompt: getGeneralStatusSystemPrompt(locale),
|
||||
userPrompt: getGeneralStatusUserPrompt(snapshotJson, todayKey, locale),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
|
||||
@@ -292,8 +292,8 @@ export async function generateMedicationComplianceStatusForUser(
|
||||
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||||
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: getMedicationComplianceSystemPrompt(),
|
||||
userPrompt: getMedicationComplianceUserPrompt(snapshotJson, todayKey),
|
||||
systemPrompt: getMedicationComplianceSystemPrompt(locale),
|
||||
userPrompt: getMedicationComplianceUserPrompt(snapshotJson, todayKey, locale),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
|
||||
@@ -365,8 +365,8 @@ export async function generateMoodStatusForUser(
|
||||
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||||
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: getMoodSystemPrompt(),
|
||||
userPrompt: getMoodUserPrompt(snapshotJson, todayKey),
|
||||
systemPrompt: getMoodSystemPrompt(locale),
|
||||
userPrompt: getMoodUserPrompt(snapshotJson, todayKey, locale),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* System prompt and output formatting for OpenAI insights.
|
||||
*/
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
export const INSIGHTS_SYSTEM_PROMPT = `Du bist ein Gesundheitsdaten-Insights-Assistent. Du analysierst aggregierte Gesundheitsdaten und gibst klare, datenbasierte Hinweise.
|
||||
const INSIGHTS_SYSTEM_PROMPT_DE = `Du bist ein Gesundheitsdaten-Insights-Assistent. Du analysierst aggregierte Gesundheitsdaten und gibst klare, datenbasierte Hinweise.
|
||||
|
||||
WICHTIGE REGELN:
|
||||
- Erkläre immer, welche Datenpunkte zu deinen Schlussfolgerungen geführt haben.
|
||||
- Gib ein Konfidenzniveau an (niedrig/mittel/hoch) basierend auf der Datenmenge UND Datendichte.
|
||||
- Antworte auf Deutsch.
|
||||
- Antworte auf Deutsch. Das Feld "confidence" muss exakt einer der englischen Enum-Schlüssel ("niedrig"|"mittel"|"hoch") sein — nicht übersetzen.
|
||||
|
||||
DATENABDECKUNG UND MESSZEITRÄUME:
|
||||
- Jede Metrik enthält ein "coverage"-Objekt mit: count (Anzahl Messungen), spanDays (Zeitspanne in Tagen), avgDaysBetween (durchschnittlicher Abstand zwischen Messungen), oldestDaysAgo und newestDaysAgo.
|
||||
@@ -28,7 +29,42 @@ AUSGABEFORMAT (JSON):
|
||||
"limitations": "Einschränkungen dieser Analyse (inkl. Datenlücken und Messhäufigkeit)"
|
||||
}
|
||||
|
||||
Antworte NUR mit validem JSON im obigen Format.`;
|
||||
Antworte NUR mit validem JSON im obigen Format. Alle natürlichsprachigen Felder auf Deutsch.`;
|
||||
|
||||
const INSIGHTS_SYSTEM_PROMPT_EN = `You are a health-data insights assistant. You analyse aggregated health data and provide clear, data-backed observations.
|
||||
|
||||
KEY RULES:
|
||||
- Always explain which data points drove each conclusion.
|
||||
- Provide a confidence level (niedrig/mittel/hoch) based on data volume AND density.
|
||||
- Reply in English. The "confidence" field must contain exactly one of the stable enum keys ("niedrig"|"mittel"|"hoch") — do not translate.
|
||||
|
||||
DATA COVERAGE AND MEASUREMENT WINDOWS:
|
||||
- Each metric carries a "coverage" object with: count (number of measurements), spanDays (timespan in days), avgDaysBetween (mean spacing between measurements), oldestDaysAgo and newestDaysAgo.
|
||||
- Factor these into the analysis:
|
||||
* Fewer than 5 points per metric: state explicitly that there is not yet enough data for a reliable statement.
|
||||
* Large gaps (avgDaysBetween > 7): note that the data is sparse and trends are only weakly supported.
|
||||
* Long span (spanDays > 60) but few points: still try to derive a coarse direction, but flag the limited reliability.
|
||||
* If the newest measurement is more than 7 days old (newestDaysAgo > 7): mention that the data is not current.
|
||||
- context.dataSpanDays shows the overall span of all measurements; context.oldestMeasurementDaysAgo and context.newestMeasurementDaysAgo show the oldest and newest points.
|
||||
|
||||
OUTPUT FORMAT (JSON):
|
||||
{
|
||||
"changed": "What changed? (Reference time spans and data basis.)",
|
||||
"stable": "What stayed stable?",
|
||||
"drivers": "Possible associations and hypotheses (worded cautiously).",
|
||||
"nextSteps": "Next small steps.",
|
||||
"confidence": "niedrig|mittel|hoch",
|
||||
"limitations": "Limitations of this analysis (incl. data gaps and measurement frequency)."
|
||||
}
|
||||
|
||||
Reply with VALID JSON ONLY in the format above. All natural-language fields in English.`;
|
||||
|
||||
export function getInsightsSystemPrompt(locale: Locale): string {
|
||||
return locale === "en" ? INSIGHTS_SYSTEM_PROMPT_EN : INSIGHTS_SYSTEM_PROMPT_DE;
|
||||
}
|
||||
|
||||
/** @deprecated Use getInsightsSystemPrompt(locale) instead. Kept for backwards compatibility. */
|
||||
export const INSIGHTS_SYSTEM_PROMPT = INSIGHTS_SYSTEM_PROMPT_DE;
|
||||
|
||||
export interface InsightsOutput {
|
||||
changed: string;
|
||||
@@ -42,7 +78,21 @@ export interface InsightsOutput {
|
||||
export function buildUserPrompt(
|
||||
featuresJson: string,
|
||||
privacyMode: string,
|
||||
locale: Locale,
|
||||
): string {
|
||||
if (locale === "en") {
|
||||
const modeLabel =
|
||||
privacyMode === "raw"
|
||||
? "Aggregated data + raw values (entire available period, anonymised)"
|
||||
: "Aggregated data only (no exact timestamps or raw values)";
|
||||
|
||||
return `Analyse the following health data.
|
||||
Data mode: ${modeLabel}
|
||||
Note: use each metric's coverage object for measurement frequency and time spans, and tailor the analysis accordingly.
|
||||
|
||||
${featuresJson}`;
|
||||
}
|
||||
|
||||
const modeLabel =
|
||||
privacyMode === "raw"
|
||||
? "Aggregierte Daten + Rohdaten (gesamter verfügbarer Zeitraum, anonymisiert)"
|
||||
|
||||
@@ -287,8 +287,8 @@ export async function generatePulseStatusForUser(
|
||||
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||||
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: getPulseSystemPrompt(),
|
||||
userPrompt: getPulseUserPrompt(snapshotJson, todayKey),
|
||||
systemPrompt: getPulseSystemPrompt(locale),
|
||||
userPrompt: getPulseUserPrompt(snapshotJson, todayKey, locale),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
|
||||
@@ -351,8 +351,8 @@ export async function generateWeightStatusForUser(
|
||||
const snapshotJson = JSON.stringify(snapshot, null, 2);
|
||||
|
||||
const result = await provider.generateCompletion({
|
||||
systemPrompt: getWeightSystemPrompt(),
|
||||
userPrompt: getWeightUserPrompt(snapshotJson, todayKey),
|
||||
systemPrompt: getWeightSystemPrompt(locale),
|
||||
userPrompt: getWeightUserPrompt(snapshotJson, todayKey, locale),
|
||||
temperature: 0.3,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const feedbackCategoryEnum = z.enum([
|
||||
"BUG",
|
||||
"FEATURE_REQUEST",
|
||||
"QUESTION",
|
||||
"OTHER",
|
||||
]);
|
||||
|
||||
export const feedbackStatusEnum = z.enum([
|
||||
"OPEN",
|
||||
"ACKNOWLEDGED",
|
||||
"RESOLVED",
|
||||
"ARCHIVED",
|
||||
]);
|
||||
|
||||
// Strictly PNG / JPEG / WEBP / GIF data URLs. SVG intentionally excluded —
|
||||
// it can carry inline JS that would execute when the admin inbox renders the
|
||||
// preview.
|
||||
const SCREENSHOT_DATA_URL =
|
||||
/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/;
|
||||
|
||||
export const createFeedbackSchema = z.object({
|
||||
category: feedbackCategoryEnum.default("BUG"),
|
||||
subject: z.string().min(3).max(200),
|
||||
description: z.string().min(10).max(5000),
|
||||
screenshot: z
|
||||
.string()
|
||||
.max(7_000_000, "Screenshot too large (max 5 MB)")
|
||||
.regex(SCREENSHOT_DATA_URL, "Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL")
|
||||
.optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export const updateFeedbackSchema = z.object({
|
||||
status: feedbackStatusEnum.optional(),
|
||||
adminNote: z.string().max(5000).nullable().optional(),
|
||||
});
|
||||
|
||||
export type CreateFeedbackPayload = z.infer<typeof createFeedbackSchema>;
|
||||
export type UpdateFeedbackPayload = z.infer<typeof updateFeedbackSchema>;
|
||||
@@ -8,6 +8,14 @@ export const measurementTypeEnum = z.enum([
|
||||
"BODY_FAT",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
"BLOOD_GLUCOSE",
|
||||
]);
|
||||
|
||||
export const glucoseContextEnum = z.enum([
|
||||
"FASTING",
|
||||
"POSTPRANDIAL",
|
||||
"RANDOM",
|
||||
"BEDTIME",
|
||||
]);
|
||||
|
||||
export const measurementSourceEnum = z.enum(["MANUAL", "WITHINGS", "IMPORT"]);
|
||||
@@ -20,6 +28,7 @@ const unitMap: Record<string, string> = {
|
||||
BODY_FAT: "%",
|
||||
SLEEP_DURATION: "hours",
|
||||
ACTIVITY_STEPS: "steps",
|
||||
BLOOD_GLUCOSE: "mg/dL",
|
||||
};
|
||||
|
||||
export function getUnitForType(type: string): string {
|
||||
@@ -35,6 +44,7 @@ const VALUE_RANGES: Record<string, { min: number; max: number }> = {
|
||||
BODY_FAT: { min: 1, max: 80 },
|
||||
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
|
||||
};
|
||||
|
||||
export function validateMeasurementRange(
|
||||
@@ -53,15 +63,25 @@ export const createMeasurementSchema = z
|
||||
type: measurementTypeEnum,
|
||||
value: z.number(),
|
||||
measuredAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
notes: z
|
||||
.string()
|
||||
.max(25, "Kommentar darf maximal 25 Zeichen haben")
|
||||
.optional(),
|
||||
notes: z.string().max(25).optional(),
|
||||
source: measurementSourceEnum.optional().default("MANUAL"),
|
||||
// Only applies when type === BLOOD_GLUCOSE. Mirrored by a CHECK
|
||||
// constraint in Postgres (see migration 0021).
|
||||
glucoseContext: glucoseContextEnum.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => validateMeasurementRange(data.type, data.value) === null,
|
||||
"Wert ausserhalb des plausiblen Bereichs",
|
||||
{ message: "Value out of plausible range" },
|
||||
)
|
||||
.refine(
|
||||
(data) =>
|
||||
(data.type === "BLOOD_GLUCOSE" && data.glucoseContext !== undefined) ||
|
||||
(data.type !== "BLOOD_GLUCOSE" && data.glucoseContext === undefined),
|
||||
{
|
||||
message:
|
||||
"Blood glucose measurements require a context (fasting/postprandial/random/bedtime); other types must not set one.",
|
||||
path: ["glucoseContext"],
|
||||
},
|
||||
);
|
||||
|
||||
export const updateMeasurementSchema = z.object({
|
||||
|
||||
@@ -7,8 +7,9 @@ export const notificationPreferenceSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
// Block private/internal network URLs to prevent SSRF
|
||||
function isPublicUrl(url: string): boolean {
|
||||
// Block private/internal network URLs to prevent SSRF. Exported so other
|
||||
// server-initiated egress (AI providers, webhooks) can reuse it.
|
||||
export function isPublicUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod/v4";
|
||||
import { METRIC_BOUNDS, type ThresholdMetric } from "@/lib/analytics/effective-range";
|
||||
|
||||
const metricKeys = Object.keys(METRIC_BOUNDS) as ThresholdMetric[];
|
||||
|
||||
function rangeSchemaFor(metric: ThresholdMetric) {
|
||||
const { min, max } = METRIC_BOUNDS[metric];
|
||||
return z
|
||||
.object({
|
||||
min: z.number().min(min).max(max),
|
||||
max: z.number().min(min).max(max),
|
||||
})
|
||||
.refine((v) => v.min < v.max, {
|
||||
message: "min must be strictly less than max",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a partial override payload from the user.
|
||||
* Unknown keys are rejected. Each range must be within the metric's
|
||||
* physiological bounds and min < max.
|
||||
*/
|
||||
export const thresholdsUpdateSchema = z
|
||||
.object(
|
||||
Object.fromEntries(
|
||||
metricKeys.map((m) => [m, rangeSchemaFor(m).optional()]),
|
||||
),
|
||||
)
|
||||
.strict();
|
||||
|
||||
export type ThresholdsUpdatePayload = z.infer<typeof thresholdsUpdateSchema>;
|
||||
|
||||
/** Metric list kept exported here so admin and settings UI stay in sync. */
|
||||
export const ALL_METRICS: ThresholdMetric[] = metricKeys;
|
||||
@@ -1,9 +1,13 @@
|
||||
/**
|
||||
* German translations for zxcvbn feedback messages.
|
||||
* Used by both client-side password-strength component and server-side validation.
|
||||
* Locale-aware translations for zxcvbn-typescript feedback messages.
|
||||
*
|
||||
* The library only emits English warnings/suggestions, so we map them
|
||||
* client-side to the user's UI language. Used by both the
|
||||
* password-strength component and the server-side checkPasswordStrength.
|
||||
*/
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
const warnings: Record<string, string> = {
|
||||
const warningsDe: Record<string, string> = {
|
||||
"Straight rows of keys are easy to guess":
|
||||
"Gerade Tastenreihen sind leicht zu erraten",
|
||||
"Short keyboard patterns are easy to guess":
|
||||
@@ -32,7 +36,7 @@ const warnings: Record<string, string> = {
|
||||
"Häufige Namen sind leicht zu erraten",
|
||||
};
|
||||
|
||||
const suggestions: Record<string, string> = {
|
||||
const suggestionsDe: Record<string, string> = {
|
||||
"Use a few words, avoid common phrases":
|
||||
"Verwende mehrere Wörter, vermeide gängige Phrasen",
|
||||
"No need for symbols, digits, or uppercase letters":
|
||||
@@ -59,6 +63,25 @@ const suggestions: Record<string, string> = {
|
||||
'Vorhersehbare Ersetzungen wie „@" statt „a" helfen nicht wesentlich',
|
||||
};
|
||||
|
||||
export function translateZxcvbn(text: string): string {
|
||||
return warnings[text] ?? suggestions[text] ?? text;
|
||||
// English: identity map for all known strings (the library already returns
|
||||
// English, but we wrap them so unknown values still pass through unchanged).
|
||||
const warningsEn: Record<string, string> = Object.fromEntries(
|
||||
Object.keys(warningsDe).map((key) => [key, key]),
|
||||
);
|
||||
const suggestionsEn: Record<string, string> = Object.fromEntries(
|
||||
Object.keys(suggestionsDe).map((key) => [key, key]),
|
||||
);
|
||||
|
||||
export interface ZxcvbnTranslations {
|
||||
translate(text: string): string;
|
||||
}
|
||||
|
||||
export function getZxcvbnTranslations(locale: Locale): ZxcvbnTranslations {
|
||||
const warnings = locale === "de" ? warningsDe : warningsEn;
|
||||
const suggestions = locale === "de" ? suggestionsDe : suggestionsEn;
|
||||
return {
|
||||
translate(text: string): string {
|
||||
return warnings[text] ?? suggestions[text] ?? text;
|
||||
},
|
||||
};
|
||||
}
|
||||
+6
-1
@@ -80,7 +80,12 @@ export function proxy(request: NextRequest) {
|
||||
const requestId =
|
||||
request.headers.get("x-request-id") || crypto.randomUUID();
|
||||
|
||||
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
|
||||
// 128 bits of random, base64-encoded. randomUUID().toString() only carries
|
||||
// ~122 bits and has a predictable structure that base64-encodes to a
|
||||
// partially guessable pattern.
|
||||
const nonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString(
|
||||
"base64",
|
||||
);
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set("x-nonce", nonce);
|
||||
requestHeaders.set("x-request-id", requestId);
|
||||
|
||||
Reference in new issue
Block a user