mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
* fix(measurements): close issue #109 + sync body composition across UI/PDF/thresholds User-visible: TOTAL_BODY_WATER, BONE_MASS, and BLOOD_GLUCOSE now appear in the measurements filter, badge, mobile icon, and edit dialog (issue #109). Server-rendered doctor report now includes body composition rows (audit C-9). Body water + bone mass have proper effective-range thresholds so severity logic doesn't silently return `nominal` (audit C-15). Structural: extracted the per-type display maps from measurement-list.tsx into measurement-list-meta.ts as a deliberate first step toward the single-source manifest planned for phase P5. Coverage tests now fail-fast if a future enum addition skips any of: list label/icon/color, doctor-PDF label/unit/vital-types, chart label, threshold metric order/label/bounds. Audit-2026-05-07 / phase P0: - closes #109 (filter UI gap, root cause for the German raw-enum display) - closes audit C-9 (server PDF used a stale type map) - closes audit C-15 (no warn/critical bands for body composition) Tests: 282 → 296 (14 new). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: align OpenAPI spec + migration comment with body-composition reality - OpenAPI MeasurementType enum: add TOTAL_BODY_WATER + BONE_MASS (was at v1.3.0 baseline; spec drifted past the v1.3 server release) - Bump openapi version 1.3.0 → 1.3.2 to match package.json - Migration 0022 comment: TOTAL_BODY_WATER stores kg (water content), not percent of body weight. Validators (VALUE_RANGES) and the Withings client (type 77 → kg) treat it as kg already; the comment was the lone outlier and would mislead the next reviewer. Audit-2026-05-07 / phase P1a — closes audit M-B1 (partial; OpenAPI enum) and M-B2 (migration unit comment). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): close 3 audit CRITICALs (Withings header / idempotency Bearer / GlitchTip URL strip) C-3 (Withings webhook secret in URL): Now reads X-Withings-Webhook-Secret header in preference; URL ?secret=… fallback retained for legacy Withings configurations and emits a Wide Event warning so operators can spot still-using-the-old-flow integrators. The query-string secret was leaking into Coolify access logs and any GlitchTip event whose request.url got captured. C-4 (idempotency dead for Bearer-token clients): The default userIdResolver only consulted getSession(). Bearer-authed clients (the iOS / n8n / headless-API fleet that PR #117 was built for) fell through, so every retry created duplicate measurements. The default now tries cookie session first, then Bearer token via hashToken+apiToken.findUnique with revoked/expiry checks. All three existing call sites (measurements, mood-entries, medications/intake) get the fix automatically. H-B7 (request.url with secrets shipped to GlitchTip): reportToGlitchtip now strips the query string before forwarding the URL, so Withings legacy ?secret=… and any OAuth ?code=… that errors during the callback don't end up in someone else's incident UI. Audit-2026-05-07 / phase P2. Tests: 296 → 301 (5 new in idempotency.test for the Bearer fallback + revoked/expired/null cases). Deferred to a follow-up (needs DB migration / lock primitive): C-2 moodLog webhook → AES-GCM at rest + HMAC-over-body C-5 idempotency TOCTOU advisory_lock or in-flight sentinel row Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(measurements): add OXYGEN_SATURATION (SpO2) as first-class measurement type — v1.3.3 Closes the OxygenSaturation feature request from #109. - Migration 0024 extends MeasurementType enum (additive, IF NOT EXISTS) - Zod validators: plausibility 50–100% (below 50 is incompatible with sustained life; above 100 is physically impossible) - effective-range thresholds: green 95–100, orange 92–94, red <92. Sources: BTS Guideline 2017 (target 94–98%), ATS clinical practice (≥95% at rest is healthy), AHA/clinical literature (<92% = call provider, <88% = ER). Lower-only concern — upper orange wing collapses to greenMax since saturation cannot exceed 100%. - Personalisation: existing thresholds-override UI lets COPD / chronic-respiratory users with a doctor-set 88–92 baseline tune the bands. METRIC_ORDER + METRIC_LABEL_KEYS extended in thresholds-section.tsx; the exhaustive `Record<ThresholdMetric>` forces TS to catch any missed surface. - Withings ScanWatch type 54 mapping (best-effort — only fires for pulse-ox-capable devices). - Wired through measurement-form, measurement-list-meta (icon: Wind), health-chart, both doctor-report-pdf renderers (core + browser-side near-duplicate), OpenAPI spec (version 1.3.0 → 1.3.3), and i18n in 4 sections × 2 locales. - iOS DTO already declared `OXYGEN_SATURATION` — server enum addition closes the long-standing drift flagged in audit V2. - 4 new tests: numeric green band, upper-wing collapse, user-override (COPD baseline), METRIC_BOUNDS shape. Tests: 301 → 305. Typecheck: clean. Audit-2026-05-07 / v1.3.3 / closes #109 SpO2 feature request. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(analytics): truthfulness pass on medical citations + clamp override orangeMax Doctor-agent + hallucination-hunter audit V3 found six medical citations in effective-range.ts that the code did not actually implement. Plus a latent bug in the user-override path that produced physically-impossible orangeMax values (>100% saturation, <0% body-fat). Citation rewrites (no numeric changes): - ACTIVITY_STEPS: "WHO ≥8000 steps/day" → Saint-Maurice JAMA 2020. WHO publishes activity in min/week, not steps. Step-count was a misattribution that also leaked into AI prompts. - TOTAL_BODY_WATER: "ESPEN Body Composition Guidance 2017" → Watson formula / ICRP Reference Man. ESPEN 2017 is BIA practice guidance, it does not publish kg cut-offs. - BONE_MASS: "DEXA-equivalent bone mineral content" → "Bioimpedance- estimated; not DEXA-comparable". Withings BIA values typically run 5–7% below DEXA — calling them DEXA-equivalent in a doctor-report PDF was a real legal-exposure misattribution. - OXYGEN_SATURATION: "BTS Guideline 2017 (target 94–98%) + ATS clinical practice (≥95% at rest is healthy)" → "consumer pulse- oximeter consensus (≥95% normal at rest); slightly tighter than BTS 2017 (94–98%); NICE NG115 ≤92% escalation, BMJ panel / FDA pulse-oximeter labelling ≤88% ER threshold". Code uses 95-100/92-100 band — that's an ATS/consumer-consensus number, not BTS's literal 94-98 target. Soften the citation to match the code, not vice versa. Bug fix (1 latent, 1 real-today): - effective-range override path now clamps orangeMin/orangeMax to METRIC_BOUNDS.{min,max}. Without the clamp, a COPD user setting SpO2 override to {95,100} emitted orangeMax = 100.75 (impossible saturation), and a BODY_FAT override of {2,80} emitted orangeMin = -9.7 (negative percent). Default-range case 210 already hard-coded orangeMax: 100 for the same reason — the override path was the only branch missing the invariant. Tests: 305 → 306 (1 new: clamps override orangeMax to physiological 100%). Typecheck: clean. Audit-2026-05-07-V3 / closes Doctor + Senior-Dev medical-citation findings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): Bearer-scope wildcard handling + GDPR Art.17 account-delete erasure Two CRITICAL fixes from audit V3. NEW-V3-1: Bearer-scope bypass. apiToken.permissions ["*"] is meant to be the wildcard "session-equivalent" scope (the iOS app receives this on login). The previous check permissions.includes(requiredPermission) treated "*" as a literal string — never matched. Today many sensitive routes call requireAuth() *without* a requiredPermission, so a leaked iOS token can act as a full-scope token (account delete, settings wipe). Once those routes adopt requireAuth("scope:name"), this wildcard handling keeps the iOS app working while narrower-scoped tokens (e.g. user-issued ["medication:ingest"]) get correctly 403'd. Same wildcard handling added to the standalone permission checks in src/app/api/ingest/medication/route.ts:65, 119. NEW-V3-2: GDPR Art. 17 account-delete erasure completeness. Feedback + AuditLog rows had `onDelete: SetNull` in the schema, which preserved PII (free-text symptom descriptions, IP addresses, login-city geolocation) after the user was deleted. We now explicitly prisma.feedback.deleteMany + prisma.auditLog.deleteMany before the cascade-delete, making account deletion genuinely complete erasure. Tests 306/306 still pass; typecheck clean. Audit V3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(pdf): real text-content assertions for body composition + SpO2 rows Replaces bytes-only "renders body composition rows" theatre test with pdf-parse-driven text extraction that asserts the actual rendered labels + values for TOTAL_BODY_WATER, BONE_MASS, OXYGEN_SATURATION (DE + EN). Closes V3 audit finding STILL-V2-NEW-3: the previous test only verified "%PDF-" header + byte-count, so a regression that drops a row would still pass. Now a missing label fails the test. - adds dev dep: pdf-parse@^2.4.5 - 308/308 tests pass (was 306, +2 SpO2 + EN-locale coverage) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(measurements): close enum drift cousins — derive 5 type-arrays from canonical enum + extend 2 contract enums V3 audit finding "enum drift cousins": 7 hardcoded module-level type arrays silently dropped SpO2/TBW/BoneMass/BloodGlucose from their respective domains. Adding a new measurement type required hunting these down. Now all aggregation paths derive from `measurementTypeEnum.options`: - /api/insights/comprehensive (AI insights) - /api/dashboard/summary (iOS dashboard adapter) - /api/analytics (analytics aggregator) - /lib/insights/general-status (AI general-status fetch) - /api/import (Zod schema — round-trip now covers all 11 types) External-contract enums extended additively (no breakage for old clients): - /api/measurements/series kindEnum: + oxygen, totalBodyWater, boneMass - /api/dashboard/widgets widgetIdEnum: + oxygenSaturation - DashboardWidgetId + DEFAULT_DASHBOARD_LAYOUT: + oxygenSaturation row - formatMeasurementsForExport: glucoseContext now round-trips Coverage test (`measurement-type-enum-coverage.test.ts`) asserts the canonical enum stays the source of truth and documents PDF exclusions (BLOOD_GLUCOSE renders via context section, SLEEP/STEPS lifestyle-only). 313/313 tests pass (was 308, +5 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): close 6 V3-audit HIGH findings (CSP, web-push SSRF, IP geo, AI provider leak, import rate-limit) - CSP: chatgpt.com + api.openai.com only on /settings/ai/** routes (was blanket connect-src on every page including /auth/login → DOM-XSS exfil channel) - web-push: webPushSubscriptionSchema.endpoint requires HTTPS + isPublicUrl refinement; was z.url() only → blind SSRF probe via Push subscription - isPublicUrl: also fixes a regression where DNS labels starting with "fc"/"fd" (fcm.googleapis.com etc.) were falsely classified as IPv6 unique-local. Check is now gated on a colon being present in the host. - /api/ai/test: stop returning provider err.message + bodyExcerpt to the client (leaked provider URLs / partial keys / internal headers). Now returns categorised generic message; full details land in the wide-event via annotate() for the operator. - /lib/geo (auditLog IP geo lookup): switch default provider to ipwho.is over HTTPS, accept ipwho.is + ip-api pro response shapes, add IP_GEO_LOOKUP_DISABLED env, refuse non-HTTPS configured URLs. Previously leaked IP+timestamp over plaintext HTTP every auth event (GDPR Art. 32 + Art. 44). - /api/import: add 5/hour rate-limit (was unlimited; bulk-injection vector) Tests: 325/325 pass (was 313, +12 new — webPushSubscriptionSchema SSRF guards, fc/fd-prefix domain regression, geo HTTPS-only, geo provider shapes, geo opt-out env). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security,gdpr): trusted-proxy IP, audit-log retention, idempotency cachable filter, Bearer mock tightening V3 audit follow-on bundle covering 4 HIGH findings. (I) getClientIp — TRUST_PROXY_HOPS env (default 1) replaces blind XFF trust. Reads chain right-to-left so client-supplied leftmost entries cannot rotate the per-IP rate-limit bucket. Default 1 matches typical single-proxy self-host (Coolify/Caddy/Cloudflare-Tunnel); 0 forces x-real-ip / null. New 7-test suite covers the rotation attack + malformed entries + zero-hop fallback. (F) Audit-log retention job — daily 03:15 cleanup of audit_logs older than AUDIT_LOG_RETENTION_DAYS (default 365). Closes GDPR Art. 5(1)(e) "storage limitation" gap; previously IPs + city + login events accumulated forever. 6-test suite covers default + override + misconfig guard (rejects <7 days). (G) isCachableStatus — extracted the inline 4xx/5xx filter into a named, exported, tested function. 7 cases cover the full do-not-cache contract (401 / 403 / 408 / 429 / any 5xx) and confirm 2xx + 4xx validation are still cached. (H) Bearer-mock tightening — apiToken.findUnique mocks in require-auth-bearer.test + idempotency.test now assert the where.tokenHash shape uses the hashed value (not the raw bearer). A regression that switched back to raw-token comparison would have been silent without this assertion. Tests: 345/345 pass (was 332, +13 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(security): encrypt moodLog webhook secret at rest with AES-256-GCM (V3 audit STILL-V2-C-2) The mood_log_webhook_secret column previously stored a plaintext secret that the webhook handler compared timing-safe against every enabled user. A DB dump or backup snapshot leaked active webhook credentials. New contract — no schema migration required: - Writes go through encryptMoodLogSecret() (AES-256-GCM via existing crypto helper, ENCRYPTION_KEY). - Reads go through readMoodLogSecret() which transparently decrypts envelopes and tolerates legacy plaintext rows during the transition (graceful upgrade path — no breaking change for self-hosted users). - Webhook lookup decrypts each candidate before timing-safe compare. - Status endpoint decrypts for the user's settings page. One-shot startup migration in reminder-worker rotates any leftover plaintext rows on next worker boot. Idempotent; encrypted rows are skipped. Tests: 350/350 pass (was 345, +5 new — round-trip, legacy passthrough, isLegacyPlaintext semantics, rotateLegacy idempotence). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: sync README + AGENTS + CLAUDE + CHANGELOG + OpenAPI for v1.3.3 (V3 audit doc-drift) V3 audit Doku Specialist findings — drift between code and meta-docs. - README §Key Features now mentions pulse oximetry (SpO₂) + Withings ScanWatch sync; Tech Stack model count corrected 23 → 25. - AGENTS.md status promoted v1.3.2 → v1.3.3; vitest config corrected `vitest.config.ts` → `vitest.config.mts`; migration range corrected `0001–0022` → `0001–0024`; model count + list updated to 25 (adds Device, IdempotencyKey). - CLAUDE.md model count updated (2 occurrences). - CHANGELOG.md v1.3.3 expanded with Security + Internal sections that were missing the audit-fix-marathon work (Bearer-scope, account-delete cascade, moodLog encryption, CSP tightening, Web-Push SSRF, IP-geo HTTPS, AI-test leak fix, import rate-limit, trusted-proxy XFF, audit-log retention, idempotency cachable filter, Bearer mock tightening, enum-drift cousins, PDF text-content tests). - OpenAPI Bearer description corrected `SHA-256 hashes` → `HMAC-SHA-256 hashes (keyed with API_TOKEN_HMAC_KEY)` so the docs match the actual server-side implementation. Wildcard scope contract documented. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(audit-review): close 4 HIGH + 2 MEDIUM findings from multi-agent review pass Multi-agent review of the audit-fix branch surfaced four HIGH issues and a couple of MEDIUM hardenings. All addressed before merge. HIGH-1 — OpenAPI MetricKind enum drift (iOS strict-decode crash risk). The dashboard-summary route now emits totalBodyWater/boneMass/ oxygenSaturation MetricCards, but the spec still listed only the original 7 kinds. Generated iOS Codable would have crashed the dash the moment a user logged SpO2. MetricKind extended with the 3 new kinds + comment documenting unknown-value defensive decoding. HIGH-2 — kindEnum naming consistency on /api/measurements/series. The series adapter shipped "oxygen" while the dashboard summary used "oxygenSaturation"; both routes now use "oxygenSaturation" so iOS decoders can share a single MetricKind type across endpoints. HIGH-3 — /api/ai/test no-leak guard had no test. Added 4 tests that mock provider errors with sk-leaked-key + provider URLs in the err fields and assert the response body never echoes them, plus the 3-status-categorised generic messages (401/429/5xx/other). HIGH-4 — /api/import 5/hour rate-limit had no test. Added 2 tests that mock checkRateLimit allowed/disallowed and assert the 429 path uses the right key shape (`import:<userId>`, 5, 1h) and that the within- quota path proceeds. MEDIUM-3 — getClientIp under-length XFF chain regression. With TRUST_PROXY_HOPS=2 and a chain of length 1, the previous clamp returned the leftmost (attacker-controlled) entry, silently re-introducing the rotation attack the helper was meant to close. Now: when chain is shorter than configured hops, refuse XFF entirely (fall back to x-real-ip / null). Added regression test. LOW-1 — TRUST_PROXY_HOPS=garbage now throws at boot instead of silently degrading to hops=0. Added regression test. Tests: 358/358 pass (was 350, +8 new). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
634 lines
24 KiB
Plaintext
634 lines
24 KiB
Plaintext
generator client {
|
|
provider = "prisma-client"
|
|
output = "../src/generated/prisma"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
}
|
|
|
|
// ─── User & Auth ───────────────────────────────────────────
|
|
|
|
model User {
|
|
id String @id @default(cuid())
|
|
username String @unique
|
|
email String? @unique
|
|
passwordHash String? @map("password_hash") // argon2id, optional fallback
|
|
role String @default("USER") // "ADMIN" | "USER"
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
// Profile
|
|
heightCm Float? @map("height_cm")
|
|
dateOfBirth DateTime? @map("date_of_birth")
|
|
gender String? @map("gender") // "MALE" | "FEMALE" | null = keine Angabe
|
|
timezone String @default("Europe/Berlin")
|
|
|
|
// Codex OAuth (ChatGPT subscription)
|
|
codexAccessTokenEncrypted String? @map("codex_access_token_encrypted")
|
|
codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted")
|
|
codexTokenExpiresAt DateTime? @map("codex_token_expires_at")
|
|
codexConnectedAt DateTime? @map("codex_connected_at")
|
|
codexConnectionStatus String @default("disconnected") @map("codex_connection_status")
|
|
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
|
|
insightsCachedAt DateTime? @map("insights_cached_at")
|
|
insightsCachedText String? @map("insights_cached_text") // cached JSON output
|
|
|
|
// Telegram notifications (per-user)
|
|
telegramBotToken String? @map("telegram_bot_token") // AES-256-GCM encrypted
|
|
telegramChatId String? @map("telegram_chat_id")
|
|
telegramEnabled Boolean @default(false) @map("telegram_enabled")
|
|
|
|
// Withings OAuth credentials (per-user, encrypted)
|
|
withingsClientIdEncrypted String? @map("withings_client_id_encrypted")
|
|
withingsClientSecretEncrypted String? @map("withings_client_secret_encrypted")
|
|
|
|
// moodLog integration (per-user, encrypted)
|
|
moodLogUrlEncrypted String? @map("mood_log_url_encrypted")
|
|
moodLogApiKeyEncrypted String? @map("mood_log_api_key_encrypted")
|
|
moodLogEnabled Boolean @default(false) @map("mood_log_enabled")
|
|
moodLogLastSyncedAt DateTime? @map("mood_log_last_synced_at")
|
|
moodLogWebhookSecret String? @map("mood_log_webhook_secret")
|
|
|
|
// 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")
|
|
|
|
// HealthKit (iOS) integration config — list of per-metric direction toggles.
|
|
// Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt }
|
|
healthKitConfigJson Json? @map("healthkit_config_json")
|
|
healthKitLastSyncedAt DateTime? @map("healthkit_last_synced_at")
|
|
|
|
// Display name (optional override of username for greetings).
|
|
displayName String? @map("display_name")
|
|
|
|
// Relations
|
|
passkeys Passkey[]
|
|
sessions Session[]
|
|
challenges AuthChallenge[]
|
|
measurements Measurement[]
|
|
medications Medication[]
|
|
intakeEvents MedicationIntakeEvent[]
|
|
apiTokens ApiToken[]
|
|
auditLogs AuditLog[]
|
|
withingsConnection WithingsConnection?
|
|
moodEntries MoodEntry[]
|
|
notificationChannels NotificationChannel[]
|
|
pushSubscriptions PushSubscription[]
|
|
dataBackups DataBackup[]
|
|
achievements UserAchievement[]
|
|
feedback Feedback[]
|
|
idempotencyKeys IdempotencyKey[]
|
|
devices Device[]
|
|
|
|
@@map("users")
|
|
}
|
|
|
|
model Passkey {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
name String @default("Passkey")
|
|
credentialId String @unique @map("credential_id")
|
|
credentialPublicKey Bytes @map("credential_public_key")
|
|
counter BigInt @default(0)
|
|
credentialDeviceType String @map("credential_device_type")
|
|
credentialBackedUp Boolean @default(false) @map("credential_backed_up")
|
|
transports String[] // e.g. ["usb", "ble", "nfc", "internal"]
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@map("passkeys")
|
|
}
|
|
|
|
model Session {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
expiresAt DateTime @map("expires_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
ipAddress String? @map("ip_address")
|
|
userAgent String? @map("user_agent")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId])
|
|
@@map("sessions")
|
|
}
|
|
|
|
model AuthChallenge {
|
|
id String @id @default(cuid())
|
|
userId String? @map("user_id") // null for registration
|
|
challenge String
|
|
type String // "registration" | "authentication"
|
|
expiresAt DateTime @map("expires_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId])
|
|
@@map("auth_challenges")
|
|
}
|
|
|
|
// ─── Measurements ──────────────────────────────────────────
|
|
|
|
enum MeasurementType {
|
|
WEIGHT
|
|
BLOOD_PRESSURE_SYS
|
|
BLOOD_PRESSURE_DIA
|
|
PULSE
|
|
BODY_FAT
|
|
SLEEP_DURATION
|
|
ACTIVITY_STEPS
|
|
BLOOD_GLUCOSE // Canonical storage: mg/dL. Display unit (mg/dL or mmol/L) is user preference.
|
|
TOTAL_BODY_WATER // Canonical storage: kilograms (kg of water). Matches Withings' hydration measure (type 77) and Health Connect's TotalBodyWaterRecord. Display can compute % from same-day weight if desired.
|
|
BONE_MASS // Canonical storage: kilograms (kg). Matches Withings' bone mass measure (type 88).
|
|
OXYGEN_SATURATION // Canonical storage: percent (0..100). Withings ScanWatch reports type 54. Health Connect's OxygenSaturationRecord uses Percentage (already 0..100). HealthKit uses HKQuantityTypeIdentifierOxygenSaturation as a fraction (0..1) — iOS DTO must multiply by 100 before submitting.
|
|
|
|
@@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
|
|
IMPORT
|
|
|
|
@@map("measurement_source")
|
|
}
|
|
|
|
model Measurement {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
type MeasurementType
|
|
value Float
|
|
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")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, type, measuredAt, source])
|
|
@@index([userId, type, measuredAt])
|
|
@@index([externalId])
|
|
@@map("measurements")
|
|
}
|
|
|
|
// ─── Medications ───────────────────────────────────────────
|
|
|
|
model Medication {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
name String
|
|
dose String // e.g. "10mg"
|
|
active Boolean @default(true)
|
|
notificationsEnabled Boolean @default(true) @map("notifications_enabled")
|
|
pausedAt DateTime? @map("paused_at")
|
|
snoozedUntil DateTime? @map("snoozed_until")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
schedules MedicationSchedule[]
|
|
intakeEvents MedicationIntakeEvent[]
|
|
phaseConfig ReminderPhaseConfig?
|
|
telegramMessages TelegramReminderMessage[]
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId])
|
|
@@map("medications")
|
|
}
|
|
|
|
model MedicationSchedule {
|
|
id String @id @default(cuid())
|
|
medicationId String @map("medication_id")
|
|
windowStart String // HH:mm format, e.g. "08:00"
|
|
windowEnd String // HH:mm format, e.g. "10:00"
|
|
label String? // e.g. "Morning", "Evening"
|
|
dose String? // Per-schedule dose override; null = use Medication.dose
|
|
daysOfWeek String? @map("days_of_week") // Comma-separated day numbers (0=Sun..6=Sat), null = daily
|
|
|
|
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
|
telegramMessages TelegramReminderMessage[]
|
|
|
|
@@map("medication_schedules")
|
|
}
|
|
|
|
enum IntakeSource {
|
|
WEB
|
|
API
|
|
REMINDER
|
|
IMPORT
|
|
|
|
@@map("intake_source")
|
|
}
|
|
|
|
enum ReminderPhase {
|
|
GREEN
|
|
YELLOW
|
|
ORANGE
|
|
RED
|
|
|
|
@@map("reminder_phase")
|
|
}
|
|
|
|
enum PhaseMode {
|
|
MINUTES
|
|
PERCENT
|
|
|
|
@@map("phase_mode")
|
|
}
|
|
|
|
model MedicationIntakeEvent {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
medicationId String @map("medication_id")
|
|
scheduledFor DateTime @map("scheduled_for")
|
|
takenAt DateTime? @map("taken_at")
|
|
skipped Boolean @default(false)
|
|
source IntakeSource @default(WEB)
|
|
idempotencyKey String? @unique @map("idempotency_key")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId, medicationId, scheduledFor])
|
|
@@map("medication_intake_events")
|
|
}
|
|
|
|
model ReminderPhaseConfig {
|
|
id String @id @default(cuid())
|
|
medicationId String @unique @map("medication_id")
|
|
greenValue Int @default(60) @map("green_value")
|
|
greenMode PhaseMode @default(MINUTES) @map("green_mode")
|
|
yellowValue Int @default(30) @map("yellow_value")
|
|
yellowMode PhaseMode @default(MINUTES) @map("yellow_mode")
|
|
orangeValue Int @default(0) @map("orange_value")
|
|
orangeMode PhaseMode @default(MINUTES) @map("orange_mode")
|
|
redValue Int @default(240) @map("red_value")
|
|
redMode PhaseMode @default(MINUTES) @map("red_mode")
|
|
|
|
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
|
|
|
@@map("reminder_phase_configs")
|
|
}
|
|
|
|
model TelegramReminderMessage {
|
|
id String @id @default(cuid())
|
|
medicationId String @map("medication_id")
|
|
scheduleId String @map("schedule_id")
|
|
chatId String @map("chat_id")
|
|
messageId Int @map("message_id")
|
|
phase ReminderPhase
|
|
sentAt DateTime @default(now()) @map("sent_at")
|
|
date String // YYYY-MM-DD for day grouping
|
|
|
|
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
|
schedule MedicationSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([medicationId, scheduleId, date, phase])
|
|
@@index([medicationId, date])
|
|
@@map("telegram_reminder_messages")
|
|
}
|
|
|
|
model TelegramScheduledDeletion {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
chatId String @map("chat_id")
|
|
messageId Int @map("message_id")
|
|
deleteAfter DateTime @map("delete_after")
|
|
|
|
@@index([deleteAfter])
|
|
@@map("telegram_scheduled_deletions")
|
|
}
|
|
|
|
// ─── API Tokens (for external ingest) ──────────────────────
|
|
|
|
model ApiToken {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
name String
|
|
tokenHash String @unique @map("token_hash") // SHA-256 of the token
|
|
permissions String[] @default(["medication:ingest"])
|
|
lastUsedAt DateTime? @map("last_used_at")
|
|
expiresAt DateTime? @map("expires_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
revoked Boolean @default(false)
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId])
|
|
@@map("api_tokens")
|
|
}
|
|
|
|
// ─── Withings ──────────────────────────────────────────────
|
|
|
|
model WithingsConnection {
|
|
id String @id @default(cuid())
|
|
userId String @unique @map("user_id")
|
|
withingsUserId String @map("withings_user_id")
|
|
accessToken String @map("access_token") // encrypted at app level
|
|
refreshToken String @map("refresh_token") // encrypted at app level
|
|
tokenExpiresAt DateTime @map("token_expires_at")
|
|
lastSyncedAt DateTime? @map("last_synced_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@map("withings_connections")
|
|
}
|
|
|
|
// ─── Mood Entries (moodLog) ───────────────────────────────────
|
|
|
|
model MoodEntry {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
date String // YYYY-MM-DD
|
|
mood String // SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG
|
|
score Int // 1-5
|
|
tags String? // JSON array of tag keys
|
|
source String @default("MOODLOG") // WEB, TELEGRAM, DAYLIO
|
|
moodLoggedAt DateTime @map("mood_logged_at")
|
|
syncedAt DateTime @default(now()) @map("synced_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, date, moodLoggedAt])
|
|
@@index([userId, date])
|
|
@@map("mood_entries")
|
|
}
|
|
|
|
// ─── App Settings ─────────────────────────────────────────
|
|
|
|
model AppSettings {
|
|
id String @id @default("singleton")
|
|
registrationEnabled Boolean @default(true) @map("registration_enabled")
|
|
defaultLocale String @default("de") @map("default_locale") // "de" | "en"
|
|
telegramGlobal Boolean @default(true) @map("telegram_global") // global toggle
|
|
ntfyGlobal Boolean @default(true) @map("ntfy_global") // global toggle
|
|
webPushGlobal Boolean @default(true) @map("web_push_global") // global toggle
|
|
webPushVapidPublicKey String? @map("web_push_vapid_public_key")
|
|
webPushVapidPrivateKeyEncrypted String? @map("web_push_vapid_private_key_encrypted")
|
|
webPushVapidSubject String? @map("web_push_vapid_subject")
|
|
apiGlobal Boolean @default(true) @map("api_global") // global toggle
|
|
moodLogGlobal Boolean @default(true) @map("mood_log_global")
|
|
umamiEnabled Boolean @default(false) @map("umami_enabled")
|
|
umamiScriptUrl String? @map("umami_script_url")
|
|
umamiWebsiteId String? @map("umami_website_id")
|
|
glitchtipEnabled Boolean @default(false) @map("glitchtip_enabled")
|
|
glitchtipDsn String? @map("glitchtip_dsn")
|
|
glitchtipEnvironment String? @map("glitchtip_environment")
|
|
githubIssueTokenEncrypted String? @map("github_issue_token_encrypted")
|
|
githubIssueRepo String? @map("github_issue_repo")
|
|
|
|
// Medication reminder thresholds (minutes after windowEnd)
|
|
reminderLateMinutes Int @default(120) @map("reminder_late_minutes")
|
|
reminderMissedMinutes Int @default(240) @map("reminder_missed_minutes")
|
|
|
|
// Admin AI Provider (fallback for users without Codex OAuth)
|
|
adminAiKeyEncrypted String? @map("admin_ai_key_encrypted")
|
|
adminAiModel String @default("gpt-4o-mini") @map("admin_ai_model")
|
|
adminAiBaseUrl String @default("https://api.openai.com/v1") @map("admin_ai_base_url")
|
|
|
|
@@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 {
|
|
id String @id @default(cuid())
|
|
userId String? @map("user_id")
|
|
action String // e.g. "auth.login", "measurement.create", "export.csv"
|
|
details String? // JSON string, no sensitive data
|
|
ipAddress String? @map("ip_address")
|
|
location String? // e.g. "Berlin, DE" — resolved from IP at log time
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
|
|
@@index([userId, createdAt])
|
|
@@index([action, createdAt])
|
|
@@map("audit_logs")
|
|
}
|
|
|
|
// ─── Notification System ──────────────────────────────────────
|
|
|
|
model NotificationChannel {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
type String // "TELEGRAM" | "NTFY" | "WEB_PUSH"
|
|
enabled Boolean @default(true)
|
|
config String // AES-256-GCM encrypted JSON (channel-specific config)
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
preferences NotificationPreference[]
|
|
|
|
@@unique([userId, type])
|
|
@@index([userId])
|
|
@@map("notification_channels")
|
|
}
|
|
|
|
model NotificationPreference {
|
|
id String @id @default(cuid())
|
|
channelId String @map("channel_id")
|
|
eventType String @map("event_type") // "MEDICATION_REMINDER" | "MEASUREMENT_ANOMALY" | etc.
|
|
enabled Boolean @default(true)
|
|
|
|
channel NotificationChannel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([channelId, eventType])
|
|
@@map("notification_preferences")
|
|
}
|
|
|
|
model PushSubscription {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
endpoint String
|
|
p256dh String // encrypted
|
|
auth String // encrypted
|
|
userAgent String? @map("user_agent")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, endpoint])
|
|
@@index([userId])
|
|
@@map("push_subscriptions")
|
|
}
|
|
|
|
// ─── Data Backups ────────────────────────────────────────────
|
|
|
|
model DataBackup {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
type String @default("WEEKLY_AUTO") // WEEKLY_AUTO, MANUAL
|
|
data String // JSON export blob
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, type], map: "data_backups_user_id_type_key")
|
|
@@index([userId])
|
|
@@map("data_backups")
|
|
}
|
|
|
|
// ─── Rate Limiting ──────────────────────────────────────────
|
|
|
|
model RateLimit {
|
|
key String @id
|
|
count Int @default(1)
|
|
resetAt DateTime @map("reset_at")
|
|
|
|
@@index([resetAt])
|
|
@@map("rate_limits")
|
|
}
|
|
|
|
// ─── Idempotency Keys (mobile-client replay protection) ──
|
|
|
|
/// Caches the response of a POST/PUT/PATCH/DELETE request keyed by
|
|
/// (userId, key, method, path) so retries within the TTL replay the
|
|
/// original outcome instead of producing a duplicate side-effect.
|
|
model IdempotencyKey {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
key String
|
|
method String
|
|
path String
|
|
responseStatus Int @map("response_status")
|
|
responseBody String @map("response_body") // serialized JSON envelope
|
|
expiresAt DateTime @map("expires_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, key, method, path])
|
|
@@index([expiresAt])
|
|
@@map("idempotency_keys")
|
|
}
|
|
|
|
// ─── Devices (APNs / mobile push targets) ────────────────
|
|
|
|
/// Registered native-client device for push delivery. Token is unique
|
|
/// across users; re-registering on a different account moves ownership.
|
|
model Device {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
platform String // "ios"
|
|
token String @unique
|
|
bundleId String @map("bundle_id")
|
|
locale String?
|
|
appVersion String? @map("app_version")
|
|
model String?
|
|
lastSeen DateTime @default(now()) @map("last_seen")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
updatedAt DateTime @updatedAt @map("updated_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([userId])
|
|
@@map("devices")
|
|
}
|
|
|
|
// ─── User Achievements ───────────────────────────────────────
|
|
|
|
model UserAchievement {
|
|
id String @id @default(cuid())
|
|
userId String @map("user_id")
|
|
achievementId String @map("achievement_id")
|
|
unlockedAt DateTime @map("unlocked_at")
|
|
createdAt DateTime @default(now()) @map("created_at")
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, achievementId])
|
|
@@index([userId])
|
|
@@map("user_achievements")
|
|
}
|