mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
chore(repo): untrack docs/superpowers + ignore AI tool artefacts
Past Claude / Superpowers session output (plans + design specs) was landing in the public repo. Files stay on disk locally but are no longer tracked. .gitignore extended to cover the usual AI-tooling scratch directories (.claude/, .cursor/, .codex/, .aider*) so future sessions don't sneak anything in either. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
0783261b44
commit
4a205f5348
8 files changed
+7
-2963
No files matched your search
@@ -39,3 +39,10 @@ next-env.d.ts
|
||||
|
||||
# prisma generated
|
||||
/src/generated/prisma
|
||||
|
||||
# AI session artefacts (kept locally, not committed)
|
||||
/docs/superpowers/
|
||||
.claude/
|
||||
.cursor/
|
||||
.codex/
|
||||
.aider*
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,359 +0,0 @@
|
||||
# Codex OAuth + Medizinische Insights — Design Spec
|
||||
|
||||
**Datum:** 2026-04-06
|
||||
**Status:** Approved
|
||||
**Scope:** Replace OpenAI API-Key flow with ChatGPT Codex OAuth, improve medical insight prompts
|
||||
|
||||
## 1. Problem
|
||||
|
||||
HealthLog uses user-provided OpenAI API keys for AI-powered health insights. The API quota ran out (`insufficient_quota`), breaking all insight generation and background jobs. Users shouldn't need to manage API billing.
|
||||
|
||||
## 2. Solution
|
||||
|
||||
Replace the API-key model with **OpenAI Codex OAuth** — users authenticate with their ChatGPT Pro/Max subscription, and insights run against their flat-rate plan. An admin-level API key serves as a global fallback.
|
||||
|
||||
## 3. Provider Hierarchy
|
||||
|
||||
```
|
||||
1. User has Codex OAuth connection → Codex Responses API (gpt-5.3-codex)
|
||||
2. Admin key configured → Standard OpenAI Chat Completions API (configurable model)
|
||||
3. Nothing configured → Local fallback texts (no-key-fallbacks.ts)
|
||||
```
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
### New Files
|
||||
|
||||
```
|
||||
src/lib/ai/
|
||||
provider.ts — resolveProvider(userId) → { type, client, model }
|
||||
codex-oauth.ts — PKCE generation, token exchange, refresh
|
||||
codex-client.ts — Codex Responses API client with auto-refresh
|
||||
openai-client.ts — Standard OpenAI client (admin key, configurable base URL)
|
||||
types.ts — Shared types (InsightResult, ProviderConfig, etc.)
|
||||
prompts/
|
||||
base-system.ts — Shared medical context, role, constraints
|
||||
general-status.ts — Holistic health assessment prompt
|
||||
blood-pressure.ts — ESC/ESH guideline-based BP analysis
|
||||
weight.ts — BMI context, trend analysis, plateau detection
|
||||
pulse.ts — Resting HR trends, stress correlation
|
||||
bmi.ts — WHO classification, age-adjusted
|
||||
medication-compliance.ts — Intake patterns, efficacy correlation
|
||||
schema.ts — Zod schema for InsightResult validation
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
|
||||
```
|
||||
src/app/api/auth/codex/authorize/route.ts — NEW: Start OAuth flow
|
||||
src/app/api/auth/codex/callback/route.ts — NEW: Handle OAuth callback
|
||||
src/app/api/auth/codex/disconnect/route.ts — NEW: Remove tokens
|
||||
src/app/api/admin/ai-settings/route.ts — NEW: Admin key management
|
||||
src/app/api/insights/generate/route.ts — MODIFY: Use provider abstraction
|
||||
src/app/api/insights/settings/route.ts — MODIFY: Show Codex status instead of API key
|
||||
src/app/api/insights/*/route.ts — MODIFY: New output schema
|
||||
src/lib/insights/*.ts — MODIFY: Use new prompts + provider
|
||||
src/lib/jobs/reminder-worker.ts — MODIFY: Use provider with auto-refresh
|
||||
src/proxy.ts — MODIFY: Add /api/auth/codex/callback to PUBLIC_PATHS
|
||||
prisma/schema.prisma — MODIFY: New fields, remove openaiKeyEncrypted
|
||||
```
|
||||
|
||||
### Removed Files/Code
|
||||
|
||||
```
|
||||
src/app/api/insights/settings/route.ts — OpenAI key PUT handler (replaced)
|
||||
src/lib/insights/prompt.ts — Single prompt file (replaced by prompts/)
|
||||
User.openaiKeyEncrypted — DB field removed
|
||||
```
|
||||
|
||||
## 5. OAuth Flow
|
||||
|
||||
### Authorization (User-initiated)
|
||||
|
||||
```
|
||||
1. User clicks "Mit ChatGPT verbinden" in Settings
|
||||
2. GET /api/auth/codex/authorize
|
||||
- Generate PKCE code_verifier (128 bytes, base64url)
|
||||
- Compute code_challenge = SHA-256(code_verifier), base64url
|
||||
- Generate random state parameter
|
||||
- Store code_verifier + state in session
|
||||
- Redirect to: chatgpt.com/authorize?
|
||||
response_type=code
|
||||
&code_challenge=<challenge>
|
||||
&code_challenge_method=S256
|
||||
&redirect_uri=https://healthlog.bombeck.io/api/auth/codex/callback
|
||||
&state=<state>
|
||||
3. User logs in at ChatGPT, grants access
|
||||
4. Redirect to /api/auth/codex/callback?code=...&state=...
|
||||
5. Validate state against session
|
||||
6. Exchange code for tokens (POST chatgpt.com/oauth/token)
|
||||
7. Encrypt access_token + refresh_token with AES-256-GCM
|
||||
8. Store in User record with expiresAt
|
||||
9. Redirect to /settings with success toast
|
||||
```
|
||||
|
||||
### Token Refresh (Automatic)
|
||||
|
||||
```
|
||||
Before every API call:
|
||||
if codexTokenExpiresAt < now + 5min:
|
||||
POST chatgpt.com/oauth/token (grant_type=refresh_token)
|
||||
Store new encrypted tokens + new expiresAt
|
||||
If refresh fails:
|
||||
Increment failCount
|
||||
If failCount >= 3 over 72h:
|
||||
Set codexConnectionStatus = 'expired'
|
||||
Fall back to admin key silently
|
||||
Else:
|
||||
Retry on next call
|
||||
|
||||
On 401 response:
|
||||
Refresh token immediately, retry request once
|
||||
```
|
||||
|
||||
### Disconnect
|
||||
|
||||
```
|
||||
DELETE /api/auth/codex/disconnect
|
||||
- Delete tokens from DB
|
||||
- Revoke at OpenAI if endpoint exists
|
||||
- Audit log entry
|
||||
```
|
||||
|
||||
## 6. Database Changes
|
||||
|
||||
### Prisma Schema
|
||||
|
||||
```prisma
|
||||
model User {
|
||||
// REMOVE:
|
||||
// openaiKeyEncrypted String?
|
||||
|
||||
// ADD:
|
||||
codexAccessTokenEncrypted String?
|
||||
codexRefreshTokenEncrypted String?
|
||||
codexTokenExpiresAt DateTime?
|
||||
codexConnectedAt DateTime?
|
||||
codexConnectionStatus String @default("disconnected")
|
||||
// Values: "connected", "disconnected", "expired"
|
||||
}
|
||||
|
||||
model AppSettings {
|
||||
// ADD:
|
||||
adminAiKeyEncrypted String?
|
||||
adminAiModel String @default("gpt-4o-mini")
|
||||
adminAiBaseUrl String @default("https://api.openai.com/v1")
|
||||
}
|
||||
```
|
||||
|
||||
### Migration
|
||||
|
||||
```sql
|
||||
-- Add new columns
|
||||
ALTER TABLE users ADD COLUMN codex_access_token_encrypted TEXT;
|
||||
ALTER TABLE users ADD COLUMN codex_refresh_token_encrypted TEXT;
|
||||
ALTER TABLE users ADD COLUMN codex_token_expires_at TIMESTAMP;
|
||||
ALTER TABLE users ADD COLUMN codex_connected_at TIMESTAMP;
|
||||
ALTER TABLE users ADD COLUMN codex_connection_status TEXT NOT NULL DEFAULT 'disconnected';
|
||||
|
||||
ALTER TABLE app_settings ADD COLUMN admin_ai_key_encrypted TEXT;
|
||||
ALTER TABLE app_settings ADD COLUMN admin_ai_model TEXT NOT NULL DEFAULT 'gpt-4o-mini';
|
||||
ALTER TABLE app_settings ADD COLUMN admin_ai_base_url TEXT NOT NULL DEFAULT 'https://api.openai.com/v1';
|
||||
|
||||
-- Remove old column
|
||||
ALTER TABLE users DROP COLUMN openai_key_encrypted;
|
||||
```
|
||||
|
||||
## 7. Insight Output Schema
|
||||
|
||||
### New InsightResult (replaces old changed/stable/drivers/nextSteps)
|
||||
|
||||
```typescript
|
||||
interface InsightResult {
|
||||
summary: string
|
||||
classification: 'optimal' | 'gut' | 'grenzwertig' | 'erhoht' | 'kritisch'
|
||||
findings: {
|
||||
label: string
|
||||
value: string
|
||||
assessment: 'positive' | 'neutral' | 'attention' | 'warning'
|
||||
guideline?: string
|
||||
}[]
|
||||
correlations: {
|
||||
factor: string
|
||||
effect: string
|
||||
confidence: 'hoch' | 'mittel' | 'gering'
|
||||
}[]
|
||||
recommendations: string[]
|
||||
dataQuality: {
|
||||
coverage: string
|
||||
gaps: string[]
|
||||
confidence: 'hoch' | 'mittel' | 'gering'
|
||||
}
|
||||
disclaimer: string
|
||||
}
|
||||
```
|
||||
|
||||
### Zod Validation
|
||||
|
||||
Every AI response is validated against a Zod schema before being stored.
|
||||
Invalid responses are logged as errors and the user gets a generic fallback.
|
||||
|
||||
## 8. Prompt Design
|
||||
|
||||
### Base System Prompt
|
||||
|
||||
```
|
||||
Du bist ein klinischer Gesundheitsanalyst mit Expertise in Innerer Medizin
|
||||
und Präventivmedizin. Deine Analyse basiert auf aktuellen Leitlinien
|
||||
(ESC/ESH 2023, WHO, DGE, DEGAM).
|
||||
|
||||
Regeln:
|
||||
- Evidenzbasiert: Referenziere Grenzwerte und Leitlinien explizit
|
||||
- Muster erkennen: Tageszeit, Wochentag, saisonale Trends
|
||||
- Korrelationen: Wechselwirkungen zwischen Medikation und Vitalwerten benennen
|
||||
- Limitationen: Klar kommunizieren was die Datenlage hergibt und was nicht
|
||||
- Kein Ersatz für ärztliche Diagnose — immer explizit als Disclaimer
|
||||
- Sprache: Deutsch, medizinisch präzise aber allgemeinverständlich
|
||||
- Antworte ausschließlich im vorgegebenen JSON-Schema
|
||||
```
|
||||
|
||||
### Per-Type Specialization
|
||||
|
||||
Each insight type adds domain-specific instructions:
|
||||
|
||||
- **Blood Pressure**: ESC/ESH classification (optimal <120/80, normal <130/85, ...), morning surge detection, white-coat vs. masked hypertension hints, medication correlation
|
||||
- **Weight**: BMI-adjusted analysis, 7/30/90-day slope, plateau detection (±0.5kg over 14+ days), realistic goal projection
|
||||
- **Pulse**: Resting HR zones (bradycardia <50, athletic 50-60, normal 60-100, tachycardia >100), trend stability, stress/recovery indicators
|
||||
- **BMI**: WHO classification, waist-to-height hints if available, age context
|
||||
- **Medication Compliance**: Pattern analysis (morning vs evening adherence), streak tracking, correlation with vital sign improvements
|
||||
- **General Status**: Cross-domain synthesis, risk stratification, most impactful change recommendation
|
||||
|
||||
## 9. UI Changes
|
||||
|
||||
### User Settings (/settings)
|
||||
|
||||
Replace "OpenAI API Key" section with:
|
||||
|
||||
```
|
||||
KI-Insights
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Status: 🟢 Verbunden mit ChatGPT │
|
||||
│ Verbunden seit: 02.04.2026 │
|
||||
│ │
|
||||
│ [Trennen] │
|
||||
│ │
|
||||
│ ℹ️ Insights werden über dein ChatGPT- │
|
||||
│ Abo generiert (keine zusätzlichen │
|
||||
│ Kosten). │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Or when disconnected:
|
||||
|
||||
```
|
||||
KI-Insights
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Status: ⚪ Nicht verbunden │
|
||||
│ │
|
||||
│ [Mit ChatGPT verbinden] │
|
||||
│ │
|
||||
│ ℹ️ Verbinde dein ChatGPT Pro/Max-Konto │
|
||||
│ um KI-gestützte Gesundheitsanalysen zu │
|
||||
│ erhalten. │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Admin Settings (/settings — ADMIN only)
|
||||
|
||||
```
|
||||
KI-Anbieter (Global)
|
||||
┌─────────────────────────────────────────┐
|
||||
│ API Key: [sk-...****________] [Testen] │
|
||||
│ Modell: [gpt-4o-mini ▾] │
|
||||
│ Base URL: [https://api.openai.com/v1] │
|
||||
│ │
|
||||
│ ℹ️ Wird als Fallback verwendet, wenn │
|
||||
│ ein User keine eigene ChatGPT- │
|
||||
│ Verbindung hat. │
|
||||
│ │
|
||||
│ [Speichern] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Insights Display
|
||||
|
||||
Update insight cards to render the richer InsightResult:
|
||||
- Classification badge with color (green/yellow/orange/red)
|
||||
- Findings list with assessment icons
|
||||
- Collapsible correlations section
|
||||
- Data quality indicator (small badge)
|
||||
- Disclaimer footer (smaller text)
|
||||
|
||||
## 10. Security
|
||||
|
||||
### OAuth Security
|
||||
- PKCE (S256) prevents authorization code interception
|
||||
- State parameter prevents CSRF
|
||||
- Tokens encrypted at rest (AES-256-GCM via existing crypto.ts)
|
||||
- Tokens never sent to client/browser
|
||||
- Refresh token rotation on every refresh
|
||||
- Callback route validates state against session
|
||||
|
||||
### Admin Key Security
|
||||
- Only ADMIN role can read/write
|
||||
- Key displayed masked in UI (sk-...****)
|
||||
- Rate limiting on admin key usage (prevents abuse by many users)
|
||||
- Encrypted at rest
|
||||
|
||||
### Audit Trail
|
||||
- Every OAuth connect/disconnect logged
|
||||
- Every insight generation logs: provider type, user, model, duration
|
||||
- Admin key changes logged
|
||||
|
||||
### Security Audits (3x)
|
||||
1. **Pre-Implementation**: OAuth flow validated against OWASP OAuth Security Checklist
|
||||
2. **Post-Implementation**: Code review agent checks all new files for injection, token leaks, unencrypted secrets
|
||||
3. **Post-Deploy**: Penetration test of OAuth endpoints (CSRF, token theft, callback manipulation)
|
||||
|
||||
## 11. Testing Strategy (TDD)
|
||||
|
||||
### Unit Tests (vitest)
|
||||
|
||||
| Suite | Coverage |
|
||||
|-------|----------|
|
||||
| `ai/provider.test.ts` | Provider hierarchy, correct client selection |
|
||||
| `ai/codex-oauth.test.ts` | PKCE generation, token exchange, refresh, error scenarios |
|
||||
| `ai/codex-client.test.ts` | Request format, auto-refresh on 401, retry with backoff |
|
||||
| `ai/openai-client.test.ts` | Chat completions format, custom base URL, error handling |
|
||||
| `ai/prompts/*.test.ts` | Prompt assembly per type, output schema validation with Zod |
|
||||
| `insights/*.test.ts` | Updated generators with new output schema |
|
||||
| `api/codex-callback.test.ts` | State validation, CSRF protection, token storage |
|
||||
| `api/admin-settings.test.ts` | ADMIN-only access, key encryption, validation |
|
||||
|
||||
### Integration Tests
|
||||
- OAuth flow end-to-end (mocked ChatGPT endpoint)
|
||||
- Background job token refresh + insight generation
|
||||
- Provider fallback chain under different DB states
|
||||
|
||||
## 12. CSP Changes
|
||||
|
||||
```diff
|
||||
- connect-src 'self' https://api.openai.com https://wbsapi.withings.net
|
||||
+ connect-src 'self' https://api.openai.com https://chatgpt.com https://wbsapi.withings.net
|
||||
```
|
||||
|
||||
## 13. Migration Path
|
||||
|
||||
1. Deploy new code with both old (openaiKeyEncrypted) and new fields
|
||||
2. Run migration to add new columns
|
||||
3. Remove old column in follow-up migration
|
||||
4. Users see "Nicht verbunden" in settings, can connect via OAuth
|
||||
5. Admin can configure fallback key immediately
|
||||
6. Old cached insights remain visible until refreshed
|
||||
|
||||
## 14. Out of Scope
|
||||
|
||||
- Multiple AI providers per user (only Codex OAuth OR admin key)
|
||||
- Custom model selection per user (model is fixed per provider)
|
||||
- Streaming responses (batch only, like current implementation)
|
||||
- OpenAI API key input for individual users (removed entirely)
|
||||
@@ -1,207 +0,0 @@
|
||||
# Blood Glucose Tracking — Design Spec
|
||||
|
||||
**Datum:** 2026-04-18
|
||||
**Status:** Draft
|
||||
**Scope:** Add `BLOOD_GLUCOSE` as a first-class measurement type with context-aware classification (fasting / postprandial / random / bedtime), user-selectable display unit (mg/dL ↔ mmol/L), reference ranges from ADA 2024 and DGIM 2023, and full integration across form, charts, targets, doctor PDF, and insights.
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
Users repeatedly request blood-sugar logging — most recently in **GitHub issue #73, point 8**. Diabetics, pre-diabetics, and people on metabolic-impacting medication (steroids, atypical antipsychotics) need a single place to log glucose alongside weight, BP, and pulse so the doctor PDF reflects the complete metabolic picture. Today they fall back to spreadsheets or vendor-locked apps. Adding glucose to HealthLog closes the largest remaining gap in the "vital sign" set and unlocks correlation insights (e.g. weight ↔ fasting glucose, sleep ↔ glucose variability).
|
||||
|
||||
## 2. Scope
|
||||
|
||||
In v1 we ship:
|
||||
|
||||
- A new `BLOOD_GLUCOSE` measurement type stored canonically in **mg/dL**.
|
||||
- A required **context tag** per reading: `FASTING`, `POSTPRANDIAL`, `RANDOM`, `BEDTIME`.
|
||||
- A user preference `glucoseUnit` (`MG_DL` | `MMOL_L`, default `MG_DL` for `de` users, can flip in settings) — display only; storage is canonical.
|
||||
- Context-aware classification (see §4) and a target card on `/targets` for fasting glucose; secondary line for postprandial when data exists.
|
||||
- Health chart with diabetes/pre-diabetes band overlays.
|
||||
- Doctor PDF row + classification line.
|
||||
- Insights descriptor "glucose control" (fasting avg + variability).
|
||||
|
||||
**Stretch (v1.1, not in this spec):** HbA1c as a separate measurement type with its own classification (<5.7% / 5.7–6.4% / ≥6.5%, ADA 2024). Schema is forward-compatible (just add another enum value).
|
||||
|
||||
## 3. Data Model
|
||||
|
||||
```prisma
|
||||
enum MeasurementType {
|
||||
WEIGHT
|
||||
BLOOD_PRESSURE_SYS
|
||||
BLOOD_PRESSURE_DIA
|
||||
PULSE
|
||||
BODY_FAT
|
||||
SLEEP_DURATION
|
||||
ACTIVITY_STEPS
|
||||
BLOOD_GLUCOSE // NEW
|
||||
}
|
||||
|
||||
enum GlucoseContext { // NEW
|
||||
FASTING
|
||||
POSTPRANDIAL
|
||||
RANDOM
|
||||
BEDTIME
|
||||
|
||||
@@map("glucose_context")
|
||||
}
|
||||
|
||||
model Measurement {
|
||||
// ...existing fields
|
||||
glucoseContext GlucoseContext? @map("glucose_context") // NEW, only set when type = BLOOD_GLUCOSE
|
||||
}
|
||||
|
||||
model User {
|
||||
// ...existing fields
|
||||
glucoseUnit String @default("MG_DL") @map("glucose_unit") // "MG_DL" | "MMOL_L"
|
||||
}
|
||||
```
|
||||
|
||||
Migration `20260418_add_blood_glucose`:
|
||||
1. `ALTER TYPE measurement_type ADD VALUE 'BLOOD_GLUCOSE';`
|
||||
2. `CREATE TYPE glucose_context AS ENUM (...);`
|
||||
3. `ALTER TABLE measurements ADD COLUMN glucose_context glucose_context;`
|
||||
4. `ALTER TABLE users ADD COLUMN glucose_unit text NOT NULL DEFAULT 'MG_DL';`
|
||||
5. Add `CHECK ((type = 'BLOOD_GLUCOSE') = (glucose_context IS NOT NULL))` to enforce coupling.
|
||||
|
||||
The unique constraint `(userId, type, measuredAt, source)` already prevents duplicates; context is intentionally **not** part of the key — the same timestamp can't legally carry two contexts.
|
||||
|
||||
Storage unit is always **mg/dL** (`unit = "mg/dL"` in the row) so downstream analytics, exports, and Withings ingest don't need to branch.
|
||||
|
||||
## 4. Classifications (`src/lib/analytics/classifications.ts`)
|
||||
|
||||
New helpers, ranges from **ADA Standards of Care 2024** (Diabetes Care, Vol. 47, Suppl. 1) and **DGIM/DDG S3-Leitlinie 2023**:
|
||||
|
||||
```ts
|
||||
export type GlucoseContext = "FASTING" | "POSTPRANDIAL" | "RANDOM" | "BEDTIME";
|
||||
|
||||
export interface GlucoseClassification {
|
||||
category: string;
|
||||
color: string;
|
||||
severity: "info" | "normal" | "warning" | "danger";
|
||||
}
|
||||
|
||||
export function classifyGlucose(
|
||||
mgdl: number,
|
||||
context: GlucoseContext,
|
||||
): GlucoseClassification;
|
||||
```
|
||||
|
||||
Boundaries (mg/dL — mmol/L conversion shown for traceability):
|
||||
|
||||
| Context | Hypo (<70 / <3.9) | Normal | Pre-diabetes | Diabetes |
|
||||
|---------|-------------------|--------|--------------|----------|
|
||||
| FASTING | danger | 70–99 (3.9–5.5) normal | 100–125 (5.6–6.9) warning | ≥126 (≥7.0) danger |
|
||||
| POSTPRANDIAL (2h) | danger | <140 (<7.8) normal | 140–199 (7.8–11.0) warning | ≥200 (≥11.1) danger |
|
||||
| RANDOM | danger | <140 normal | 140–199 warning | ≥200 danger |
|
||||
| BEDTIME | danger | 90–150 normal (info if outside) | — | ≥250 danger |
|
||||
|
||||
Color tokens reuse the existing Dracula palette (`#50fa7b` normal, `#f1fa8c` info, `#ffb86c` / `#ff79c6` warning, `#ff5555` danger). Add `getGlucoseFastingRange()` returning `{ min: 70, max: 99 }` for the target card range bar, and `getGlucosePostprandialRange()` for the optional secondary bar.
|
||||
|
||||
## 5. UI
|
||||
|
||||
**Measurement form (`src/components/measurements/measurement-form.tsx`):**
|
||||
- New entry in `MEASUREMENT_TYPES`: `{ value: "BLOOD_GLUCOSE", labelKey: "measurements.typeGlucose", unitKey: "measurements.unitGlucose", placeholder: "95" }`.
|
||||
- When `type === "BLOOD_GLUCOSE"`, render an additional `<Select>` for context (4 options). Default = `FASTING` between 04:00–10:00 local time, else `RANDOM`.
|
||||
- Unit suffix is resolved via `useGlucoseUnit()` hook reading `user.glucoseUnit`; the input value is converted to mg/dL before POST.
|
||||
- Min 20, max 600 mg/dL (or equivalent mmol/L); inputs accept 1 decimal in mmol/L mode, integers in mg/dL mode.
|
||||
|
||||
**Targets page (`src/app/targets/page.tsx`):**
|
||||
- New `TYPE_ICONS["BLOOD_GLUCOSE"]` = `Droplet` (Lucide), color token `text-dracula-yellow`.
|
||||
- Card title "Fasting glucose" with `RangeBar` 70–99, orangeMin 100, orangeMax 125 (puts pre-diabetes in yellow zone, diabetes in red).
|
||||
- If postprandial data exists in last 30 days, render a second labelled `RangeBar` below using `getGlucosePostprandialRange()`, mirroring the BP-diastolic pattern already in `TargetCard`.
|
||||
- Source link → `https://diabetesjournals.org/care/issue/47/Supplement_1` (ADA 2024).
|
||||
|
||||
**Health chart (`src/components/charts/health-chart.tsx`):**
|
||||
- New chart preset on `/charts` page: `types: ["BLOOD_GLUCOSE"]`, color `var(--dracula-yellow)`, `valueBands` for pre-diabetes (100–125) and diabetes (≥126) overlays, plus a `ReferenceLine` at 70 (hypo).
|
||||
- Filter chips for context (All / Fasting / Postprandial / Random / Bedtime) — implemented as a client-side filter on the dataset, not a new API call.
|
||||
- Y-axis unit follows user pref (mg/dL or mmol/L); points are converted on render.
|
||||
|
||||
**Insights:** Add `glucose-control.ts` insight returning `{ avgFasting, fastingInTargetPct, postprandialPeak, variability, classification }`. AI-friendly descriptor injected into the general-status prompt: `"glucose control: avg fasting {avg} mg/dL ({class}), {pct}% of fasting readings in target."`
|
||||
|
||||
**Doctor PDF (`src/lib/doctor-report-pdf.ts`):**
|
||||
- `TYPE_LABEL_KEYS["BLOOD_GLUCOSE"] = "doctorReport.typeGlucose"`.
|
||||
- `TYPE_UNIT_KEYS["BLOOD_GLUCOSE"]` resolves to user pref via a new `unitFor(type, ctx)` extension.
|
||||
- Add `BLOOD_GLUCOSE` to `vitalTypes` array — table row appears automatically.
|
||||
- New optional section "Glukose-Klassifikation" below the BP block: shows fasting average + classification key, plus postprandial average if `count > 0`, with i18n keys `doctorReport.glucoseClassificationTitle`, `doctorReport.glucoseFastingRow`, `doctorReport.glucosePostprandialRow`.
|
||||
- Distribution table per context (n per FASTING/POSTPRANDIAL/RANDOM/BEDTIME) so the GP sees coverage.
|
||||
|
||||
## 6. API
|
||||
|
||||
No new endpoints. `POST /api/measurements` already accepts `type`, `value`, `measuredAt`, `notes` — extend the Zod schema in `src/lib/validations/measurement.ts` with optional `glucoseContext: z.enum([...])` and a refinement: `BLOOD_GLUCOSE` requires the context, other types reject it. The form sends mg/dL; conversion happens client-side.
|
||||
|
||||
`/api/analytics` and `/api/insights/targets` add `BLOOD_GLUCOSE` to their type whitelists and emit a `glucose` summary object with per-context aggregates when ≥1 reading exists.
|
||||
|
||||
`PATCH /api/auth/me` (already exists for profile updates) gains an optional `glucoseUnit` field.
|
||||
|
||||
## 7. Conversion Helpers
|
||||
|
||||
New module `src/lib/glucose.ts`:
|
||||
|
||||
```ts
|
||||
export const GLUCOSE_FACTOR = 18.0182; // mg/dL per mmol/L (molar mass of glucose)
|
||||
|
||||
export function mgdlToMmol(mgdl: number): number {
|
||||
return Math.round((mgdl / GLUCOSE_FACTOR) * 10) / 10; // 1 decimal
|
||||
}
|
||||
|
||||
export function mmolToMgdl(mmol: number): number {
|
||||
return Math.round(mmol * GLUCOSE_FACTOR); // integer
|
||||
}
|
||||
|
||||
export function formatGlucose(mgdl: number, unit: GlucoseUnit, t: T): string;
|
||||
```
|
||||
|
||||
Round-trip rule: `mmolToMgdl(mgdlToMmol(x))` must be within ±1 mg/dL of `x` for x ∈ [40, 400]. Tested in §9.
|
||||
|
||||
## 8. i18n Keys (`messages/de.json` + `messages/en.json`)
|
||||
|
||||
| Key | DE | EN |
|
||||
|---|---|---|
|
||||
| `measurements.typeGlucose` | Blutzucker | Blood glucose |
|
||||
| `measurements.unitGlucose.mgdl` | mg/dL | mg/dL |
|
||||
| `measurements.unitGlucose.mmol` | mmol/L | mmol/L |
|
||||
| `measurements.glucoseContextLabel` | Kontext | Context |
|
||||
| `measurements.glucoseContextFasting` | Nüchtern | Fasting |
|
||||
| `measurements.glucoseContextPostprandial` | Nach dem Essen (2 h) | Post-meal (2h) |
|
||||
| `measurements.glucoseContextRandom` | Zufällig | Random |
|
||||
| `measurements.glucoseContextBedtime` | Vor dem Schlafen | Bedtime |
|
||||
| `targets.glucoseFastingTitle` | Nüchtern-Blutzucker | Fasting glucose |
|
||||
| `targets.glucosePostprandialTitle` | 2-h-Postprandial | 2h post-meal |
|
||||
| `targets.glucoseSourceAda` | ADA 2024 / DGIM 2023 | ADA 2024 / DGIM 2023 |
|
||||
| `classifications.glucoseHypo` | Hypoglykämie | Hypoglycemia |
|
||||
| `classifications.glucoseNormal` | Normal | Normal |
|
||||
| `classifications.glucosePrediabetes` | Prädiabetes | Pre-diabetes |
|
||||
| `classifications.glucoseDiabetes` | Diabetes-Bereich | Diabetes range |
|
||||
| `doctorReport.typeGlucose` | Blutzucker | Blood glucose |
|
||||
| `doctorReport.glucoseClassificationTitle` | Glukose-Klassifikation | Glucose classification |
|
||||
| `doctorReport.glucoseFastingRow` | Ø Nüchtern: {avg} {unit} — {class} | Avg fasting: {avg} {unit} — {class} |
|
||||
| `doctorReport.glucosePostprandialRow` | Ø Postprandial: {avg} {unit} — {class} | Avg postprandial: {avg} {unit} — {class} |
|
||||
| `settings.glucoseUnitLabel` | Blutzucker-Einheit | Blood glucose unit |
|
||||
| `insights.glucoseControlTitle` | Glukose-Kontrolle | Glucose control |
|
||||
|
||||
## 9. Nyquist Validation
|
||||
|
||||
- **Unit conversion**: property test — for every integer mg/dL in [40, 400], `mmolToMgdl(mgdlToMmol(x))` differs from `x` by ≤1.
|
||||
- **Classification boundaries**: assert `classifyGlucose(99, "FASTING").severity === "normal"`, `classifyGlucose(100, "FASTING").severity === "warning"`, `classifyGlucose(125, ...) === "warning"`, `classifyGlucose(126, ...) === "danger"`, `classifyGlucose(69, "FASTING").severity === "danger"` (hypo). Same for postprandial at 139/140/199/200.
|
||||
- **Schema refinement**: `POST /api/measurements` with `BLOOD_GLUCOSE` and missing `glucoseContext` returns 400; with non-glucose type and a `glucoseContext` returns 400.
|
||||
- **End-to-end smoke**: form submit → DB row → chart render → PDF generation; assert mg/dL stored regardless of UI unit, assert PDF row appears in `vitalRows`.
|
||||
- **Doctor PDF snapshot test**: render with mixed contexts, assert classification line and per-context distribution rows.
|
||||
|
||||
## 10. Risks
|
||||
|
||||
- **Hypoglycemia notifications**: showing "severe low — seek help" on every reading <54 mg/dL could panic users with sensor noise or delayed entries. Notifications are **opt-in** via NotificationPreference (`MEASUREMENT_ANOMALY` event), default off, and the message text always ends with "HealthLog ist kein Medizinprodukt — bei akuten Symptomen 112 anrufen."
|
||||
- **Unit confusion**: a user flipping the unit pref must not reinterpret historical mg/dL values as mmol/L. Migration sets a default; UI shows the unit suffix on every value.
|
||||
- **Withings overlap**: Withings supports glucose via some BGM integrations. Document that Withings sync stays disabled for glucose in v1 (out of scope) to avoid double-entry races.
|
||||
- **Context defaulting**: auto-selecting `FASTING` based on time of day is a heuristic; the user must always be able to override before save.
|
||||
|
||||
## 11. Out of Scope
|
||||
|
||||
- CGM (Dexcom / Libre) live integration.
|
||||
- Ketone (β-hydroxybutyrate) tracking.
|
||||
- Insulin dose log and bolus calculator.
|
||||
- Carb counting / meal logging.
|
||||
- HbA1c (deferred to v1.1, schema is ready).
|
||||
|
||||
## 12. Effort
|
||||
|
||||
**M (medium).** Roughly: schema + migration (S), classifications + conversion lib + tests (S), form/context selector + unit pref (M), targets card + chart preset (S), insight + PDF row + i18n (M), Nyquist tests (S). Estimate 1.5–2 dev-days end-to-end including review. No external dependencies, no new infra.
|
||||
@@ -1,162 +0,0 @@
|
||||
# Built-in Feedback System — Design Spec
|
||||
|
||||
**Status:** Proposed (v1.2+)
|
||||
**Date:** 2026-04-18
|
||||
**Owner:** TBD
|
||||
**Tracking:** GitHub issue #73 (point 6)
|
||||
**Effort:** M
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
The current bug-report flow (`src/app/bugreport/page.tsx` + `src/app/api/bugreport/route.ts`) is gated on a GitHub token and repository configured by an admin. The status route returns `configured: false` when those are missing, and the UI degrades to an "ask your admin to configure GitHub" screen — so a non-technical owner of a self-hosted HealthLog instance cannot collect feedback at all without first procuring a GitHub PAT and dedicating a repo.
|
||||
|
||||
Verbatim from issue #73, point 6: *"Provide a simple built-in feedback system where users can submit reports without GitHub accounts, repositories, or tokens. The app should handle issue creation automatically in the background. Alternatively, provide a pre-configured default repository maintained by the developer."*
|
||||
|
||||
We need a feedback path that **works with zero external configuration**, while keeping the existing GitHub integration available as an **optional escalation** for instance owners who do want public issues.
|
||||
|
||||
## 2. Approach
|
||||
|
||||
- **Primary store: Postgres.** Every submission lands in a new `Feedback` table managed by Prisma. No external dependency required.
|
||||
- **Admin inbox UI** at `/admin#feedback` lists, filters and triages submissions.
|
||||
- **GitHub stays optional and per-item.** The admin can press "Publish to GitHub issue" on any feedback row; the existing route logic in `src/app/api/bugreport/route.ts` is refactored into a reusable helper and called from the new admin endpoint. If GitHub isn't configured, the button is hidden — the rest of the flow is unaffected.
|
||||
- **v1 admin notification: email** via the existing `src/lib/notifications/dispatcher.ts` (Telegram / ntfy / web-push reuse the same dispatcher). Optional; silent if no admin channel is configured.
|
||||
- **Existing `/api/bugreport` is deprecated but kept** for two release cycles so any external automation still works. Internally it forwards to the new feedback pipeline.
|
||||
|
||||
## 3. Data Model
|
||||
|
||||
New Prisma model in `prisma/schema.prisma`, additive only:
|
||||
|
||||
```prisma
|
||||
enum FeedbackCategory { BUG FEATURE_REQUEST QUESTION OTHER }
|
||||
enum FeedbackStatus { OPEN ACK RESOLVED ARCHIVED }
|
||||
|
||||
model Feedback {
|
||||
id String @id @default(cuid())
|
||||
userId String? // nullable — see auth note below
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
email String? // snapshot of submitter email at time of submission
|
||||
category FeedbackCategory @default(BUG)
|
||||
subject String @db.VarChar(200)
|
||||
description String @db.Text // ≤ 5000 chars enforced in Zod
|
||||
screenshotBase64 String? @db.Text // ≤ 5 MB; later moved to object storage
|
||||
metadata Json? // { url, userAgent, locale, appVersion, viewport, timestamp }
|
||||
status FeedbackStatus @default(OPEN)
|
||||
adminNote String? @db.Text
|
||||
gitHubIssueUrl String?
|
||||
notifiedEmail Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, createdAt])
|
||||
@@index([userId])
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `userId` is nullable to keep the door open for anonymous public-instance submissions in a later iteration; **v1 still requires auth** (`requireAuth()`), so the column will always be populated. The `email` snapshot survives user deletion for triage continuity.
|
||||
- `metadata` is a free-form JSON envelope mirroring the same context we'd otherwise paste into a GitHub issue body — we capture it once at submission time so the admin doesn't have to ask "what page were you on?".
|
||||
- An `AuditLog` row (`feedback.submit`, `feedback.status_change`, `feedback.publish_github`) follows the existing pattern in `src/lib/auth/audit.ts`.
|
||||
|
||||
## 4. API
|
||||
|
||||
All routes use `apiHandler` (Wide Events + x-request-id) and return the standard `{ data, error, meta }` envelope.
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/feedback` | user | Submit feedback. Rate-limited 5/h per `user.id`. Zod-validated. Returns `{ id, confirmationNumber }`. |
|
||||
| `GET` | `/api/admin/feedback` | admin | Paginated list. Query params: `status`, `category`, `q`, `page`, `pageSize` (default 20, cap 100). |
|
||||
| `GET` | `/api/admin/feedback/:id` | admin | Single item with raw `metadata` and screenshot. |
|
||||
| `PATCH`| `/api/admin/feedback/:id` | admin | Mutate `status` and/or `adminNote`. Writes audit log. |
|
||||
| `POST` | `/api/admin/feedback/:id/github` | admin | Publish to GitHub. 404 if GitHub not configured. Stores `gitHubIssueUrl` on success. |
|
||||
| `GET` | `/api/feedback/status` | user | Public capability probe: `{ available: true, githubAvailable: boolean }`. Replaces `/api/bugreport/status`'s "configured: false" wall. |
|
||||
|
||||
`apiSuccess` payloads use `confirmationNumber = id.slice(-6).toUpperCase()` so users can quote a short code without leaking the full cuid.
|
||||
|
||||
## 5. UI
|
||||
|
||||
### `/bugreport` (renamed conceptually to "Feedback", URL kept)
|
||||
- **Category** dropdown (Bug / Feature request / Question / Other).
|
||||
- **Subject** input (max 120 chars, required).
|
||||
- **Description** textarea (10–5000 chars, existing constraint).
|
||||
- **Screenshot** optional, same upload control as today, ≤ 5 MB, EXIF stripped client-side before base64 encoding.
|
||||
- **Submit** — always enabled when authenticated. No more "not configured" wall.
|
||||
- On success: toast "Thanks for your feedback — reference #ABC123".
|
||||
|
||||
### Admin section `/admin#feedback`
|
||||
- Table: created, category badge, subject, status badge, submitter, actions.
|
||||
- Filters: status (OPEN/ACK/RESOLVED/ARCHIVED), category, free-text search over `subject`/`description`.
|
||||
- Detail dialog: full description, screenshot preview, raw `metadata` JSON, admin note textarea, action buttons **Ack** / **Resolve** / **Archive** / **Publish to GitHub** (last one only when `githubAvailable`).
|
||||
- Empty states (no feedback yet, no results for filter) with translated copy.
|
||||
|
||||
## 6. Screenshot Handling
|
||||
|
||||
- **Storage:** base64 in the `screenshotBase64` column. Hard cap **5 MB per item** (matching the current `bugReportSchema.screenshot` limit) and a soft per-user **20 MB total quota** rejected at submit with a 413.
|
||||
- **EXIF stripping** in the browser via canvas re-encode before upload — prevents inadvertent GPS leak.
|
||||
- **PII warning** in the form copy: "Your screenshot may contain personal data. Crop it before uploading if unsure."
|
||||
- **Risk acknowledged in §13** — DB bloat is the obvious downside; v2 moves blobs to object storage.
|
||||
|
||||
## 7. Abuse & Safety
|
||||
|
||||
- Rate-limit via `src/lib/rate-limit.ts`: `feedback:submit:${user.id}` → 5 per rolling hour. Admin endpoints rate-limited too (`feedback:admin:${user.id}`, 60/min).
|
||||
- Description and subject are **plain text** — no HTML, no Markdown rendering on display in the admin inbox (use `<pre>` or a plain `<div>` with `whitespace-pre-wrap`). When publishing to GitHub we apply the same backtick-escape sanitization as the current bugreport route.
|
||||
- Length caps enforced in Zod and at the DB level (`@db.VarChar(200)` for subject).
|
||||
- **Auto-archive job** in `src/lib/jobs/`: daily pg-boss job marks `RESOLVED` items older than 180 days as `ARCHIVED`, and deletes `ARCHIVED` items older than 365 days. Configurable via `AppSettings`.
|
||||
- Inbox-size soft cap (e.g., 10 000 OPEN rows) surfaces a banner in the admin UI prompting cleanup.
|
||||
|
||||
## 8. Migration
|
||||
|
||||
- Additive Prisma migration only (new enum, new table). No existing data touched.
|
||||
- `/api/bugreport` route stays for **two release cycles**, internally calls the new feedback pipeline, and emits a `Deprecation` response header pointing to `/api/feedback`.
|
||||
- `/api/bugreport/status` keeps responding for legacy clients but its `configured` field now means "feedback works" (always `true` for authenticated users), with `hasGithub` exposed separately.
|
||||
- No data backfill required.
|
||||
|
||||
## 9. Notifications
|
||||
|
||||
- On a successful `POST /api/feedback`, dispatch through `src/lib/notifications/dispatcher.ts` to whichever admin channels are configured (email-first for v1; Telegram/ntfy/web-push fall out for free since they share the dispatcher).
|
||||
- Payload: category, subject, submitter username, deep link `/admin#feedback?id=...`. **No description body** in the notification — keeps PII out of email/push transports.
|
||||
- `notifiedEmail = true` after a successful send so retries don't double-fire.
|
||||
- If no channel is configured, dispatch is a no-op (logged as an info annotation, not a warning).
|
||||
|
||||
## 10. i18n
|
||||
|
||||
New translation namespace `feedback.*` in `messages/en.json` and `messages/de.json`:
|
||||
- Category names (`feedback.category.bug` etc.), status names, form labels, admin-inbox column headers, action button labels, empty states, toast/confirmation copy, PII warning text, deprecation banner on legacy bugreport endpoint.
|
||||
- Existing `bugreport.*` keys stay until the route is removed; new UI does not depend on them.
|
||||
|
||||
## 11. GitHub Publish
|
||||
|
||||
- Refactor the GitHub-issue creation in `src/app/api/bugreport/route.ts` into `src/lib/feedback/publish-github.ts` — pure function `publishToGithub(feedback): Promise<{ issueNumber, issueUrl }>`. Reused by both the deprecated bugreport route (transparently) and the new `POST /api/admin/feedback/:id/github`.
|
||||
- Idempotency: if `gitHubIssueUrl` is already set on the feedback row, return 409 with the existing URL.
|
||||
- Admin can append optional context via a textarea in the publish dialog; this is prepended to the issue body and **not** stored on the feedback row.
|
||||
|
||||
## 12. Nyquist Validation
|
||||
|
||||
Acceptance scenarios that must pass before sign-off:
|
||||
- Submit while logged out → 401.
|
||||
- Submit 6 times in an hour → 6th returns 429 with rate-limit headers.
|
||||
- Screenshot > 5 MB → 413, no DB row created.
|
||||
- Cumulative user quota > 20 MB → 413 with quota-specific error code.
|
||||
- Non-admin hits `/api/admin/feedback*` → 403.
|
||||
- Publish-to-GitHub round-trip: feedback row gains `gitHubIssueUrl`, repeat call returns 409.
|
||||
- Auto-archive job marks correct rows after fast-forwarding clock in test.
|
||||
- Notification dispatch is silent when no channel configured (info event, not warning).
|
||||
- Plain-text rendering verified — submitting `<script>alert(1)</script>` displays escaped in admin UI.
|
||||
- Wide Event payload includes `action.name=feedback.submit`, `meta.category`, `meta.has_screenshot`.
|
||||
|
||||
## 13. Risks
|
||||
|
||||
- **DB bloat from base64 screenshots.** Mitigation: per-item + per-user quotas now, object storage migration (S3/MinIO/Coolify volume) tracked as a follow-up. Schema is forward-compatible — `screenshotBase64` becomes nullable alongside a future `screenshotKey` column.
|
||||
- **Email transport coupling.** If the dispatcher fails, submission must still succeed; notifications are fire-and-forget after the DB row commits.
|
||||
- **Admin-inbox UX scaling** past a few hundred items needs server-side filtering (already in the API spec).
|
||||
|
||||
## 14. Out of Scope
|
||||
|
||||
- User-to-user comment threads on feedback items.
|
||||
- Voting / upvoting feedback.
|
||||
- Public read-only feedback board.
|
||||
- Anonymous (logged-out) submissions — schema-ready, intentionally not enabled in v1.
|
||||
- Importing existing GitHub issues back into the inbox.
|
||||
|
||||
## 15. Effort
|
||||
|
||||
**M** — one Prisma migration, ~6 new API routes (most thin), one form refactor, one new admin section, refactor of existing GitHub call into a helper, dispatcher hookup, one pg-boss job, ~30 i18n keys × 2 locales.
|
||||
@@ -1,227 +0,0 @@
|
||||
# User-Configurable Health Thresholds — Design Spec
|
||||
|
||||
**Datum:** 2026-04-18
|
||||
**Status:** Draft
|
||||
**Scope:** Per-user override layer for the metric target ranges currently rendered on `/targets` and used across analytics.
|
||||
|
||||
## 1. Problem
|
||||
|
||||
HealthLog computes target ranges from established medical guidelines:
|
||||
|
||||
- Weight from BMI 18.5–24.9 (WHO) via `getWeightRange` / `buildWeightBandsFromHeight`
|
||||
- BP targets age-personalized per ESC/ESH 2018 (`bp-targets.ts`, `getBpTargetsByAge`)
|
||||
- Pulse from CDC/NCHS percentiles or AHA fallback (`pulse-targets.ts`)
|
||||
- Body fat from ACE thresholds (`classifyBodyFat`)
|
||||
- Sleep duration from AASM/SRS (`getSleepDurationRange`)
|
||||
- Steps from WHO (`getStepsRange`)
|
||||
|
||||
These are sane defaults, but real patients receive **doctor-recommended individual targets**. A diabetic with autonomic neuropathy may have a BP target of 110/65–135/80; a marathoner may have a personal "green" pulse band of 38–55 that the AHA bands flag as bradycardia; a recovering anorexia patient may have a clinically supervised weight floor above the BMI-18.5 line. Today the UI exposes computed ranges with **no override mechanism**, and analytics, alerts, and the doctor-report PDF all silently use the guideline values.
|
||||
|
||||
## 2. Why (User Intent)
|
||||
|
||||
- Users may have doctor-recommended personalized targets that differ from the default ESC/ESH / WHO / AHA guidelines.
|
||||
- Current UI shows computed defaults with no override mechanism.
|
||||
- User wants a dedicated settings page where thresholds can be overridden per metric, with the default still visible for comparison and one-click reset.
|
||||
|
||||
## 3. Requirements
|
||||
|
||||
1. **Per-metric override** for the full set: `WEIGHT` (min/max), `BLOOD_PRESSURE_SYS` (low/high), `BLOOD_PRESSURE_DIA` (low/high), `PULSE` (min/max), `BODY_FAT` (min/max), `SLEEP_DURATION` (min/max in hours), `ACTIVITY_STEPS` (min/max). Reserve slots for `BLOOD_GLUCOSE_FASTING` and `BLOOD_GLUCOSE_POSTPRANDIAL` even though they are not yet measured — the schema should not need a migration when the metric ships.
|
||||
2. **Optional yellow zone**: each metric may carry a separate `warnMin/warnMax` (orange band) in addition to `min/max` (green band). Falls back to the default's orange computation (currently `±30%` span via `buildTrafficRange`) if absent.
|
||||
3. **Comparable to default**: every UI surface that renders a range must be able to show "Default: X / Your override: Y" — the API therefore returns `defaults`, `overrides`, and `effective` together.
|
||||
4. **Reset to default per metric** — single-click, no confirm needed unless overrides existed (see §11).
|
||||
5. **Source-of-truth precedence**: `effective = override ?? default(profile)`. Default still recomputes when profile (height, age, gender) changes, so users who never override see no change in behavior.
|
||||
6. **Server-side validation** — overrides must satisfy `min < max`, sit within hard sanity bounds, and pass plausibility checks (out-of-normal-range warns, see §11).
|
||||
7. **All consumers must use one helper** — no analytics module should call the raw default-computer directly anymore (see §6).
|
||||
8. **Audit trail** for every change (medical data, see §8).
|
||||
|
||||
## 4. Data Model
|
||||
|
||||
### Decision: JSON column on `User`, not a new table
|
||||
|
||||
Two options were considered:
|
||||
|
||||
**Option A — `UserThreshold` table** (one row per user/metric):
|
||||
- Pro: query-friendly, easy partial indexes, future reporting per metric.
|
||||
- Con: 9+ rows per user for a feature that is read together every page load; joins on every targets fetch; migration friction when adding metrics.
|
||||
|
||||
**Option B — JSON column on `User`** (`thresholdsJson Json?`):
|
||||
- Pro: read-with-user is one row, atomic write, schema-less for new metrics, mirrors Prisma 7 / Postgres `jsonb` strengths, validated by Zod at the application layer.
|
||||
- Con: cannot index individual thresholds in the DB (acceptable — there is no use case for "find all users whose pulse cap > 110").
|
||||
|
||||
**Choose Option B.** A `UserSetting` model does not currently exist, so we add the column directly to `User`:
|
||||
|
||||
```
|
||||
// prisma/schema.prisma — additive
|
||||
model User {
|
||||
...
|
||||
thresholdsJson Json? @map("thresholds_json")
|
||||
}
|
||||
```
|
||||
|
||||
### JSON shape (TypeScript, source of truth in `src/lib/validations/thresholds.ts`)
|
||||
|
||||
```ts
|
||||
type ThresholdOverride = {
|
||||
min?: number;
|
||||
max?: number;
|
||||
warnMin?: number; // optional yellow zone lower bound
|
||||
warnMax?: number; // optional yellow zone upper bound
|
||||
updatedAt: string; // ISO-8601, set server-side on every PUT
|
||||
};
|
||||
|
||||
type ThresholdsJson = {
|
||||
WEIGHT?: ThresholdOverride;
|
||||
BLOOD_PRESSURE_SYS?: ThresholdOverride;
|
||||
BLOOD_PRESSURE_DIA?: ThresholdOverride;
|
||||
PULSE?: ThresholdOverride;
|
||||
BODY_FAT?: ThresholdOverride;
|
||||
SLEEP_DURATION?: ThresholdOverride;
|
||||
ACTIVITY_STEPS?: ThresholdOverride;
|
||||
BLOOD_GLUCOSE_FASTING?: ThresholdOverride; // reserved
|
||||
BLOOD_GLUCOSE_POSTPRANDIAL?: ThresholdOverride; // reserved
|
||||
};
|
||||
```
|
||||
|
||||
An override is "active" when `min` and `max` are both numbers; partial-only objects are rejected by Zod. Absence of a key === use default.
|
||||
|
||||
## 5. API
|
||||
|
||||
### `GET /api/user/thresholds`
|
||||
|
||||
Response (envelope `{ data, error, meta }`):
|
||||
```
|
||||
data: {
|
||||
defaults: { WEIGHT: { min, max, warnMin, warnMax, source }, ... },
|
||||
overrides: { PULSE: { min, max, warnMin?, warnMax?, updatedAt }, ... },
|
||||
effective: { WEIGHT: { min, max, warnMin, warnMax, isOverride: false }, ... },
|
||||
profile: { heightCm, age, gender }
|
||||
}
|
||||
```
|
||||
`defaults` is always populated (even for metrics the user has not measured, so the UI can render an empty slot with the default visible). `overrides` only contains keys the user has actually customized. `effective` is the merged result every analytics consumer should think in.
|
||||
|
||||
### `PUT /api/user/thresholds`
|
||||
|
||||
Body: partial map of metric → override (Zod-validated). Replaces only the listed metrics; unspecified keys are untouched.
|
||||
```
|
||||
{ "PULSE": { "min": 50, "max": 95 },
|
||||
"BLOOD_PRESSURE_SYS": { "min": 110, "max": 130, "warnMin": 105, "warnMax": 140 } }
|
||||
```
|
||||
Per-metric Zod rules (`src/lib/validations/thresholds.ts`):
|
||||
- `min < max`
|
||||
- if `warnMin` set: `warnMin <= min`; if `warnMax` set: `warnMax >= max`
|
||||
- hard sanity bounds (reject obviously broken input):
|
||||
- `WEIGHT`: 30–300 kg
|
||||
- `BLOOD_PRESSURE_SYS`: 70–220 mmHg
|
||||
- `BLOOD_PRESSURE_DIA`: 40–140 mmHg
|
||||
- `PULSE`: 30–220 bpm
|
||||
- `BODY_FAT`: 3–60 %
|
||||
- `SLEEP_DURATION`: 3–14 h
|
||||
- `ACTIVITY_STEPS`: 0–50000
|
||||
- `BLOOD_GLUCOSE_FASTING`: 50–250 mg/dL
|
||||
- `BLOOD_GLUCOSE_POSTPRANDIAL`: 70–350 mg/dL
|
||||
- soft "out-of-normal-range" check returns a Zod warning (not error) — surfaced to the UI to trigger the confirm dialog (see §11).
|
||||
|
||||
### `DELETE /api/user/thresholds/:metric`
|
||||
|
||||
Removes one key from `thresholdsJson`. Returns the new `effective` block so the client can refresh without a second roundtrip.
|
||||
|
||||
All three routes are wrapped by `apiHandler` and `requireAuth()`, log Wide Events, and emit AuditLog entries (§8).
|
||||
|
||||
## 6. Integration Points
|
||||
|
||||
A single helper becomes the only public entry for "what is the target range":
|
||||
|
||||
```
|
||||
src/lib/analytics/effective-range.ts
|
||||
getEffectiveRange(metric, user, overrides?) → { min, max, warnMin, warnMax, source, isOverride }
|
||||
```
|
||||
|
||||
It internally calls the existing default computers (`getWeightRange`, `getBpTargets`, `getPersonalizedPulseTarget`, `classifyBodyFat`'s table, `getSleepDurationRange`, `getStepsRange`) and applies any matching override. `source` becomes `"User override"` when `isOverride === true`, otherwise the existing guideline string.
|
||||
|
||||
Refactor in scope:
|
||||
- `src/lib/analytics/classifications.ts` — `classifyBP`, `classifyPulse`, `classifyBodyFat`, `classifySleepDuration`, `classifySteps`, `getWeightRange`, `getPulseRange`, `getSleepDurationRange`, `getBpTargetsByAge`: keep as pure default computers, but stop being called from feature code. Add thin `classifyXWithRange(value, range)` variants that take the resolved range.
|
||||
- `src/lib/analytics/value-bands.ts` — `buildTrafficRange` and `buildWeightBandsFromHeight` accept an optional `override` argument and prefer it over the BMI-derived band.
|
||||
- `src/lib/analytics/pulse-targets.ts` — `getPersonalizedPulseTarget` stays as the default; new `resolvePulseTarget(user, overrides)` lives in `effective-range.ts`.
|
||||
- `src/lib/analytics/bp-targets.ts` — `getBpTargets` likewise stays; `effective-range.ts` wraps it.
|
||||
- `src/app/api/insights/targets/route.ts` (the API behind `/targets`) reads `thresholdsJson` once and passes it to `getEffectiveRange` per metric, then returns the `effective` block to the client. The `source` field already shown on `TargetCard` flips to "User override" automatically.
|
||||
- Doctor-report PDF (`src/lib/doctor-report-pdf.ts`) and Wide-Event annotations include `isOverride` so reports clearly mark customized targets.
|
||||
|
||||
## 7. Migration
|
||||
|
||||
Additive only — single Prisma migration adds `users.thresholds_json jsonb`. Existing users get `NULL`, which `getEffectiveRange` treats as "no overrides → behave exactly as before". No data backfill, no flag rollout.
|
||||
|
||||
## 8. Auditing
|
||||
|
||||
Every `PUT` and `DELETE` writes one `AuditLog` row (model already exists at `prisma/schema.prisma:391`):
|
||||
|
||||
- `action`: `"thresholds.update"` or `"thresholds.reset"`
|
||||
- `details` (JSON string): `{ metric, before: {min,max,warnMin,warnMax} | null, after: {...} | null, reason?: "user" | "out_of_range_confirmed" }`
|
||||
- `userId`, `ipAddress`, `location` from session context (already populated by `apiHandler`).
|
||||
|
||||
The `before` value is read from the current `thresholdsJson` *or* the computed default (so the audit trail captures the implicit baseline a user customized away from). Reset writes `after: null`.
|
||||
|
||||
## 9. i18n Keys
|
||||
|
||||
New section under `messages/en.json` and `messages/de.json`:
|
||||
|
||||
| Key | EN | DE |
|
||||
|---|---|---|
|
||||
| `thresholds.title` | "Custom thresholds" | "Eigene Zielwerte" |
|
||||
| `thresholds.intro` | "Override the medical default ranges with values your doctor recommended. Defaults stay visible for comparison and you can reset any metric to the guideline at any time." | "Ersetze die medizinischen Standardbereiche durch arztempfohlene Werte. Die Standardwerte bleiben sichtbar und du kannst jeden Wert jederzeit auf die Leitlinie zurücksetzen." |
|
||||
| `thresholds.defaultLabel` | "Default" | "Standard" |
|
||||
| `thresholds.overrideLabel` | "Your override" | "Eigener Wert" |
|
||||
| `thresholds.enableToggle` | "Use custom value" | "Eigenen Wert verwenden" |
|
||||
| `thresholds.warnZoneLabel` | "Yellow zone (optional)" | "Gelber Bereich (optional)" |
|
||||
| `thresholds.minLabel` | "Min" | "Min" |
|
||||
| `thresholds.maxLabel` | "Max" | "Max" |
|
||||
| `thresholds.resetButton` | "Reset to default" | "Auf Standard zurücksetzen" |
|
||||
| `thresholds.resetPrompt` | "Reset {metric} to the medical default?" | "{metric} auf den medizinischen Standardwert zurücksetzen?" |
|
||||
| `thresholds.saved` | "Thresholds saved" | "Zielwerte gespeichert" |
|
||||
| `thresholds.outOfRangeWarn` | "This value lies outside the typical medical range. Please confirm only if your doctor recommended it." | "Dieser Wert liegt außerhalb des üblichen medizinischen Bereichs. Bitte nur bestätigen, wenn dein Arzt das empfohlen hat." |
|
||||
| `thresholds.notMedicalAdvice` | "HealthLog does not provide medical advice. Custom thresholds are stored as-is." | "HealthLog ersetzt keine ärztliche Beratung. Eigene Zielwerte werden unverändert gespeichert." |
|
||||
| `thresholds.validation.minLessMax` | "Min must be less than max" | "Min muss kleiner als Max sein" |
|
||||
| `thresholds.validation.warnEnvelope` | "Yellow zone must surround the green zone" | "Der gelbe Bereich muss den grünen Bereich umschließen" |
|
||||
| `thresholds.validation.outOfBounds` | "Value outside allowed range ({min}–{max} {unit})" | "Wert außerhalb des erlaubten Bereichs ({min}–{max} {unit})" |
|
||||
|
||||
Existing `targets.*` keys stay; the targets page gains a small "Customize" link routing to `/settings#thresholds`.
|
||||
|
||||
## 10. UI
|
||||
|
||||
New section in the existing settings shell at `/settings#thresholds`:
|
||||
|
||||
- One card per metric. Header shows the metric label + icon (reuses `TYPE_ICONS` from `targets/page.tsx`).
|
||||
- Body left: read-only "Default: 60 – 100 bpm" with the source citation (same external link logic as `getTargetSourceLink`).
|
||||
- Body right: an "Use custom value" toggle. When on, two number inputs (min, max) plus an optional collapsible "Yellow zone" with `warnMin`/`warnMax`.
|
||||
- Inline validation via React Hook Form + Zod (the same schema the server uses).
|
||||
- Footer: "Reset to default" button (visible only when an override exists).
|
||||
- Out-of-range soft warnings render as an inline orange callout with a "I confirm my doctor recommended this" checkbox required to enable Save.
|
||||
- Persistent disclaimer banner at the top: `thresholds.notMedicalAdvice`.
|
||||
|
||||
## 11. Nyquist Validation (tests in `src/lib/analytics/__tests__/effective-range.test.ts` and route tests)
|
||||
|
||||
- `getEffectiveRange` returns the override when present and the default when absent (one test per metric).
|
||||
- Boundary tests: `min === max` rejected; `warnMin === min` accepted; off-by-one above/below the hard sanity bounds rejected/accepted.
|
||||
- Profile-change test: removing `heightCm` collapses the WEIGHT default to "no range", but a present override survives.
|
||||
- API contract: `GET` returns `defaults`, `overrides`, `effective`, `profile`. `PUT` with partial body leaves untouched metrics intact. `DELETE :metric` removes only that key.
|
||||
- AuditLog firing: spy that one row is written per `PUT`/`DELETE`, with correct `before`/`after`. Confirm `details` JSON parses.
|
||||
- Out-of-range confirmation: PUT with a value flagged "out_of_normal_range" requires `?confirm=1` query param (or a body flag) — without it the route returns `400 confirmation_required`.
|
||||
- i18n parity test: every new EN key has a DE counterpart (re-uses the existing parity test if present, else add).
|
||||
|
||||
## 12. Risks & Mitigations
|
||||
|
||||
- **Users set unhealthy targets.** Hard sanity bounds reject obvious nonsense; soft "out-of-normal-range" warns and demands explicit confirmation. Persistent banner ("not medical advice"). Audit trail enables forensic review.
|
||||
- **Insights/AI prompts may quote a user-customized range as if it were the medical guideline.** The provider prompts must receive the `source` field and explicitly distinguish "User override" from a guideline citation. Update `src/lib/ai/prompts/*` accordingly.
|
||||
- **Doctor-report PDF.** Mark each customized range with a small "(custom)" tag so a treating physician sees that the report's coloring reflects the patient's own targets, not the guideline.
|
||||
- **Default drift over time.** If we update guidelines (e.g. ESC/ESH 2024), users with overrides are unaffected — but users without overrides will see their colored bands shift. Document in the changelog.
|
||||
|
||||
## 13. Out of Scope
|
||||
|
||||
- Sharing thresholds between users / household.
|
||||
- Doctor export of overrides (a treating physician downloading the override JSON to reuse) — could come later as part of the doctor report.
|
||||
- Time-bounded thresholds ("until 2026-12-31"), seasonal thresholds, schedule-based thresholds.
|
||||
- Per-medication or per-condition threshold packs.
|
||||
- Threshold suggestions from AI insights.
|
||||
|
||||
## 14. Effort
|
||||
|
||||
**M (medium).** One Prisma migration, one new helper module, three new API routes, refactoring of ~5 analytics consumers to go through `getEffectiveRange`, one new settings card, ~14 i18n keys per locale, and one new test file. No external integrations, no UI primitive work (existing shadcn `Card`/`Input`/`Switch`/`Button` cover everything). Estimated 1.5–2 focused days for a senior dev including tests and audit-log wiring.
|
||||
@@ -1,117 +0,0 @@
|
||||
# Dashboard Customization — Design Spec
|
||||
|
||||
**Status:** Proposed (v1.2+)
|
||||
**Date:** 2026-04-18
|
||||
**Owner:** TBD
|
||||
|
||||
## Why
|
||||
|
||||
- Users have different health focus areas. Someone not tracking blood pressure shouldn't see an empty BP card.
|
||||
- Current "hide if no data" rule (`showBodyFatCard = (bf?.count ?? 0) > 0`, etc. in `src/app/page.tsx`) is implicit — users can't pin the cards they care about, and a card vanishes the moment they delete the last entry.
|
||||
- This is for a v1.2+ release, not part of the current bugfix push.
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
The dashboard at `src/app/page.tsx` renders a fixed set of trend cards and charts (weight, BP sys/dia, pulse, body fat, mood, BMI) in a hard-coded order. Visibility is implicit (derived from data presence), ordering is non-negotiable. Narrowly-focused users carry visual noise; users with strong preferences cannot reorder. We need explicit, server-persisted, per-user control over which widgets appear and in what order.
|
||||
|
||||
## 2. Requirements
|
||||
|
||||
- **Per-user widget visibility.** Each chart and trend card is a "widget" that the user can show or hide independently.
|
||||
- **Widget ordering — list-based reorder with up/down buttons + numeric position.** Defended below.
|
||||
- **"Reset to defaults" action** that restores the v1.0 behavior (everything visible, default order).
|
||||
- **Server-persisted** so settings follow the user across devices (web + PWA on phone). localStorage only acts as an optimistic cache.
|
||||
- **i18n-friendly.** Widget IDs are stable English keys; display labels resolve through existing `dashboard.*` translation keys (`messages/en.json`, `messages/de.json`).
|
||||
|
||||
**Reorder UX choice:** list-based (up/down arrows) over drag-and-drop. Reasons: (a) mobile-first PWA — drag-on-touch with scroll containers is fragile; (b) keyboard/screen-reader accessible by default; (c) no extra dep (`@dnd-kit` ~30 KB). DnD can be added later behind a flag.
|
||||
|
||||
## 3. Data Model
|
||||
|
||||
**Decision: extend `User` with a `dashboardWidgets Json?` column.** No new table.
|
||||
|
||||
```prisma
|
||||
model User {
|
||||
// ...
|
||||
dashboardWidgets Json? @map("dashboard_widgets")
|
||||
}
|
||||
```
|
||||
|
||||
Shape (validated by Zod on read/write):
|
||||
```ts
|
||||
type DashboardWidgetState = {
|
||||
version: 1;
|
||||
widgets: Array<{ id: WidgetId; visible: boolean; order: number }>;
|
||||
};
|
||||
```
|
||||
|
||||
`WidgetId` is a string-literal union seeded from a server-side registry (`src/lib/dashboard/widgets.ts`):
|
||||
`"weight" | "bp" | "pulse" | "body_fat" | "mood" | "bmi"`.
|
||||
|
||||
**Tradeoffs vs. dedicated `DashboardWidget` model:** JSON wins because the widget set is small (<20 forecast), state is read once per dashboard load, and we never need to query "which users hide widget X." A table would cost a migration + FK + N writes per reorder for ~6 rows, with no queryability payoff. Cost: schema drift risk — mitigated by the Zod gate and the `version` field, which lets us evolve the shape without a SQL migration.
|
||||
|
||||
## 4. API
|
||||
|
||||
Two routes under `src/app/api/dashboard/widgets/route.ts`, both wrapped in `apiHandler` and `requireAuth()`.
|
||||
|
||||
**`GET /api/dashboard/widgets`** — returns merged state (stored values overlaid on the registry; new widgets appear at the end with `visible: true`):
|
||||
```json
|
||||
{ "data": { "version": 1, "widgets": [
|
||||
{ "id": "weight", "visible": true, "order": 0 },
|
||||
{ "id": "mood", "visible": false, "order": 5 }
|
||||
]}, "error": null, "meta": {...} }
|
||||
```
|
||||
|
||||
**`PUT /api/dashboard/widgets`** — replaces full state. Zod schema in `src/lib/validations/dashboard.ts`:
|
||||
```ts
|
||||
const dashboardWidgetsSchema = z.object({
|
||||
version: z.literal(1),
|
||||
widgets: z.array(z.object({
|
||||
id: z.enum(WIDGET_REGISTRY.map(w => w.id)),
|
||||
visible: z.boolean(),
|
||||
order: z.int().min(0),
|
||||
})).min(1),
|
||||
}).refine(s => new Set(s.widgets.map(w => w.id)).size === s.widgets.length);
|
||||
```
|
||||
Unknown IDs and duplicates are rejected. Mutations invalidate `queryKeys.dashboardLayout()`.
|
||||
|
||||
**`DELETE /api/dashboard/widgets`** — clears the column → user gets defaults again ("Reset to defaults").
|
||||
|
||||
## 5. UI
|
||||
|
||||
**Location:** new section in `/settings` named **"Dashboard"** (matches existing settings architecture: Profile, Notifications, Integrations, etc.). Plus a small **"Customize"** icon button on the dashboard header next to the "Add" dropdown that deep-links to `/settings/dashboard`.
|
||||
|
||||
**Flow** (settings page):
|
||||
1. Header "Dashboard widgets" + subhead "Choose which cards and charts appear on your dashboard."
|
||||
2. Ordered list of shadcn `Card` rows — translated label (`t("dashboard.weight")`), short description, `Switch` for visibility, `ChevronUp`/`ChevronDown` reorder buttons (disabled at list ends).
|
||||
3. Sticky footer: outline "Reset to defaults" (confirm via `AlertDialog`) + primary "Save" (disabled until dirty).
|
||||
4. Optimistic update via TanStack Query mutation; on error, rollback + toast.
|
||||
|
||||
**Dashboard read path:** `src/app/page.tsx` becomes a thin orchestrator — fetch layout via `useQuery(queryKeys.dashboardLayout())`, sort by `order`, filter `visible`, render through a `<DashboardWidget id="…" />` switch. Existing data-presence guards stay as a safety net.
|
||||
|
||||
## 6. Migration
|
||||
|
||||
- Prisma migration: `ALTER TABLE users ADD COLUMN dashboard_widgets jsonb`.
|
||||
- No backfill. Null = "use defaults" = the v1.0 behavior frozen as `DEFAULT_LAYOUT` constant in `src/lib/dashboard/widgets.ts`. The `GET` endpoint synthesizes this on the fly.
|
||||
- First save writes the full normalized blob.
|
||||
- Existing users see no change until they open settings.
|
||||
|
||||
## 7. Nyquist Validation Criteria
|
||||
|
||||
- **Vitest:** Zod rejects duplicate IDs, unknown IDs, negative order, missing version.
|
||||
- **Vitest:** layout merger appends new registry widgets with `visible: true` when user state predates them.
|
||||
- **Vitest:** `DELETE` then `GET` returns `DEFAULT_LAYOUT`.
|
||||
- **API integration:** unauth `PUT` → 401; valid `PUT` → 200 + persisted; payload >50 KB → 413.
|
||||
- **Manual UAT:** hide BP on desktop → reload on phone PWA → BP gone. Reorder weight to last → reload → still last. Reset → defaults restored.
|
||||
- **A11y:** keyboard nav on reorder buttons, `aria-label` on Up/Down, screen-reader announces position changes.
|
||||
- **Perf:** dashboard initial render does not regress (React Profiler check).
|
||||
|
||||
## 8. Out of Scope
|
||||
|
||||
- Custom user-defined widgets / per-widget chart config (color, range, time window).
|
||||
- Widgets for future measurement types (blood glucose, SpO2, sleep stages, steps) — added to registry when those features ship.
|
||||
- Multiple dashboards or tabs; sharing layouts between users.
|
||||
- Drag-and-drop reorder (revisit if list UX gets complaints).
|
||||
- Per-widget grid size (1x/2x spans).
|
||||
|
||||
## 9. Estimated Effort
|
||||
|
||||
**M** — roughly 1–2 days. Schema + Zod + registry ~2h, API + tests ~3h, Settings UI ~4h, dashboard refactor ~3h, i18n + polish + UAT ~2h.
|
||||
@@ -1,216 +0,0 @@
|
||||
# Multi-provider AI Insights — Design Spec
|
||||
|
||||
**Date:** 2026-04-18
|
||||
**Status:** Draft
|
||||
**Scope:** Add Anthropic Claude and self-hosted (OpenAI-compatible) LLM endpoints alongside the existing OpenAI / ChatGPT Codex providers. Per-user provider choice, admin-level fallback, encrypted credential storage.
|
||||
|
||||
## 1. Problem
|
||||
|
||||
Today HealthLog can only talk to OpenAI — either the user's ChatGPT subscription via Codex OAuth, or an admin-managed OpenAI API key. That ties insight generation to a single vendor and to the public internet.
|
||||
|
||||
> "I'd like to pick my provider per user. OpenAI for some accounts, Claude for me, and on my home server I want to point HealthLog at a local Ollama instance — none of my BP readings should leave the LAN."
|
||||
> — User intent
|
||||
|
||||
Consequences of the single-vendor model:
|
||||
- No way to use Anthropic Claude despite better medical-reasoning answers.
|
||||
- Privacy-sensitive users (BP, mood, medication) have no on-prem option.
|
||||
- Vendor outages or quota exhaustion bring all insights down (already observed with OpenAI quota).
|
||||
|
||||
## 2. Current state
|
||||
|
||||
`src/lib/ai/provider.ts` exposes `resolveProvider(userId)` returning an `AIProvider`. The contract in `src/lib/ai/types.ts` is small and provider-agnostic:
|
||||
|
||||
```ts
|
||||
export type ProviderType = "codex" | "admin-key" | "none";
|
||||
export interface AIProvider {
|
||||
type: ProviderType;
|
||||
generateCompletion(params: CompletionParams): Promise<CompletionResult>;
|
||||
}
|
||||
export interface CompletionParams {
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
}
|
||||
export interface CompletionResult {
|
||||
content: string;
|
||||
tokensUsed: number | null;
|
||||
model: string;
|
||||
providerType: ProviderType;
|
||||
}
|
||||
```
|
||||
|
||||
Implementations: `OpenAIClient` (Chat Completions, `response_format: json_object`, captures upstream body excerpt for diagnostics), `CodexClient` (chatgpt.com Responses API with refresh-on-401). Selection is hard-coded: Codex if connected, else admin OpenAI key, else `NoProvider`.
|
||||
|
||||
What needs to change:
|
||||
- Widen `ProviderType` to include `anthropic` and `local`.
|
||||
- Add per-user provider choice (currently only Codex is per-user).
|
||||
- Add per-user model + base-URL + API key fields, mirrored on `AppSettings`.
|
||||
- Two new clients (`AnthropicClient`, `LocalOpenAICompatibleClient`).
|
||||
- New selection function that honors user preference before admin defaults.
|
||||
|
||||
## 3. Target abstraction
|
||||
|
||||
The `AIProvider` interface stays exactly as-is. New concrete clients implement it without touching call-sites in `src/app/api/insights/*`.
|
||||
|
||||
```
|
||||
ProviderType = "codex" | "openai" | "anthropic" | "local" | "admin-key" | "none"
|
||||
```
|
||||
(`"admin-key"` retained for back-compat with already-cached `InsightResult.providerType` strings.)
|
||||
|
||||
Selection order in `resolveProvider(userId)`:
|
||||
1. **User explicit choice** (`User.aiProvider`) with valid credentials.
|
||||
2. **Codex OAuth** if user picked `CHATGPT_OAUTH` and tokens are present (existing path).
|
||||
3. **Admin default** (`AppSettings.adminAiProvider` + admin key) for users with no personal config.
|
||||
4. **`NoProvider`** — surface a clear error to the route handler.
|
||||
|
||||
The function is the single boundary: every call that needs an LLM goes through it. No client is constructed elsewhere.
|
||||
|
||||
## 4. Data model
|
||||
|
||||
`User` model additions (all nullable; user keeps current behaviour until they opt in):
|
||||
|
||||
```prisma
|
||||
aiProvider String? @map("ai_provider") // OPENAI | ANTHROPIC | LOCAL | CHATGPT_OAUTH
|
||||
aiModel String? @map("ai_model") // e.g. claude-sonnet-4-6
|
||||
aiBaseUrl String? @map("ai_base_url") // local endpoint
|
||||
aiOpenAiKeyEncrypted String? @map("ai_openai_key_encrypted")
|
||||
aiAnthropicKeyEncrypted String? @map("ai_anthropic_key_encrypted")
|
||||
aiLocalKeyEncrypted String? @map("ai_local_key_encrypted") // optional bearer for local
|
||||
```
|
||||
|
||||
`AppSettings` mirrors:
|
||||
|
||||
```prisma
|
||||
adminAiProvider String @default("OPENAI") @map("admin_ai_provider")
|
||||
adminAiAnthropicKeyEncrypted String? @map("admin_ai_anthropic_key_encrypted")
|
||||
adminAiAnthropicModel String? @default("claude-sonnet-4-6") @map("admin_ai_anthropic_model")
|
||||
adminAiLocalBaseUrl String? @map("admin_ai_local_base_url")
|
||||
adminAiLocalKeyEncrypted String? @map("admin_ai_local_key_encrypted")
|
||||
adminAiLocalModel String? @map("admin_ai_local_model")
|
||||
```
|
||||
|
||||
The existing `adminAiKeyEncrypted` / `adminAiModel` / `adminAiBaseUrl` fields stay and represent the OpenAI configuration. One Prisma migration adds the columns; existing users have `aiProvider = NULL` and route through admin default exactly like today.
|
||||
|
||||
## 5. New clients
|
||||
|
||||
### AnthropicClient
|
||||
- `POST {baseUrl}/v1/messages` with `x-api-key` and `anthropic-version: 2023-06-01`.
|
||||
- `system` parameter for `params.systemPrompt`; `messages: [{ role: "user", content: params.userPrompt }]`.
|
||||
- Structured JSON via **tool use**: declare a single `record_insight` tool whose `input_schema` mirrors `insightResultSchema`. Set `tool_choice: { type: "tool", name: "record_insight" }`. Read JSON from the `tool_use` block's `input` and serialize to a string for `CompletionResult.content`.
|
||||
- Prompt caching: send the system prompt with `cache_control: { type: "ephemeral" }`. Insights re-run the same multi-paragraph medical preamble per metric — empirically ~90% cost reduction on cached reads.
|
||||
- Tokens: sum `usage.input_tokens + usage.output_tokens` (incl. `cache_read_input_tokens`).
|
||||
- Errors: capture body excerpt like `OpenAIClient` already does, scrub any `sk-ant-...` substrings before logging.
|
||||
|
||||
Default models (selectable in UI):
|
||||
- `claude-sonnet-4-6` (default — quality / cost balance)
|
||||
- `claude-opus-4-7` (highest quality)
|
||||
- `claude-haiku-4-5` (cheap, fast)
|
||||
|
||||
### LocalOpenAICompatibleClient
|
||||
- Same wire format as `OpenAIClient` (`/chat/completions`, optional `Authorization: Bearer ...` only if a key is set).
|
||||
- `baseUrl` is required and user-configurable (e.g. `http://localhost:11434/v1` for Ollama, `http://lm-studio.lan:1234/v1`).
|
||||
- Some local backends reject `response_format: { type: "json_object" }`. Strategy: feature-detect on first failure (HTTP 400 with body containing `response_format`), persist `User.aiLocalSupportsJsonMode = false`, and re-issue without the field. Append `"Return JSON only, no prose."` to the user prompt and run output through `jsonrepair` before `JSON.parse`.
|
||||
- No-telemetry mode: never set `User-Agent` beyond `HealthLog/local`, never include user IDs in headers, do not send the request body to Wide Event annotation when provider is `local` (only `model`, `latencyMs`, `tokensUsed`).
|
||||
|
||||
## 6. UI — `/settings#ai`
|
||||
|
||||
Single dropdown `aiProvider` with options OpenAI / Anthropic Claude / Local LLM / ChatGPT (OAuth). Conditional fields:
|
||||
|
||||
| Provider | Visible fields |
|
||||
| ------------ | --------------------------------------------------------------- |
|
||||
| OpenAI | API key, model (default `gpt-4o-mini`) |
|
||||
| Anthropic | API key, model dropdown (sonnet/opus/haiku) |
|
||||
| Local | Base URL, model name, optional API key, "Disable JSON mode" hint |
|
||||
| ChatGPT OAuth| Existing Codex connect button |
|
||||
|
||||
Each provider section has a **Test connection** button that POSTs to `/api/insights/test` with a tiny prompt (`"Reply with {\"ok\":true}"`), surfacing the upstream status + body excerpt on failure.
|
||||
|
||||
Admin settings page gains the same dropdown for the global default plus an explanatory note: *"Used only for users who have not configured their own provider."*
|
||||
|
||||
## 7. Backward compatibility
|
||||
|
||||
- Migration is additive — no column drops, no data rewrites.
|
||||
- `User.aiProvider IS NULL` ⇒ behave exactly as today (Codex if connected, else admin OpenAI).
|
||||
- `CompletionResult.providerType = "admin-key"` continues to be emitted by `OpenAIClient` so cached insights and analytics dashboards keep working. New clients use new strings.
|
||||
- Existing tests in `src/lib/ai/__tests__` keep passing; new tests are additive.
|
||||
|
||||
## 8. Error handling
|
||||
|
||||
Each client follows the `OpenAIClient` pattern: capture up to 500 chars of the upstream body, attach `{ httpStatus, upstream, model }` to the thrown `Error`, let `apiHandler` log it as a Wide Event. Per-provider mapping in `src/lib/ai/errors.ts`:
|
||||
|
||||
| Upstream | User-facing message (i18n key) |
|
||||
| --------------------------------------- | ----------------------------------------- |
|
||||
| Anthropic 401 / `invalid_api_key` | `insights.error.anthropicInvalidKey` |
|
||||
| Anthropic 429 / `rate_limit_error` | `insights.error.anthropicRateLimited` |
|
||||
| Anthropic 529 / `overloaded_error` | `insights.error.anthropicOverloaded` |
|
||||
| Local fetch ECONNREFUSED | `insights.error.localUnreachable` |
|
||||
| Local 400 mentioning `response_format` | auto-retry once, then `insights.error.localNoJsonMode` |
|
||||
| OpenAI 429 `insufficient_quota` | existing key |
|
||||
|
||||
## 9. Internationalisation
|
||||
|
||||
New keys (EN + DE) under `settings.ai.*` and `insights.error.*`:
|
||||
|
||||
```
|
||||
settings.ai.providerLabel "AI Provider" / "KI-Anbieter"
|
||||
settings.ai.providerOpenAI "OpenAI"
|
||||
settings.ai.providerAnthropic "Anthropic Claude"
|
||||
settings.ai.providerLocal "Local LLM"
|
||||
settings.ai.providerChatGptOAuth "ChatGPT (OAuth)"
|
||||
settings.ai.baseUrlLabel "Endpoint URL" / "Endpunkt-URL"
|
||||
settings.ai.baseUrlHint.local "z.B. http://localhost:11434/v1"
|
||||
settings.ai.modelLabel "Model" / "Modell"
|
||||
settings.ai.testConnection "Test connection" / "Verbindung testen"
|
||||
settings.ai.testSuccess "Connection OK ({model}, {ms} ms)"
|
||||
settings.ai.testFailure "Failed: {detail}"
|
||||
settings.ai.localPrivacyNote "Requests stay on your network. Bodies are not logged."
|
||||
insights.error.anthropicInvalidKey "Anthropic key rejected. Check Settings → AI."
|
||||
insights.error.anthropicRateLimited "Anthropic rate-limited. Try again shortly."
|
||||
insights.error.anthropicOverloaded "Anthropic is overloaded. Try again in a minute."
|
||||
insights.error.localUnreachable "Local LLM endpoint not reachable: {url}"
|
||||
insights.error.localNoJsonMode "Local model rejected JSON mode — falling back to text parsing."
|
||||
```
|
||||
|
||||
## 10. Security
|
||||
|
||||
- All keys stored via existing AES-256-GCM helpers in `src/lib/crypto.ts`. Never persist plaintext.
|
||||
- Anthropic keys begin with `sk-ant-`. The Wide Event redactor (`src/lib/logging/event-builder.ts`) gets a regex `/sk-ant-[A-Za-z0-9_\-]{20,}/g` → `"sk-ant-***"` applied to all `externalCalls[].errorBody`.
|
||||
- Local provider runs with `next/server` `fetch` against arbitrary URLs — validate that `aiBaseUrl` parses as `http`/`https`, reject `file://`, `data://`, and (in production) bare IPs in private ranges *unless* `LOCAL_LLM_ALLOW_PRIVATE=true` (default true on self-hosted, false on `apps-01` build).
|
||||
- Test-connection endpoint is `requireAuth` and rate-limited (5 req/min/user) to prevent SSRF probing.
|
||||
- Admin keys remain admin-only via `requireAdmin()`.
|
||||
|
||||
## 11. Nyquist validation
|
||||
|
||||
- **Unit (Vitest)** — one `*.test.ts` per new client, mocking `fetch`:
|
||||
- `anthropic-client.test.ts`: success path returns parsed JSON from tool_use; 401 maps to error; cache_control header present.
|
||||
- `local-client.test.ts`: success path; auto-retry on `response_format` 400; ECONNREFUSED handled; bearer header omitted when no key.
|
||||
- **Integration** — extend `provider.test.ts`:
|
||||
- User with `aiProvider = ANTHROPIC` and key → returns `AnthropicClient`.
|
||||
- User with no preference falls back to admin default of each kind.
|
||||
- Codex still wins when user picks `CHATGPT_OAUTH` and is connected.
|
||||
- **End-to-end smoke** — manual: switch provider in `/settings#ai`, hit `/api/insights/general-status`, verify `providerType` in cached `InsightResult`.
|
||||
|
||||
## 12. Risks & mitigations
|
||||
|
||||
- **Admin changes default provider, breaks users.** Mitigated by precedence rule: any user with `aiProvider != NULL` is unaffected. Migration backfills nothing — admin only impacts opted-out users.
|
||||
- **Local model returns malformed JSON.** Two-layer defence: tool-use where supported, `jsonrepair` fallback otherwise; on persistent failure surface `dataQuality.confidence = "gering"` and the raw text in a debug field.
|
||||
- **Cached insight from old provider.** Cache key already includes `providerType`; switching provider invalidates naturally on next generate.
|
||||
- **Anthropic prompt-caching cost regression** if system prompt is rebuilt per request without cache_control. Mitigation: emit a Wide Event metric `ai.cache_read_tokens` and add a Grafana panel.
|
||||
|
||||
## 13. Out of scope
|
||||
|
||||
- Vendor-specific features beyond JSON output (Anthropic citations, OpenAI function-calling tool registries, Google Gemini).
|
||||
- Streaming responses to the UI (insights are batch-generated).
|
||||
- Multi-region routing or load-balancing across providers.
|
||||
- Per-metric provider override (one provider per user, applied to all metrics).
|
||||
- Cost dashboards beyond the existing `tokensUsed` field.
|
||||
|
||||
## 14. Effort
|
||||
|
||||
**M–L.** ~2 days dev:
|
||||
- 0.5d schema migration + `resolveProvider` rewrite + types.
|
||||
- 0.5d `AnthropicClient` (tool-use, caching, errors).
|
||||
- 0.5d `LocalOpenAICompatibleClient` + JSON-mode feature detection + jsonrepair.
|
||||
- 0.25d settings UI + test-connection endpoint.
|
||||
- 0.25d i18n + tests + docs.
|
||||
Reference in new issue
Block a user