// v1.4.34 web-freeze. Schema is locked additive-only until iOS launch // + v1.5.0 marker. See .planning/v15-strategic-plan.md §2. // // ─── Timezone storage convention (UTC-only invariant) ─────────────── // // The database is UTC-only. Almost every `DateTime` column is the // Prisma default `timestamp(3)` WITHOUT time zone; a handful of rollup // columns (`MeasurementRollup.bucketStart` / `computedAt`, and the mood // rollup buckets) carry `@db.Timestamptz(3)`. This is NOT a live bug: // Prisma always reads and writes UTC over the wire, so both types // round-trip the same instant. The invariant every reader, writer, and // raw-SQL query MUST hold is: // // * The application server and the Postgres session run in UTC. // * Wall-clock / calendar-local anchoring lives in an explicit `tz` // string column (e.g. `MoodEntry.tz`) or the user's `timezone`, NOT // in the column type. // * Raw SQL that does date math on a `timestamp` column treats the // stored value as UTC; never compare a bare `timestamp` against // `now()`/`current_timestamp` with the expectation of a zone offset. // // Do NOT do a `timestamp` → `timestamptz` type sweep ad hoc — the mixed // types are harmless under the UTC invariant, and a partial sweep is // riskier than the documented convention. If a server-region move is // ever on the table, convert the whole schema in one audited migration // (`ALTER ... TYPE timestamptz USING ... AT TIME ZONE 'UTC'`). 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" | "OTHER" | null = keine Angabe timezone String @default("Europe/Berlin") // v1.25 (W-ENV) — opt-in environmental-context module home location. A coarse, // user-set city (rounded lat/lon, ~city granularity — never a GPS track) the // nightly weather fetch resolves against when no travel override covers the // day. City-level granularity is the privacy floor: the weather model only // needs ~10–25 km. All nullable — an account without the module enabled (or // without a home set) simply has no environment rows. `homeTimezone` anchors // the day-key an observation is stored under (it can differ from the display // `timezone` above for an account that tracks weather for another locale). homeLat Float? @map("home_lat") homeLon Float? @map("home_lon") homeLabel String? @map("home_label") homeTimezone String? @map("home_timezone") // v1.25 — effective-from instant for the home location. Stamped the moment a // home is set/updated. Day resolution attributes a past day to home ONLY when // the day is on/after this instant; earlier days are skipped (never fabricated // from the current home). To fill the deep past the user adds an explicit // dated location period instead. Backfill defaults its start to this instant. homeSince DateTime? @map("home_since") // 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 // v1.16.8 — SHA-256 fingerprint of the compacted feature snapshot the // cached comprehensive text was generated from. Nightly / forced / lazy // regenerations compare the fresh snapshot's hash against this and skip // the provider call (refreshing only `insightsCachedAt`) when nothing // the prompt sees has changed. insightsSnapshotHash String? @map("insights_snapshot_hash") // v1.16.8 — last time a forced full warm's comprehensive step failed or // timed out. The forced warm path backs off for an hour after a failure // so a persistently broken provider chain cannot be re-driven into a // full generation attempt by every page-open. insightsWarmFailedAt DateTime? @map("insights_warm_failed_at") // v1.18.7 — UTC YYYY-MM-DD of the last daily-briefing phrasing re-roll. // The content-hash gate keeps the FINDINGS byte-stable on unchanged data, // but the daily-briefing PARAGRAPH is re-rolled once per calendar day at a // higher, seedless temperature so the prose reads fresh day-over-day // without re-paying for the findings. This stamps the day a re-roll ran so // the gate stays at most one extra call per day. insightsBriefingRerollDate String? @map("insights_briefing_reroll_date") // v1.4.36 W3 T3 — per-user AI Insights exclude-metrics list, mirrors // `coachPrefsJson.excludeMetrics`. The /api/insights/generate route // filters matching keys off `features` before JSON.stringify so the // model never sees data the user opted out of. Default `[]` = legacy // behaviour for every existing row. insightsExcludeMetrics String[] @default([]) @map("insights_exclude_metrics") // v1.8.5 — user-level injection-site deny-list. Sites listed here are // never offered for ANY medication's injection-site picker and the // intake write path 422s a submitted site that lands on this list, // even when the per-medication `allowedInjectionSites` lists it as // preferred (deny always wins). Default `[]` = no global exclusion. globalExcludedInjectionSites InjectionSite[] @default([]) @map("global_excluded_injection_sites") // Telegram notifications (per-user) telegramBotToken String? @map("telegram_bot_token") // AES-256-GCM encrypted // v1.19.2 — one Telegram chat binds to exactly one HealthLog account. // The inbound webhook resolves the user from this chat id; a chat shared // across two accounts would route a reply / button tap ambiguously. The // unique is partial by Postgres NULL semantics (multiple NULLs allowed), // so an unlinked account (NULL chat id) never collides. Migration 0189. telegramChatId String? @unique @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") // v1.11.0 — WHOOP OAuth credentials (per-user BYO-keys, encrypted at app // level). The per-app authorized-user cap makes a single shared WHOOP app // unworkable for a multi-operator product, so each self-hoster registers // their own WHOOP dev app and pastes the client id/secret into Settings. whoopClientIdEncrypted String? @map("whoop_client_id_encrypted") whoopClientSecretEncrypted String? @map("whoop_client_secret_encrypted") // v1.12.0 — Fitbit/Pixel OAuth credentials (per-user BYO-keys, encrypted at // app level) over the Google Health API. The per-app authorized-user cap in // Google's Testing mode makes a single shared client unworkable for a // multi-operator product, so each self-hoster registers their own Google // Cloud OAuth client and pastes the client id/secret into Settings. fitbitClientIdEncrypted String? @map("fitbit_client_id_encrypted") fitbitClientSecretEncrypted String? @map("fitbit_client_secret_encrypted") // v1.17.0 — Nightscout glucose integration (F1, per-user, encrypted at app // level). A self-hoster points HealthLog at their own Nightscout instance: // the base URL + the API token / secret are stored encrypted. By default // the base URL must resolve to a public host (the SSRF floor); a self-hoster // running Nightscout on a private/LAN network can flip // `nightscoutAllowPrivateHost` to opt that instance past the public-host // requirement explicitly. nightscoutUrlEncrypted String? @map("nightscout_url_encrypted") nightscoutTokenEncrypted String? @map("nightscout_token_encrypted") nightscoutAllowPrivateHost Boolean @default(false) @map("nightscout_allow_private_host") // v1.17.0 — Polar OAuth credentials (F4, per-user, encrypted at app level). // Mirrors the WHOOP credential columns: the per-app authorized-user cap on // Polar AccessLink makes a single shared app unworkable for a multi-operator // product, so each self-hoster registers their own Polar app and the OAuth // tokens are stored encrypted. polarAccessTokenEncrypted String? @map("polar_access_token_encrypted") polarUserIdEncrypted String? @map("polar_user_id_encrypted") // v1.17.1 — Polar BYO-app client credentials (W1c, per-user, encrypted at // app level). Mirrors the WHOOP/Fitbit `clientId`/`clientSecret` columns 1:1 // so a self-hoster pastes their own Polar AccessLink app's client id/secret // into Settings before the OAuth connect flow runs. Stored encrypted as a // string via `src/lib/crypto.ts`, matching every other `*Encrypted` column. polarClientIdEncrypted String? @map("polar_client_id_encrypted") polarClientSecretEncrypted String? @map("polar_client_secret_encrypted") // v1.17.0 — Oura OAuth credentials (F4, per-user, encrypted at app level). // Mirrors the WHOOP credential columns; OAuth access/refresh tokens stored // encrypted. ouraAccessTokenEncrypted String? @map("oura_access_token_encrypted") ouraRefreshTokenEncrypted String? @map("oura_refresh_token_encrypted") // v1.17.1 — Oura BYO-app client credentials (W1c, per-user, encrypted at app // level). Mirrors the WHOOP/Fitbit `clientId`/`clientSecret` columns 1:1 so a // self-hoster pastes their own Oura Cloud app's client id/secret into Settings // before the OAuth connect flow runs. Stored encrypted as a string via // `src/lib/crypto.ts`, matching every other `*Encrypted` column. ouraClientIdEncrypted String? @map("oura_client_id_encrypted") ouraClientSecretEncrypted String? @map("oura_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") // Per-user insights tile layout — tile visibility + ordering for the // `/insights` surface. Mirrors `dashboardWidgetsJson` so the iOS // client and the web Settings UI can persist insights tile order + // visibility through the same shape. Null = default layout (see // `src/lib/insights-layout.ts:DEFAULT_INSIGHTS_LAYOUT`). The GET // endpoint never lazy-writes — the column only carries data once // the user has explicitly saved a layout. // Shape: { "tiles": [{ "id": "blutdruck", "visible": true, "order": 0 }, ...], "version": 1 } insightsLayoutJson Json? @map("insights_layout_json") // Per-user medications list presentation — the card/table view choice // plus the user-defined manual medication order shared by BOTH views. // Follows the per-surface-column convention of `dashboardWidgetsJson` / // `insightsLayoutJson`. Null = defaults (cards, alphabetical order). // The GET endpoint never lazy-writes — the column only carries data // once the user has explicitly toggled the view or saved an order. // Shape: { "version": 1, "view": "table", "order": ["", ...] } medicationListLayoutJson Json? @map("medication_list_layout_json") // v1.17.0 — per-user mood-tag presentation: group order + per-group tag // placement (catalogue tags "moved" into a group live here only — their // `categoryId` never changes). Display-only, preserve-when-absent PUT, // unknown keys dropped at read. Null = seeded defaults. // Shape: { "groupOrder": ["", ...], // "placements": { "": ["", ...] } } moodTagLayoutJson Json? @map("mood_tag_layout_json") // Blood-glucose display unit preference. Canonical storage is mg/dL. glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL" // v1.18.6 — explicit "I have diabetes / clinician glucose targets" opt-in. // When `true`, the glucose target resolver applies the tighter ADA glycemic // GOAL bands (fasting 80–130, postprandial < 180) instead of the general // non-diabetic normal bands. NEVER auto-inferred from values, NEVER a // diagnosis — purely a user-declared preference that selects which target // band the user is judged against. Default `false`. hasDiabetes Boolean @default(false) @map("has_diabetes") // v1.18.10 — opt-in local (in-browser) OCR for lab-report scans. When `true`, // a user whose AI provider cannot read images (e.g. ChatGPT-OAuth/Codex, a // text-only model) can still scan a report: the image is OCR'd in the browser // via tesseract.js and only the extracted TEXT is sent to the text-only // provider for structuring. Less accurate than native vision — the mandatory // review screen remains the safety backstop. Default `false`; vision-capable // users keep the higher-accuracy native path and never pay the WASM download. labsLocalOcrEnabled Boolean @default(false) @map("labs_local_ocr_enabled") // v1.7.0 unit pref — global metric/imperial display preference. // Canonical storage stays SI; this only selects the display-time // transform branch (km/h vs mph, km vs mi). Null = default "metric". unitPreference String? @map("unit_preference") // "metric" | "imperial" | null = default "metric" // Hour-cycle display preference. AUTO follows the locale convention // (en → AM/PM, de → 24h), H12 forces AM/PM, H24 forces 24-hour. // Display-time only — canonical storage stays UTC instants. timeFormat TimeFormatPreference @default(AUTO) @map("time_format") // Date display preference. AUTO follows the active locale convention // (de → dd.MM.yyyy, en → MM/dd/yyyy); DMY pins day-month-year, // MDY pins month-day-year, YMD pins ISO yyyy-MM-dd. Display-time only — // canonical storage stays UTC instants. dateFormat DateFormatPreference @default(AUTO) @map("date_format") // 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") // v1.4.3: per-user OpenAI API key for the OPENAI provider — previously // the schema only stored Anthropic and Local keys, leaving OPENAI without // a credential surface. Encrypted with the active ENCRYPTION_KEYS entry // before storage. aiOpenaiKeyEncrypted String? @map("ai_openai_key_encrypted") // v1.4.16 phase B5b — ordered fallback chain for multi-provider AI // redundancy. Each entry is `{ providerType, priority, enabled }`, // sorted by ascending priority at runtime. Credentials live in the // dedicated encrypted columns (codex* / aiAnthropicKeyEncrypted / // aiLocalKeyEncrypted / aiOpenaiKeyEncrypted) — the chain just // enumerates which providers to try and in what order. Null = legacy // behaviour (single-provider resolution via `aiProvider`). aiProviderChain Json? @map("ai_provider_chain") // v1.22 (#89) — per-user response timeout for AI generation, in SECONDS. // Threaded onto `CompletionParams.timeoutMs` (×1000). Null = the generous // built-in default (~180 s). Surfaced in Settings → AI mainly for // local/self-hosted servers (an MLX/exo backend can take >60 s on the // first request while it loads the model), where the legacy 60 s client // default timed the Coach out before the first token landed. aiResponseTimeoutSeconds Int? @map("ai_response_timeout_seconds") // v1.22 (#90) — dedicated document-scan (Lab-OCR) provider. OFF by default: // the lab-report OCR path uses the main provider chain unchanged. When // `aiOcrEnabled` is true the OCR ingestion resolves THIS provider/model/key // instead, so a user can keep a cheap text Coach on one provider and route // expensive vision OCR to a vision-capable one. The key is AES-256-GCM at // rest like every other `*Encrypted` column (registered in // `src/lib/crypto/encrypted-columns.ts` + the rotation script). aiOcrEnabled Boolean @default(false) @map("ai_ocr_enabled") aiOcrProvider String? @map("ai_ocr_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" aiOcrModel String? @map("ai_ocr_model") aiOcrBaseUrl String? @map("ai_ocr_base_url") // LOCAL OCR provider only aiOcrKeyEncrypted String? @map("ai_ocr_key_encrypted") // Onboarding onboardingCompletedAt DateTime? @map("onboarding_completed_at") // v1.4.25 W14b — last persisted onboarding wizard step. // Encodes resume-from-where-you-left-off semantics for the rebuilt // multi-step wizard at `/onboarding/[step]`. Values mirror the // wizard's step indices: // 0 = welcome // 1 = goals // 2 = source // 3 = baseline // 4 = done (triggers `onboardingCompletedAt` flip on the same // round-trip — the column stays at 4 for completed users) // Null = legacy pre-W14b user; the wizard treats null as 0 on mount. onboardingStep Int @default(0) @map("onboarding_step") // v1.17.1 — goal slugs chosen during onboarding (GoalsChipPicker). // Seeds dashboard tile pinning + reminder suggestions; NOT a clinical // target store (targets are fully derived from height/age/gender + // clinical refs in /api/insights/targets). Empty [] = skipped / no // preference. The slug set is the closed enum // `ONBOARDING_GOAL_SLUGS` in src/lib/onboarding/goals.ts; a String[] // (not a relation) mirrors `insightsExcludeMetrics` precedent — the // set is small and fixed, never queried relationally. onboardingGoals String[] @default([]) @map("onboarding_goals") // v1.4.15 Phase B5: tracks whether the user has finished (or // explicitly dismissed) the spotlight tour that overlays the // dashboard on first arrival. Distinct from the wizard at // `/onboarding` (which is gated by `onboardingCompletedAt`); the // tour runs *after* the wizard, on the dashboard itself, walking // the user through the tile strip, quick-add menu, insights, // integrations, and achievements. Default false; flipped to true // on tour completion or dismiss; users can replay the tour from // Settings → Account. onboardingTourCompleted Boolean @default(false) @map("onboarding_tour_completed") // v1.18.6 — resumable module-tour progress. The coarse boolean above // stays as the "did the user ever finish/dismiss" flag the launcher // gates on; this column carries the fine-grained resume point so a // mid-tour reload reopens at the right module. Follows the per-user // JSON-column convention (`dashboardWidgetsJson` / `insightsLayoutJson` // / `coachPrefsJson`). Shape (validated by // `src/lib/onboarding/tour-progress.ts`): // { lastStopId: string | null, // completedStopIds: string[], // status: "in_progress" | "completed" | "skipped", // updatedAt: string (ISO) } // Null = the user has not started the module tour (fresh account or a // legacy account that only carries the boolean flag). Cleared back to // null on a Settings replay so the tour genuinely restarts. onboardingTourProgressJson Json? @map("onboarding_tour_progress_json") // v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Shown // once at the start of onboarding: HealthLog is a private tracking tool, // not a clinical assessment or diagnosis; if worried, talk to a doctor. // Mirrors the `researchModeAcknowledged*` pair — the version pins exactly // which copy the user read, so a future material wording change can ask // again. Null = never acknowledged (legacy accounts read as never-asked; // the prompt only gates fresh onboarding, so they are not re-walled). The // acknowledged legal text stays reachable on the public privacy page. disclaimerAcknowledgedAt DateTime? @map("disclaimer_acknowledged_at") disclaimerAcknowledgedVersion String? @map("disclaimer_acknowledged_version") // 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") // Doctor-report personalization (v1.4.15 phase B6). // Persisted between report exports so the dialog can pre-fill the last-used // practice / clinic name. Free-text, hard-capped to 120 chars upstream by // `sanitisePracticeName()`. Null = user has never set one. lastReportPracticeName String? @map("last_report_practice_name") // v1.4.23 H4 — Coach drawer per-user prompt-tuning preferences. // Shape: // { "tone": "warm" | "neutral" | "concise", // "verbosity": "brief" | "default" | "detailed", // "excludeMetrics": ("bp" | "weight" | "pulse" | "mood" | // "compliance" | "hrv" | "sleep" | // "resting_hr" | "steps")[], // "showEvidenceByDefault": boolean } // Null = legacy v1.4.22 defaults (tone=warm, verbosity=default, no // metrics excluded, evidence closed). The Coach prompt builder reads // this column and the snapshot builder filters on `excludeMetrics` // BEFORE the snapshot lands in the system prompt — otherwise the // model would still see the data the user excluded. coachPrefsJson Json? @map("coach_prefs_json") // v1.4.25 W5e — per-user, per-metric-class source priority for // cross-source dedup. Shape mirrors // `src/lib/validations/source-priority.ts`: // { "steps": ["APPLE_HEALTH", "WITHINGS", "MANUAL"], // "weight": ["WITHINGS", "APPLE_HEALTH", "MANUAL"], // ... } // Null = use `DEFAULT_SOURCE_PRIORITY`. The cumulative-metric // aggregators (steps, calories, distance, flights climbed, sleep // duration) consult this column to pick ONE source per day when // multiple sources report the same metric for the same day. Point // measurements (weight, BP, pulse, body-fat, body-temp, SpO2, HRV, // RHR, VO2 max) keep every source's row — the priority list only // controls display preference. // // Today (v1.4.25) only WITHINGS + MANUAL exist, so the function // effectively no-ops for every user. The architecture lands now so // v1.5's Apple Health passthrough drops in without extra schema // work. sourcePriorityJson Json? @map("source_priority_json") // v1.4.25 W19c — Research Mode acknowledgment for the opt-in GLP-1 // PK research-view chart. The chart is hidden behind a per-user // flag that is set to `true` only after the user has dismissed an // undismissable MDR-disclaimer dialog. The `researchModeAcknowledged*` // pair records exactly *which version* of the disclaimer copy the // user has read; when the disclaimer copy changes (e.g. EMA guidance // update, new drug added), the Settings surface re-prompts the user // and refuses to surface the chart until the new version is // acknowledged. Keeping the version on the user row (instead of an // append-only audit table) is intentional — the Coach refusal layer // and the chart-render guard both need a cheap synchronous read. // // `researchModeEnabled = false` is the safe default — the chart // never renders without an explicit opt-in. The DELETE branch of // the API endpoint sets all three columns back to NULL/false so a // user who turns Research Mode off must re-acknowledge the // disclaimer if they ever turn it back on, even if the version // hasn't changed in the interim. researchModeEnabled Boolean @default(false) @map("research_mode_enabled") researchModeAcknowledgedAt DateTime? @map("research_mode_acknowledged_at") researchModeAcknowledgedVersion String? @map("research_mode_acknowledged_version") // v1.4.30 — iOS SyncMode foundation (R-E C-2). Last-known // synchronization checkpoint per user. iOS reads it via // `GET /api/sync/state` to decide whether to drain its local // SwiftData backlog before opening a fresh anchored-query window. // NULL = never synced; the SyncMode store treats that as "force // a full refresh on the next pair". lastSyncedAt DateTime? @map("last_synced_at") // v0.5.4 ios-coord — daily mood-reminder opt-in. // // When `true` the reminder worker dispatches a `MOOD_REMINDER` // notification at 22:00 in the user's local timezone if no // `MoodEntry` row exists for the local date yet. Default `false` // because mood capture is an emotional surface — the user has to // explicitly opt in from Settings → Notifications before the // server starts nudging them. The dedup is handled by the // `MoodReminderDispatch` table (one row per user + local date). moodReminderEnabled Boolean @default(false) @map("mood_reminder_enabled") // v1.4.25 W6c — Doctor-report section toggles. // Shape: // { "bp": boolean, // "weight": boolean, // "pulse": boolean, // "bmi": boolean, // "mood": boolean, // privacy-sensitive; default OFF // "compliance": boolean, // "sleep": boolean } // Null = documented defaults (every section ON except mood). Mood is // opt-in by default — the API layer drops mood entirely from the // payload when the toggle is off so the data never leaves the DB row. doctorReportPrefsJson Json? @map("doctor_report_prefs_json") // v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences blob. // // Opt-in suppression for redundant server-side pushes. Shape: // { "medication": { "clientManaged": boolean } } // // When `medication.clientManaged === true`, the reminder cron skips // the APNs `MEDICATION_REMINDER` for this user — iOS v0.6.0.7+ // schedules local SpeziScheduler banners that cover the same dose. // The iOS app flips the flag to true on first launch after it has // confirmed (a) APNs permission = `.authorized`, (b) local Spezi // reminders are actively scheduled, and (c) the binary is // >= v0.6.0.7. Default `null` (resolved as `clientManaged: false`) // so existing installs see zero regression — server reminders flow // unchanged until the iOS client explicitly opts the user in. // // Only `MEDICATION_REMINDER` is gated by this flag; MOOD_REMINDER, // PERSONAL_RECORD, SYSTEM_ALERT, and anomaly pushes flow unchanged. // // Persisted via `PATCH /api/auth/me/notification-prefs` with // deep-merge semantics so future categories (e.g. mood, // personal_records) slot in next to `medication` without // overwriting siblings. notificationPrefs Json? @map("notification_prefs") // v1.4.47 W3 — per-user Coach opt-out (privacy-leaning users). // // When `true`, every Coach mount point on the web client renders // nothing — ``, ``, the inline // `` pill, the `/targets` page CTA, etc. The // gate sits BELOW the operator-level `flags.coach` check: both // must agree to render the affordance. Default `false` so the // upgrade from v1.4.46 is invisible to anyone who hasn't toggled // the new switch. // // Persistence point is the Settings → Insights `` labelled // "Hide Coach" / "Coach ausblenden"; the toggle writes through // `PATCH /api/auth/me/disable-coach` and the next /api/auth/me // round-trip surfaces the new value to every Coach gate via the // `useAuth()` hook. disableCoach Boolean @default(false) @map("disable_coach") // v1.18.0 — module enable/disable foundation. // // A DISABLED allowlist for the optional ("secondary") domains. The // contract is default-on: a NULL column, an empty object, or a key // simply absent all mean "module enabled". A key present and set to // `false` means that module is OFF; `true` is redundant-but-allowed. // No backfill — every existing user reads as all-on. // // Shape (string keys ⇒ boolean): // { "mood": false, "glucose": false, ... } // // The canonical key set + which keys are toggleable lives in // `src/lib/modules/registry.ts`; the resolver + 403 guard live in // `src/lib/modules/gate.ts`. CORE domains (weight, blood pressure, // pulse, medications — the measurement engine + meds) are NEVER // present here and the gate refuses to disable them, so a crafted // `{ "weight": false }` blob cannot turn the core engine off. // // Two modules do NOT resolve from this column to avoid a double // source of truth: `cycle` delegates to `CycleProfile`/`isCycleEnabled` // and `coach` delegates to `User.disableCoach` + the operator-level // assistant master flag. The column may still carry a `cycle`/`coach` // key for forward-compat but the gate ignores it for those two. // // Persisted via `PATCH /api/auth/me/modules` with field-by-field // merge semantics (no mass assignment); surfaced resolved on // `GET /api/auth/me` as a `modules: { : boolean }` map. modulePreferencesJson Json? @map("module_preferences_json") // v1.18.6 (CCH-03) — "Coach last seen" stamp powering the unread dot // on the Coach FAB. The proactive nudge now lands as an ASSISTANT // message in the conversation rail (CCH-02); the FAB shows a discreet // unread dot when the newest Coach message is newer than this stamp, // and clears it when the user opens the Coach (drawer or page, which // writes the current time here via `POST /api/insights/coach/seen`). // Null = never opened the Coach, so any existing message reads as // unread once. Server-authoritative so the signal is consistent across // web + iOS; the FAB keeps a local mirror only as an instant-paint // optimisation. coachLastSeenAt DateTime? @map("coach_last_seen_at") // v1.5.5 — self-hosted avatar storage. // // Replaces the Gravatar third-party leak (the old `getGravatarUrl` // path hashed the email with SHA-256 and shipped the digest to // www.gravatar.com on every authenticated page load, which let // Automattic correlate the digest against its own table of known // emails). The bytes live on the User row so the standard pg_dump // backup path already covers them — no new filesystem volume to // mount, no new Docker compose change. // // `avatarBytes` carries the raw image (JPEG / PNG / WebP), hard- // capped at 2 MiB by the upload route and at 2048×2048 by the // post-decode dimension check. `avatarContentType` carries the // sniffed MIME (`image/jpeg`, `image/png`, `image/webp`); the // value is never trusted from the multipart header — it is // resolved from the file's magic bytes. `avatarUpdatedAt` is the // cache-bust anchor: the /api/auth/me profile response appends // the unix-ms suffix to the avatar URL so a re-upload paints // fresh client-side without an HTTP cache flush. // // Null on all three columns = the user has not uploaded an // avatar and the profile response returns `avatarUrl: null`. The // client falls back to the username initials. avatarBytes Bytes? @map("avatar_bytes") avatarContentType String? @map("avatar_content_type") avatarUpdatedAt DateTime? @map("avatar_updated_at") // v1.23 — second-factor (TOTP) + account-security flags. The TOTP shared // secret rides the same AES-256-GCM at-rest envelope as every other // credential (registered in ENCRYPTED_COLUMNS + the rotation script); // `totpConfirmedAt` marks the pending→active promotion. `mfaEnforced` and // `passkeyUpgradeNudgeDismissed` are per-account flags later waves read. totpSecretEncrypted String? @map("totp_secret_encrypted") totpConfirmedAt DateTime? @map("totp_confirmed_at") // v1.23 — TOTP replay guard. The last accepted time-step counter // (RFC 6238 `T = floor(now / period)`). A code is rejected when its // resolved step is <= this value even while still inside the ±1 drift // window, so a captured code cannot be reused within its 30-second // life. BigInt because the step counter grows unbounded with wall time. totpLastStep BigInt? @map("totp_last_step") mfaEnforced Boolean @default(false) @map("mfa_enforced") passkeyUpgradeNudgeDismissed Boolean @default(false) @map("passkey_upgrade_nudge_dismissed") // Relations passkeys Passkey[] sessions Session[] challenges AuthChallenge[] // v1.23 — second-factor stores (kept separate from `passkeys`, which is the // passwordless-primary credential set; an MFA credential is meaningless // without the password). mfaChallenges MfaChallenge[] mfaRecoveryCodes MfaRecoveryCode[] webauthnMfaCredentials WebauthnMfaCredential[] knownDevices UserKnownDevice[] // v1.23 — "remember this device" trusted-device tokens. A trusted device // skips the second factor (factor 2 only) within the window; the password // is still required. Revoked when the second factor is removed. trustedDevices TrustedDevice[] measurements Measurement[] medications Medication[] intakeEvents MedicationIntakeEvent[] medicationPauseEras MedicationPauseEra[] apiTokens ApiToken[] // v1.24 — MCP remote OAuth 2.1 connections (1:N). Each row is a granted // authorization for an MCP client, audience-bound to health:read/health:write. mcpOAuthConnections McpOAuthConnection[] refreshTokens RefreshToken[] auditLogs AuditLog[] withingsConnection WithingsConnection? // v1.4.47 W6 — short-lived `(nonce → userId)` ledger backing the // Withings OAuth state check. The connect route mints a row with a // 10-minute TTL; the callback resolves the user via this relation // instead of parsing the cookie value, closing the L-1 audit gap // where the legacy `${user.id}:${nonce}` shape leaked the user id // into request logs / network captures. withingsOAuthStates WithingsOAuthState[] // v1.11.0 — WHOOP integration. Mirrors the Withings connection (1:1) + // OAuth-state ledger (1:N) relations. whoopConnection WhoopConnection? whoopOAuthStates WhoopOAuthState[] whoopConnectTickets WhoopConnectTicket[] // v1.12.0 — Fitbit/Pixel integration. Mirrors the WHOOP connection (1:1) + // OAuth-state ledger (1:N) relations. fitbitConnection FitbitConnection? fitbitOAuthStates FitbitOAuthState[] moodEntries MoodEntry[] mentalHealthAssessments MentalHealthAssessment[] moodTagsCustom MoodTag[] @relation("UserMoodTags") moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories") moodTagsHidden MoodTagHidden[] notificationChannels NotificationChannel[] pushSubscriptions PushSubscription[] dataBackups DataBackup[] achievements UserAchievement[] recommendationFeedback RecommendationFeedback[] idempotencyKeys IdempotencyKey[] devices Device[] integrationStatuses IntegrationStatus[] coachConversations CoachConversation[] coachFacts CoachFact[] coachPlans CoachPlan[] coachReminders CoachReminder[] coachUsage CoachUsage[] // v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings // getworkouts / future Strava). Schema lands now so the v1.5 iOS // session can build against a stable DTO. Routes hang off Workout // (1:1), not User — the User → WorkoutRoute path is two hops by // design. workouts Workout[] // v1.4.25 W8d — personal records (schema only; detection worker // lands in a later release). The GET /api/personal-records route // ships with v1.4.25 so the iOS app can build its query path // against a stable contract. personalRecords PersonalRecord[] // v1.4.25 W19b — per-pen / per-vial inventory entities, the // companion to the running-sum MedicationInventoryEvent ledger. // Carries the EMA §6.3 30-day in-use clock per opened pen. inventoryItems MedicationInventoryItem[] // v1.4.25 W19d — GLP-1 side-effect logbook. 21-entry × 5-category // EMA EPAR §4.8-derived taxonomy with 1-5 Likert severity. Daily // capture (not per-shot) so delayed-onset symptoms map to their // actual occurrence time. medicationSideEffects MedicationSideEffect[] // v1.4.34 — Apple Health `export.zip` import jobs. Mirrors the // pg-boss job state so the polling endpoint can read live per- // phase progress without round-tripping through pg-boss internals. importJobs ImportJob[] // v1.5.0 — persistent measurement rollup table. Carries the // count/min/max/mean/sd/slope/r2 stats per (type, granularity, // bucketStart) so the analytics + comprehensive read paths bypass // the trailing-window row walk and survive process restarts + // multi-instance fan-out. Populated synchronously on the DAY // granularity from the measurement write hooks; WEEK/MONTH/YEAR // are enqueued through pg-boss for the worker to fold. measurementRollups MeasurementRollup[] // v0.5.4 ios-coord — per-day dedup ledger for MOOD_REMINDER pushes. // One row per (user, local date) the worker has nudged. Prevents // a second push when the cron rechecks the same hour later in the // 22:00 window (the cron fires every 15 min — without the ledger // a user could see three pushes back-to-back). moodReminderDispatches MoodReminderDispatch[] // v1.4.39 W-MOOD — per-(user, granularity, bucket) mood rollup. New // tier behind the mood-analytics read path; per-write hooks fold // MoodEntry mutations into the DAY granularity inline so the next // read serves from this table instead of the unbounded // MoodEntry.findMany walk. moodEntryRollups MoodEntryRollup[] // v1.4.39 W-MED — per-(user, medication, day) compliance rollup. // Per-write hook on MedicationIntakeEvent mutations + the scheduler // keep the row in sync so `/api/medications/intake?scope=compliance` // and `computeUserHealthScoreFastPath` skip the per-event findMany. medicationComplianceRollups MedicationComplianceRollup[] // v1.4.40 SB-10 — AI consent receipts the iOS app POSTs when the user // grants AI processing during onboarding. Persisted audit trail // covers App-Store Guideline 5.1.2(i) ("data-collection consent must // be verifiable") plus the operator's GDPR Art. 7 documentation // burden. Append-only — a user revoking + re-granting consent mints // a fresh row so the timeline reads chronologically. consentReceipts ConsentReceipt[] // v1.4.49 — per-attempt push-delivery ledger. Every sender (APNS, // WEB_PUSH, TELEGRAM, NTFY) writes one fire-and-forget row per // dispatch so the admin diagnostic endpoint can return the trailing // 20 attempts ordered by recency. Daily prune at 03:35 Europe/Berlin // retires rows older than 90 days (see `handlePushAttemptCleanup` in // `reminder-worker.ts`). pushAttempts PushAttempt[] // v1.10.3 — per-(user, day) Strain personal-anchor cache (day-total // TRIMP + running EWMA reference) so the nightly Strain engine anchors // the 0–100 map to the user's own training load without re-integrating // the 42-day chronic window of HR series each night. strainTrimpCache StrainTrimpCache[] // v1.11.0 — owner-minted clinician share links. Each row grants a // scoped, time-limited, revocable read view of the health record to a // clinician holding the raw `hls_` token (stored only as an HMAC hash). clinicianShareLinks ClinicianShareLink[] // v1.11.0 W1 — durable per-provider AI-health ledger (one row per // provider chain entry) so a known-bad credential is negatively cached // across workers instead of re-burning a 401 every generation. providerHealth ProviderHealth[] // v1.11.0 W3 — one durable period-narrative row per (period, locale). // Delete/regenerate-clean; the generated prose is AES-256-GCM at rest. insightNarratives InsightNarrative[] // v1.17.1 — preventive-care / measurement (Vorsorge) reminders. One row // per user-created reminder ("BP every 7 days", "annual blood panel"). // Server computes the canonical `nextDueAt`; the cron fires through the // existing notification dispatcher. Soft-deleted. measurementReminders MeasurementReminder[] // v1.17.1 — minimal structured lab store (LDL, HbA1c, ferritin, TSH, …) // that pairs with the Vorsorge "annual blood panel" reminder so a result // has somewhere to land. Soft-deleted. labResults LabResult[] // v1.19.0 — user-scoped biomarker catalog (canonical name/unit/range per // marker); LabResult rows link to a Biomarker for the resolved range/unit. biomarkers Biomarker[] // v1.25.5 — user-defined custom metrics. A separate generic store // (catalog + values), fully isolated from the closed MeasurementType // system: not synced, not in FHIR, not in AI insights — log + chart only. customMetrics CustomMetric[] customMetricEntries CustomMetricEntry[] // v1.19.0 — illness / condition journal (born-gated by the module system). // IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the // per-day timeline mirroring the CycleDayLog shape. illnessEpisodes IllnessEpisode[] illnessDayLogs IllnessDayLog[] // v1.25 (W-RECORDS) — structured health-record entries. Allergies map to // FHIR AllergyIntolerance; family-history entries map to FHIR // FamilyMemberHistory. Reference records (not time-series signals); the // sensitive free-text fields are encrypted at rest. allergies Allergy[] familyHistory FamilyHistoryEntry[] // v1.25 (W-ENV) — opt-in environmental-context module. `EnvironmentContext` // is the per-day weather/daylight observation keyed to the resolved location; // `EnvironmentTravelLocation` holds the manual date-range travel overrides // that win over the home location for the days they cover. environmentContexts EnvironmentContext[] environmentTravelLocations EnvironmentTravelLocation[] // v1.25 (W-DOCS-IN) — inbound clinical documents (doctor reports / // discharge letters) the user uploads. The raw document is encrypted at // rest; extracted facts land in a review-then-confirm staging area // (ExtractedFact) and only reach the structured stores on explicit // approval. Opt-in module, off by default. inboundDocuments InboundDocument[] extractedFacts ExtractedFact[] // v1.19.0 — full Withings ECG waveform recordings (encrypted at rest). // The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION // Measurement EVENT row (v1.18.11); this holds the raw signal samples. ecgRecordings EcgRecording[] // v1.7.0 profile — optional patient-identity fields surfaced on the // health-record export cover (PDF) and the FHIR `Patient` resource. // `fullName` + `insurerName` are plaintext (consistent with the other // plaintext profile columns: dateOfBirth, gender, heightCm). The German // KVNR (`insuranceNumber`) is a quasi-identifier, so it is stored // AES-256-GCM encrypted under the active key via `src/lib/crypto.ts` // (column suffix `*Encrypted` per the project convention). All three are // optional — an unset field collapses its cover line / FHIR slot exactly // like the existing `practiceName`. fullName String? @map("full_name") insurerName String? @map("insurer_name") insuranceNumberEncrypted String? @map("insurance_number_encrypted") // v1.8.6 profile — optional German insurer institution number (IKNR, // 9 digits). Surfaced on the FHIR `Coverage` resource's contained // payor `Organization` (system `http://fhir.de/sid/arge-ik/iknr`). // Stored plaintext, matching `insurerName` — the IKNR is a public // registry identifier, not a quasi-identifier like the KVNR, so it // does not warrant the encrypted column treatment. Optional; an unset // field collapses the Coverage payor identifier exactly like an // unset `insurerName` collapses its name. insurerIkNumber String? @map("insurer_ik_number") // v1.15.0 — cycle tracking. The CycleProfile is a 1:1 settings row // (lazily upserted on first cycle-surface access); MenstrualCycle + // CycleDayLog are the soft-deleted, sync-feed-bearing capture rows; // CyclePrediction is a 1:1 cached forecast (debounced regenerate). cycleProfile CycleProfile? menstrualCycles MenstrualCycle[] cycleDayLogs CycleDayLog[] cyclePrediction CyclePrediction? // v1.15.0 — owner of a per-user custom CycleSymptom (NULL = the global // seeded catalogue). cycleSymptomsCustom CycleSymptom[] @relation("UserCycleSymptoms") // v1.15.20 — user-authored "about me" context for the AI surfaces // (1:1, encrypted free text). See `UserHealthProfile`. healthProfile UserHealthProfile? // v1.15.20 — registration invite tokens (closed-registration flow). // `InviteCreator` = admin who minted the invite; `InviteConsumer` = // the account that registered through it (last consumer wins on a // multi-use invite). invitesCreated InviteToken[] @relation("InviteCreator") invitesUsed InviteToken[] @relation("InviteConsumer") // v1.16.0 — per-signup invite redemption ledger (multi-use history). inviteRedemptions InviteRedemption[] @relation("InviteRedemptions") @@map("users") } // ─── v1.25 (W-ENV) Environmental-context module ────────────────────────────── /// How the location for an environment observation was resolved. HOME = the /// user's configured home location; TRAVEL = an explicit dated location period /// (a past trip OR a corrected stretch of history) that overrides home for the /// days it covers; DEVICE = a future client-supplied coarse per-day location. /// DEVICE is the priority forward path for the iOS v2 client: the value exists /// so the resolver and the wire contract already accept it, but nothing writes /// it server-side yet. enum EnvironmentLocationSource { HOME TRAVEL DEVICE } /// A per-day environmental observation (daily weather / daylight) for the /// location resolved for that day. Day-keyed exactly like the rollup tier: /// `date` is a YYYY-MM-DD string anchored to the location's IANA timezone, so a /// late-evening reading does not mis-bucket under UTC. One row per (user, day); /// a re-fetch upserts (the archive feed settles over a few days). All weather /// fields are nullable so a partial feed still stores what it has. The location /// stored here is coarse (city granularity) — never a GPS track. model EnvironmentContext { id String @id @default(cuid()) userId String @map("user_id") user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// YYYY-MM-DD anchored to the resolved location's timezone. date String /// Coarse resolved location for the day (rounded lat/lon + label + the source /// that won precedence). lat Float lon Float locationLabel String @map("location_label") source EnvironmentLocationSource /// Daily temperature (°C). Min doubles as the overnight-low proxy. tempMin Float? @map("temp_min") tempMax Float? @map("temp_max") tempMean Float? @map("temp_mean") /// Daily apparent ("feels-like") mean (°C). apparentMean Float? @map("apparent_mean") /// Sunshine + daylight, stored in seconds (the feed's native unit). sunshineSec Int? @map("sunshine_sec") daylightSec Int? @map("daylight_sec") /// Precipitation total (mm) over the local day. precipSum Float? @map("precip_sum") /// Pressure aggregated from the hourly feed: daily mean (hPa) + the intraday /// delta (max−min, hPa) — the derived feature for the headache/symptom overlay. pressureMean Float? @map("pressure_mean") pressureDelta Float? @map("pressure_delta") /// Relative humidity mean (%) and cloud-cover mean (%) from the hourly feed. humidityMean Float? @map("humidity_mean") cloudMean Float? @map("cloud_mean") /// WMO weather code for the day (descriptive condition lookup). weatherCode Int? @map("weather_code") /// When the row was last fetched/refreshed from the upstream feed. fetchedAt DateTime @default(now()) @map("fetched_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@unique([userId, date]) @@index([userId, date]) @@map("environment_contexts") } /// An explicit dated LOCATION PERIOD: for any day in `[startDate, endDate]` the /// weather fetch resolves against this location instead of the home location. /// Declared, not inferred — it dominates the home fallback so a stretch spent /// elsewhere is not mis-attributed to the home city. Two uses share this one /// model: a discrete trip away from home, AND the mechanism to correct history /// (e.g. a whole-history home city for the years before the current home became /// effective). Coarse location only. (Stored under `source = TRAVEL`.) model EnvironmentTravelLocation { id String @id @default(cuid()) userId String @map("user_id") user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// Inclusive YYYY-MM-DD range (anchored to the user's home timezone for the /// day-key the override matches against). startDate String @map("start_date") endDate String @map("end_date") lat Float lon Float label String createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@index([userId, startDate, endDate]) @@map("environment_travel_locations") } 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") // v1.23 — stamped each time the credential's counter is bumped on a // successful assertion, so the management UI can surface "last used". lastUsedAt DateTime? @map("last_used_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") // v1.23 — step-up freshness stamp. Set when the session completes a second // factor (or re-confirms it); `requireFreshMfa` (a later wave) reads it. mfaVerifiedAt DateTime? @map("mfa_verified_at") // v1.23 — sliding "last seen" for the user-facing active-session list. lastActiveAt DateTime? @map("last_active_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) // v1.18.7 — the sliding-30-day session reaper sweeps on `expiresAt`; without // this index the cleanup degrades to a sequential scan on a busy instance. @@index([expiresAt]) @@map("sessions") } // ─── v1.23 second-factor (MFA) substrate ─────────────────── // These tables back the TOTP / WebAuthn-second-factor / recovery-code flows // that later waves build on. Phase F lands the empty stores only. // Single-use step-up ticket minted after a valid password when the account // has a second factor enabled. The partial "password OK, awaiting factor 2" // state lives in this row — never in a half-built Session — so no session or // token is issued before the second factor passes. The ticket is presented as // an opaque value; only its hash is stored. model MfaChallenge { id String @id @default(cuid()) userId String @map("user_id") // Which factor the ticket is being exercised against ("totp" | "webauthn" | // "recovery_code"). A free-form column; the verify route validates it. kind String ticketHash String @unique @map("ticket_hash") // hashToken(ticket) attempts Int @default(0) expiresAt DateTime @map("expires_at") consumedAt DateTime? @map("consumed_at") createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) // The verify path and the expiry sweep both filter on `expiresAt`. @@index([expiresAt]) @@map("mfa_challenges") } // One row per single-use recovery code. The code is Argon2id-hashed at rest // (same primitive as the password); `usedAt` burns it. Regeneration deletes the // prior set in a transaction. model MfaRecoveryCode { id String @id @default(cuid()) userId String @map("user_id") codeHash String @map("code_hash") // Argon2id usedAt DateTime? @map("used_at") createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@map("mfa_recovery_codes") } // Second-factor WebAuthn credential (typically a roaming security key), kept // SEPARATE from `Passkey`: a `Passkey` row is a passwordless-primary login // credential, whereas an MFA credential only ever runs after the password has // identified the account. Mirrors the `Passkey` field encoding. model WebauthnMfaCredential { id String @id @default(cuid()) userId String @map("user_id") name String @default("Security key") credentialId String @unique @map("credential_id") credentialPublicKey Bytes @map("credential_public_key") counter BigInt @default(0) transports String[] // e.g. ["usb", "ble", "nfc", "internal"] createdAt DateTime @default(now()) @map("created_at") lastUsedAt DateTime? @map("last_used_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@map("webauthn_mfa_credentials") } // v1.23 — login-alert dedupe ledger. One row per (user, coarse device // fingerprint). The fingerprint is a one-way SHA-256 hash of // `userId | normalised-User-Agent | coarse-location-or-asn` — never the raw // User-Agent or IP, so the table holds no directly identifying request // metadata (`deviceHash` is salted by the userId, so the same browser on two // accounts yields two unrelated hashes). `label` is a coarse, human-readable // descriptor for the activity surface (e.g. "Firefox on macOS — Berlin, DE"); // it intentionally omits the IP. First sighting of a hash fires one new-device // notification through the dispatcher; later sightings only bump `lastSeenAt`. model UserKnownDevice { id String @id @default(cuid()) userId String @map("user_id") deviceHash String @map("device_hash") label String? firstSeenAt DateTime @default(now()) @map("first_seen_at") lastSeenAt DateTime @default(now()) @map("last_seen_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) // Dedupe is per-user: the same coarse fingerprint can never be inserted // twice for one account, so the "first sighting → alert" decision is a // single unique-constrained upsert. @@unique([userId, deviceHash]) @@index([userId]) @@map("user_known_devices") } // v1.23 — "remember this device" trusted-device token. // // At the second-factor step the user may opt in to trusting the browser for 30 // days. On opt-in a random token is minted, set in an httpOnly+Secure cookie, // and only its HMAC hash (`tokenHash`, keyed by `API_TOKEN_HMAC_KEY`) is stored // here — a leaked row cannot reconstruct a usable cookie. On a later sign-in // from the same browser the password is still required (factor 1); a matching, // unexpired trusted-device row lets the login skip factor 2 only. The token is // NEVER accepted for step-up (`requireFreshMfa`) — a stolen cookie cannot reach // a destructive action. Revocable from the device list and revoked wholesale // when the second factor is removed or the user signs out everywhere. model TrustedDevice { id String @id @default(cuid()) userId String @map("user_id") tokenHash String @unique @map("token_hash") label String? createdAt DateTime @default(now()) @map("created_at") expiresAt DateTime @map("expires_at") lastUsedAt DateTime @default(now()) @map("last_used_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) // The reaper for expired trusted devices sweeps on `expiresAt`. @@index([expiresAt]) @@map("trusted_devices") } 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]) // v1.18.7 — challenges are swept by expiry; index the column the reaper // filters on so the cleanup stays index-backed. @@index([expiresAt]) @@map("auth_challenges") } // ─── Measurements ────────────────────────────────────────── enum MeasurementType { WEIGHT BLOOD_PRESSURE_SYS BLOOD_PRESSURE_DIA PULSE BODY_FAT SLEEP_DURATION // v1.4.23: unit semantics shifted from hours to minutes so HealthKit category samples (per-stage rows) survive without precision loss. 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. // ── v1.4.23 Apple Health additions ── HEART_RATE_VARIABILITY // ms — HealthKit `heartRateVariabilitySDNN` (typically one sample per night). RESTING_HEART_RATE // bpm — HealthKit `restingHeartRate` (one sample per day; distinct from spot PULSE). ACTIVE_ENERGY_BURNED // kcal — HealthKit `activeEnergyBurned`, cumulative; daily-rollup pattern. FLIGHTS_CLIMBED // count — HealthKit `flightsClimbed`, cumulative. WALKING_RUNNING_DISTANCE // metres — HealthKit `distanceWalkingRunning`; SI metres in DB, UI converts per locale. VO2_MAX // mL/(kg·min) — HealthKit `vo2Max`; one sample every few days at most. BODY_TEMPERATURE // celsius — HealthKit `bodyTemperature`; iOS DTO already encodes this enum value. // ── v1.4.25 W5d Withings full coverage ── FAT_FREE_MASS // kg — Withings meastype 5 (Body+, Body Cardio, Body Comp, Body Scan). Algorithmically = weight − fat mass. FAT_MASS // kg — Withings meastype 8. Mass form of BODY_FAT (kg, not %). MUSCLE_MASS // kg — Withings meastype 76. Body+ family. SKIN_TEMPERATURE // celsius — Withings meastype 73 (ScanWatch dermal reading). DISTINCT from BODY_TEMPERATURE — surface temps run ~32 °C, core ~37 °C; sharing a bucket would corrupt analytics. PULSE_WAVE_VELOCITY // m/s — Withings meastype 91 (Body Cardio / Body Scan). Indicator of arterial stiffness. VASCULAR_AGE // years — Withings meastype 155 (Body Scan). Composite of PWV + age. VISCERAL_FAT // rating — Withings meastype 170 (Body Comp / Body Scan). Withings' rating scale (1–12); not directly comparable to a percent. // ── v1.4.25 W8d Apple Health server-prep ── AUDIO_EXPOSURE_ENV // dBA — HealthKit `environmentalAudioExposure`; ambient noise sample (e.g. concert, traffic). Watch + iPhone microphone. AUDIO_EXPOSURE_HEADPHONE // dBA — HealthKit `headphoneAudioExposure`; AirPods listening-volume sampling. TIME_IN_DAYLIGHT // minutes — HealthKit `timeInDaylight` (iOS 17+). Sun-exposure daily rollup; mood + sleep correlate. // ── v1.4.30 R-F T1.4 + T1.5 Tier-1 additions ── WALKING_STEADINESS // percent (0-100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `appleWalkingSteadiness` (iOS 15+). Daily rollup. Falls + mobility-risk signal Apple's own Health app shows prominently in the Mobility section. AUDIO_EXPOSURE_EVENT // count (event flag: 1 per fired loud-listening event) — HealthKit `environmentalAudioExposureEvent` + `headphoneAudioExposureEvent` category-events. Apple fires these when the rolling 7-day average crosses the WHO 80-dBA threshold. The notes field carries the source identifier (env vs headphone). // ── v1.5.5 iOS-coord additions — six identifiers the deferred list refused to map. ── // Each one was emitted by the iOS app, dropped server-side with a // per-entry `skipped:"unmappable_identifier"`, and ack-200'd to the // client. iOS read 200 as success, advanced its sync anchor, and the // samples were lost forever. Now mapped end-to-end. RESPIRATORY_RATE // count/min — HealthKit `respiratoryRate`. Watch + iPhone sleep + workout sampling. BODY_MASS_INDEX // kg/m² — HealthKit `bodyMassIndex`. iOS computes it from weight + height before upload; we still store it as a first-class metric for trend display. LEAN_BODY_MASS // kg — HealthKit `leanBodyMass`. Body-composition counterpart to FAT_MASS; pairs with WEIGHT − FAT_MASS for sanity checks. WALKING_HEART_RATE_AVERAGE // bpm — HealthKit `walkingHeartRateAverage`. Daily rollup; cardio-fitness signal distinct from RESTING_HEART_RATE and spot PULSE. WALKING_ASYMMETRY // percent (0-100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `walkingAsymmetryPercentage`. Mobility section sibling to WALKING_STEADINESS. WALKING_DOUBLE_SUPPORT // percent (0-100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `walkingDoubleSupportPercentage`. Mobility section sibling. // ── v1.5.5 iOS-coord follow-up — gait raw-SI pair ── // Both identifiers flow over the wire in raw SI units (no ×100 // scaling). The percent gait metrics above use the server-side // scaling path because Apple ships them as 0..1 fractions; the two // below are already in metres and metres-per-second respectively // and pass through `convertToDbUnit` as identity. WALKING_STEP_LENGTH // metres — HealthKit `walkingStepLength`. Mobility section gait metric; adult walking typically 0.5–0.8 m. WALKING_SPEED // metres per second — HealthKit `walkingSpeed`. Mobility section gait metric; healthy adult casual gait ≈ 1.2–1.4 m/s. // v1.10.0 — additive HealthKit signals (WX-A) // Seven previously-deferred quantity identifiers, wired end-to-end with // the generic metric-status assessment treatment. Each rides the same // server-side `convertToDbUnit` + plausibility-range path as the rest of // the HealthKit surface; see `src/lib/measurements/apple-health-mapping.ts`. CARDIO_RECOVERY // bpm — HealthKit `heartRateRecoveryOneMinute` (iOS 16+). The drop in heart rate one minute after peak exercise; a larger drop signals fitter autonomic recovery (higher-better). WRIST_TEMPERATURE // celsius — HealthKit `appleSleepingWristTemperature` (iOS 16+). Overnight wrist temperature; Apple surfaces it as a baseline deviation, we store the absolute reading. DISTINCT from BODY_TEMPERATURE (core) and SKIN_TEMPERATURE (Withings dermal spot). FALL_COUNT // count — HealthKit `numberOfTimesFallen`. Hard-fall detections; cumulative daily count, lower-better. SIX_MINUTE_WALK_DISTANCE // metres — HealthKit `sixMinuteWalkTestDistance`. Apple's estimated 6-minute-walk-test distance; mobility + cardiopulmonary endurance signal, higher-better. STAIR_ASCENT_SPEED // metres per second — HealthKit `stairAscentSpeed`. Mobility section gait metric measured while climbing stairs, higher-better. STAIR_DESCENT_SPEED // metres per second — HealthKit `stairDescentSpeed`. Mobility section gait metric measured while descending stairs, higher-better. BREATHING_DISTURBANCES // count — HealthKit `appleSleepingBreathingDisturbances` (iOS 18+). Per-night sleep-breathing-disturbance index Apple classifies as NotElevated / Elevated; lower-better. // v1.10.0 — categorical events (WX-B) // Device-flagged EVENT rows. These are NOT continuous measurements — // each row records a discrete on-device notification the user's wearable // produced (Apple Watch / Withings ScanWatch). `value` is always 1 (one // fired event); the device's verdict / severity lands in the // `rhythmClassification` column (see the `RhythmClassification` enum and // the CHECK constraint in migration 0105). HealthLog never re-derives or // re-classifies the underlying signal — it only stores and surfaces the // result the device's own certified algorithm already emitted. IRREGULAR_RHYTHM_NOTIFICATION // HealthKit `HKCategoryTypeIdentifierIrregularHeartRhythmEvent` + ScanWatch AFib screening. The certified on-device algorithm flagged a possible irregular rhythm. AWARENESS ONLY — never a HealthLog diagnosis. HIGH_HEART_RATE_EVENT // HealthKit `HKCategoryTypeIdentifierHighHeartRateEvent`. The watch detected a sustained high heart rate above the user's configured threshold while apparently inactive. LOW_HEART_RATE_EVENT // HealthKit `HKCategoryTypeIdentifierLowHeartRateEvent`. The watch detected a sustained low heart rate below the user's configured threshold. WALKING_STEADINESS_EVENT // HealthKit `HKCategoryTypeIdentifierAppleWalkingSteadinessEvent`. The device flagged low / very-low walking steadiness (a falls-risk mobility signal); the severity rides in `rhythmClassification`. BREATHING_DISTURBANCE_EVENT // HealthKit `HKCategoryTypeIdentifierSleepApneaEvent` + `AppleSleepingBreathingDisturbances`. The device flagged an elevated breathing-disturbance / possible sleep-apnea signal during sleep. SCREENING SIGNAL ONLY — never a HealthLog diagnosis. // v1.10.0 — computed scores (WX-C) // Server-derived wellness scores (0–100, unit `score`). Each is minted by a // nightly engine from the user's already-stored signals and persisted as a // `COMPUTED`-source Measurement row — never ingested from a client. Only // RECOVERY_SCORE is computed in v1.10.0; STRESS_SCORE + STRAIN_SCORE are // defined now so the later engines that compute them need no schema change. // These are NOT clinical vitals — they never appear in the doctor-report PDF. RECOVERY_SCORE // Daily recovery / readiness proxy. Higher = more recovered. Blends RHR + HRV (SDNN) + sleep + respiratory-rate deviation from the personal baseline (Plews 2013 / Buchheit 2014 lineage). Descriptive, not a clinical or training-recovery assessment. v1.11.0: WHOOP ships a device-native Recovery into this SAME type, distinguished by `source = WHOOP` vs `COMPUTED` — the two unique keys keep both rows; the cross-source picker decides display. STRESS_SCORE // Reserved for the later strain/stress engine. Higher = more physiological stress. No engine computes it in v1.10.0. STRAIN_SCORE // Reserved for the later strain/stress engine. Higher = more cardiovascular strain (workout-load lineage). No engine computes it in v1.10.0. WHOOP day-strain does NOT reuse this — it lands in DAY_STRAIN so the WHOOP-native value and HealthLog's TRIMP proxy never share a bucket. // ── v1.11.0 — WHOOP integration (additive) ── // Native WHOOP scores ingest as first-class `source = WHOOP` Measurement // rows. Where the concept already exists as a COMPUTED type (RECOVERY_SCORE) // the WHOOP row reuses that type and is distinguished by source; where it // would collide with a HealthLog engine (STRAIN_SCORE) or a different // physiological measure (SDNN HEART_RATE_VARIABILITY) it gets a NEW type so // the native value and the derived proxy never share a bucket. See // `.planning/v1.11-build/epic-A-whoop-buildspec.md` §3.2 / §5. HRV_RMSSD // ms — WHOOP `recovery.score.hrv_rmssd_milli`. The RMSSD HRV metric WHOOP reports; kept structurally distinct from the SDNN HEART_RATE_VARIABILITY (different estimator, not interchangeable). DAY_STRAIN // score (0–21) — WHOOP `cycle.score.strain`. The day's cardiovascular load on WHOOP's 0–21 scale. Distinct from the COMPUTED STRAIN_SCORE (0–100 TRIMP proxy) so the two never share a bucket. WORKOUT_STRAIN // score (0–21) — WHOOP `workout.score.strain`. Per-workout strain; preferentially stored in `Workout.metadata` (tied to the workout row) rather than as a free-floating Measurement, but the type exists for the rare detached case. SLEEP_PERFORMANCE // % (0–100) — WHOOP `sleep.score.sleep_performance_percentage`. Sleep achieved vs sleep needed. SLEEP_EFFICIENCY // % (0–100) — WHOOP `sleep.score.sleep_efficiency_percentage`. Time asleep vs time in bed. SLEEP_CONSISTENCY // % (0–100) — WHOOP `sleep.score.sleep_consistency_percentage`. Night-to-night bed/wake timing regularity. SLEEP_NEED // minutes — WHOOP `sleep.score.sleep_needed.baseline_milli` (+ debt / strain / nap components; ms→min). The model's recommended sleep duration for the night. ENERGY_EXPENDITURE_KJ // kJ — WHOOP `cycle.score.kilojoule`. Day energy expenditure in kilojoules (WHOOP reports kJ natively; kept in kJ rather than converted to kcal so the device-native value round-trips). // ── v1.12.8 — WHOOP cycle + sleep coverage completion (additive) ── // Three previously-uncaptured WHOOP fields. AVERAGE_HEART_RATE / MAX_HEART_RATE // ride the cycle score (already fetched, previously dropped); kept distinct // from spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE because // they are the day's whole-cycle aggregates. SLEEP_DISTURBANCE_COUNT is the // per-night `stage_summary.disturbance_count`. AVERAGE_HEART_RATE // bpm — WHOOP `cycle.score.average_heart_rate`. The day's whole-cycle average heart rate. MAX_HEART_RATE // bpm — WHOOP `cycle.score.max_heart_rate`. The day's whole-cycle peak heart rate. SLEEP_DISTURBANCE_COUNT // count — WHOOP `sleep.score.stage_summary.disturbance_count`. Per-night tally of sleep disturbances. // ── v1.17.1 — Polar Nightly Recharge + Training Load Pro coverage (additive) ── // Two native Polar signals with no existing home. ANS_CHARGE is the HRV-based // autonomic-nervous-system charge component of Nightly Recharge — Polar's most // differentiated recovery signal, distinct from the 1–6 recovery band that // already maps to RECOVERY_SCORE. CARDIO_LOAD is Polar's Training Load Pro // cardiovascular-strain figure (its WHOOP-day-strain analogue); kept distinct // from DAY_STRAIN (WHOOP's 0–21 scale) and the COMPUTED STRAIN_SCORE proxy so // each device-native value keeps its own bucket and native scale. ANS_CHARGE // score — Polar Nightly Recharge `ans_charge`. The HRV-based autonomic-nervous-system charge (typically ~-10..+10 around the personal baseline; higher = more recovered). Descriptive, not a clinical assessment. CARDIO_LOAD // score — Polar Training Load Pro `cardio_load`. The session/day cardiovascular load (TRIMP-lineage strain). Distinct from WHOOP DAY_STRAIN (0–21) and the COMPUTED STRAIN_SCORE so the device-native value never shares a bucket. // ── v1.17.1 — Oura coverage completion (additive) ── // Oura's headline 0–100 Sleep Score from the `daily_sleep` collection // (distinct from the per-stage `sleep` detail). Stored as `source = OURA`, // unit `score`. Kept apart from SLEEP_PERFORMANCE / SLEEP_EFFICIENCY (WHOOP // sub-scores) so two vendors' headline numbers never share a bucket. SLEEP_SCORE // score (0–100) — Oura `daily_sleep.score`. Per-night sleep quality headline. // Oura nightly body-temperature deviation from the user's personal baseline // (`daily_readiness.temperature_deviation`), in °C. NOT an absolute // temperature — a signed offset (illness / luteal-phase / stress signal), // so it cannot share the BODY_TEMPERATURE / SKIN_TEMPERATURE / WRIST_TEMPERATURE // absolute-reading buckets. Plausible band is small and centred on 0. BODY_TEMPERATURE_DEVIATION // celsius (signed deviation) — Oura `daily_readiness.temperature_deviation`. // ── v1.19.0 — Oura resilience (additive) ── // Oura's daily resilience level from the `daily_resilience` collection — a // categorical band describing how well the body is coping with cumulative // load: limited / adequate / solid / strong / exceptional. Stored ORDINAL- // ENCODED into the numeric `value` (limited=1, adequate=2, solid=3, strong=4, // exceptional=5) so it fits the existing numeric Measurement model with no new // categorical column or CHECK constraint. Unit is the level scale (`level`). // The 5-level → ordinal mapping is the single source of truth in // `src/lib/oura/client.ts` (RESILIENCE_LEVELS). Stored as `source = OURA`. RESILIENCE // level (ordinal 1–5) — Oura `daily_resilience.level` (limited=1 … exceptional=5). // ── v1.25 — clinical-signals wave (manual / instrument-derived) ── // PHQ-9 / GAD-7 total scores. Server-derived from a completed // `MentalHealthAssessment` (one measurement row per administration, value = // total). Unit `score`. The raw item answers live encrypted on the // assessment row, never here — these rows carry only the numeric trend so // the existing chart / rollup infra reads them with no item exposure. PHQ9_SCORE // score (0–27) — PHQ-9 depression screen total. Opt-in. See MentalHealthAssessment. GAD7_SCORE // score (0–21) — GAD-7 anxiety screen total. Opt-in. See MentalHealthAssessment. // Maximal isometric handgrip force (best of all trials, either hand). The // EWGSOP2 sarcopenia floor is sex-specific (men < 27 kg, women < 16 kg); the // band is applied at display time, the column stores the raw kg. GRIP_STRENGTH // kg — hand-dynamometer maximal grip (EWGSOP2 sarcopenia limb). // Patient-reported pain intensity on the 0–10 Numeric Rating Scale // (0 = no pain, 10 = worst imaginable). LOINC 72514-3. PAIN_NRS // score (0–10) — pain Numeric Rating Scale. // Waist circumference in cm (WHO STEPS protocol) + the ancestry-robust // waist-to-height ratio (WHtR; keep waist < half your height ⇒ < 0.5). WAIST_CIRCUMFERENCE // cm — waist circumference (WHO bands; ethnicity-aware at display). WAIST_TO_HEIGHT // ratio — waist-to-height ratio (WHtR ≥ 0.5 = increased risk). @@map("measurement_type") } /// v1.25 — validated mental-health self-assessment instruments: PHQ-9 /// (depression) + GAD-7 (anxiety). The discriminator on /// `MentalHealthAssessment`; extensible (WHO-5 / PSS-10 are natural future /// siblings) without a schema change. enum AssessmentInstrument { PHQ9 GAD7 @@map("assessment_instrument") } /// v1.25 — one completed administration of a validated mental-health screener /// (PHQ-9 / GAD-7). Opt-in, beside the existing `MoodEntry` (never replaces it). /// The per-item answers are the most sensitive payload, so they live in a /// single AES-256-GCM `Bytes` blob (`responsesEncrypted`) and are never indexed /// nor logged. The derived total / band / item-9 flag are denormalised /// (server-authoritative) for cheap history reads without decrypting. A /// matching `PHQ9_SCORE` / `GAD7_SCORE` Measurement row is written per /// administration (linked by `externalId = assessment:`) so the score rides /// the existing chart / rollup infra — the trend, never the item content. model MentalHealthAssessment { id String @id @default(cuid()) userId String @map("user_id") instrument AssessmentInstrument /// The validated translation actually presented (officially-validated wording /// per locale, English fallback otherwise — recorded for the audit trail). locale String /// Instrument variant identifier (e.g. "standard"); future-proofs revisions. version String @default("standard") /// Encrypted JSON blob: { items: number[], functionalDifficulty?: 0..3, schema: 1 }. /// AES-256-GCM via the shared Bytes codec; registered in ENCRYPTED_COLUMNS + /// the rotation script. The item-9 raw value lives ONLY here. responsesEncrypted Bytes @map("responses_encrypted") /// Derived total (PHQ-9 0–27, GAD-7 0–21). Denormalised for fast reads. totalScore Int @map("total_score") /// Derived severity band label (server-authoritative; descriptive, never a diagnosis). severityBand String @map("severity_band") /// PHQ-9 only: item 9 (self-harm ideation) answered > 0. A coarse boolean so /// the history view can re-surface crisis resources without decrypting. item9Flagged Boolean @default(false) @map("item9_flagged") /// Audit stamp: the supportive crisis-resource signpost was shown to the user. /// Never triggers any third-party alert — private, on-device-only signposting. crisisShownAt DateTime? @map("crisis_shown_at") /// Recall-window anchor (when the screener was taken). takenAt DateTime @map("taken_at") tz String? source String @default("WEB") externalId String? @map("external_id") syncVersion Int @default(0) @map("sync_version") deletedAt DateTime? @map("deleted_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, source, externalId]) @@index([userId, instrument, takenAt]) @@index([userId, updatedAt, id]) // `mental_health_assessments_user_external_id_unique` is a partial UNIQUE // index over `(user_id, external_id) WHERE external_id IS NOT NULL` — see // migration 0221. It deduplicates an outbox replay across sources (a repeat // client externalId returns the existing administration). Prisma can't express // a partial index in the schema language, so it lives in raw SQL and is // intentionally omitted here. @@map("mental_health_assessments") } /// v1.10.0 — categorical events (WX-B) /// The device's own classification RESULT / severity for an EVENT row. /// Non-NULL only for the categorical EVENT `MeasurementType`s /// (IRREGULAR_RHYTHM_NOTIFICATION, HIGH/LOW_HEART_RATE_EVENT, /// WALKING_STEADINESS_EVENT, BREATHING_DISTURBANCE_EVENT), NULL for every /// continuous measurement — mirroring the `GlucoseContext` / `SleepStage` /// nullable-context precedent. /// /// HealthLog stores ONLY the verdict the device's FDA-cleared / CE-marked /// on-device algorithm already produced. It never re-classifies a raw /// signal and never emits a diagnosis of its own. `IRREGULAR` means the /// device flagged a possible irregular rhythm; `NOT_DETECTED` / /// `INCONCLUSIVE` carry the device's own non-positive verdicts so the /// awareness timeline can be honest rather than only ever showing alarms. /// `LOW` / `VERY_LOW` carry the walking-steadiness severity; `FIRED` is the /// neutral "this event occurred" verdict for the high/low-HR + breathing /// events that have no finer device-side grading. enum RhythmClassification { IRREGULAR // device flagged a possible irregular rhythm (AFib screening) NOT_DETECTED // device's algorithm ran and did not flag an irregular rhythm INCONCLUSIVE // device could not classify (poor signal / out-of-range HR) LOW // walking-steadiness: low VERY_LOW // walking-steadiness: very low FIRED // neutral "event occurred" verdict (high/low-HR, breathing disturbance) @@map("rhythm_classification") } /// 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") } // Hour-cycle display preference for rendered times. AUTO defers to the // active locale's convention; H12/H24 pin the cycle regardless of locale. enum TimeFormatPreference { AUTO H12 H24 @@map("time_format_preference") } // Date display preference for rendered dates. AUTO defers to the active // locale's convention; DMY/MDY/YMD pin the field order regardless of locale. enum DateFormatPreference { AUTO DMY MDY YMD @@map("date_format_preference") } enum MeasurementSource { MANUAL WITHINGS IMPORT APPLE_HEALTH // v1.4.23 — HealthKit batch ingest. iOS DTO mirrors this exact spelling (not "HEALTHKIT"). // v1.10.0 — computed scores (WX-C) // Server-derived rows. A `COMPUTED` measurement is minted by a nightly // engine from the user's already-stored signals (the Recovery score is the // first); it is NEVER ingested from a client. Server-owned / read-only: // the client-facing batch + single-POST write surfaces reject it exactly // like WITHINGS / IMPORT, but it is part of the read enum so iOS can decode // the rows it queries / charts. COMPUTED // v1.11.0 — WHOOP integration. Native WHOOP scores ingest server-side // (no client write path) as `source = WHOOP` rows. iOS DTO mirrors this // exact spelling so the `/api/sync/changes` delta feed decodes the rows. WHOOP // v1.12.0 — Fitbit/Pixel integration via the Google Health API. Server-owned // (no client write path); rejected on batch + single POST like WITHINGS/WHOOP. // iOS DTO mirrors this exact spelling so /api/sync/changes decodes the rows. FITBIT // v1.17.0 — Nightscout glucose integration (F1). Server-owned (no client // write path); glucose rows ingest from the user's Nightscout instance. // Rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS DTO // mirrors this exact spelling so /api/sync/changes decodes the rows. NIGHTSCOUT // v1.17.0 — Polar OAuth integration (F4). Server-owned (no client write // path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS // DTO mirrors this exact spelling so /api/sync/changes decodes the rows. POLAR // v1.17.0 — Oura OAuth integration (F4). Server-owned (no client write // path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS // DTO mirrors this exact spelling so /api/sync/changes decodes the rows. OURA // v1.19.2 — measurements captured from a numeric reply to a Telegram // measurement reminder. Unlike the server-owned integrations above, this // is a user-driven write, but it enters through the webhook (chat-bound) // rather than the cookie/Bearer client write path, so it carries its own // source label. The client batch + single-POST surfaces still reject it // (server-resolved only); iOS DTO mirrors this spelling so the // /api/sync/changes delta feed decodes the rows. TELEGRAM // v1.22.0 — measurements logged through the remote MCP write surface. A // distinct provenance from MANUAL/TELEGRAM: the row was confirmed and // written by an external assistant over the `/mcp` wire under a // `health:write`-scoped token, never the cookie/Bearer client write path. // Its own value gives the MCP writes an isolated idempotency namespace // `(userId, type, MCP, externalId)`. The client batch + single-POST // surfaces still reject it (server-resolved only); iOS DTO mirrors this // spelling so the /api/sync/changes delta feed decodes the rows. MCP @@map("measurement_source") } /// Per-stage sleep label for HealthKit `HKCategoryValueSleepAnalysis` /// samples. Non-NULL only when `Measurement.type = SLEEP_DURATION`, /// mirroring the `GlucoseContext` pattern. A single night produces one /// row per stage (analytics sums them for `total_minutes`). enum SleepStage { IN_BED AWAKE ASLEEP // legacy iOS 15- `asleepUnspecified` — pre-iOS-16 sleep tracking with no stage breakdown. REM CORE DEEP @@map("sleep_stage") } model Measurement { id String @id @default(cuid()) userId String @map("user_id") type MeasurementType value Float // v1.19.2 (iOS #34 extension) — per-bucket spread for an aggregated row. // Set ONLY on the hourly heart-rate bucket // (`stats:HKQuantityTypeIdentifierHeartRate:`), where `value` // is the hour's AVERAGE bpm and these carry the hour's MIN / MAX bpm so a // client can render the intra-hour range without re-uploading raw // samples. NULL for every other row (per-sample readings, per-day // cumulative `stats:` totals, manual entries) — the series reader hands // them through only for the heart-rate kind. valueMin Float? @map("value_min") valueMax Float? @map("value_max") unit String // kg, mmHg, bpm, %, minutes (sleep), steps, mg/dL, kcal, m, ms, mL/(kg·min), celsius source MeasurementSource @default(MANUAL) measuredAt DateTime @map("measured_at") notes String? // LEGACY plaintext — superseded by `notesEncrypted` (v1.23). New writes go to the encrypted column and null this; the boot-time backfill migrates the remaining rows. Column drop is a follow-up release once the backfill reports zero remaining. // v1.23 — free-text measurement note, AES-256-GCM at rest (Bytes, matching // the IllnessDayLog / CycleDayLog / LabResult note convention). Registered // in ENCRYPTED_COLUMNS + the rotation script. notesEncrypted Bytes? @map("notes_encrypted") // External-system dedup key. Withings used `grpid` (numeric); v1.4.23 // adds Apple Health which uses `HKSample.uuid` (string). NULL for // manual entries — Postgres treats NULLs as distinct in unique // indexes, so two manual rows with the same (user, type, source) are // still allowed. externalId String? @map("external_id") // v1.4.23 — opaque marker for the source's schema version (HealthKit // sample's `metadata.HKWasUserEntered`, Withings model code, …). // Stored as a string so the ingest path can replay it back unchanged. externalSourceVersion String? @map("external_source_version") // Only set for BLOOD_GLUCOSE measurements. NULL for all others. glucoseContext GlucoseContext? @map("glucose_context") // v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL // only for `type = SLEEP_DURATION`. Mirrors the glucoseContext // pattern. A single night produces ~5 rows (one per stage). sleepStage SleepStage? @map("sleep_stage") // v1.10.0 — categorical events (WX-B). The device's own classification // verdict / severity for an EVENT row. NULL for every continuous // measurement; non-NULL only for the categorical EVENT `MeasurementType`s. // Mirrors the `sleepStage` / `glucoseContext` nullable-context columns. A // CHECK constraint (migration 0105) enforces the type pairing so a // continuous row can never carry a classification. NOT part of any unique // index — dedup stays on `(userId, type, source, externalId)` keyed by the // device's `HKSample.uuid`, so a re-posted event collapses to a duplicate. rhythmClassification RhythmClassification? @map("rhythm_classification") // v1.4.25 W8c — device-type tag for the cross-source canonical // picker. Stored as a free-text string (kept simple — Prisma enum // would require coordinated client + server bumps every time we // gain a new device class). The canonical values are documented in // `deviceTypeEnum` (src/lib/validations/source-priority.ts): // `watch | band | ring | phone | scale | other | unknown`. Legacy // rows + every pre-W8c Measurement stays NULL; the picker treats // NULL as `unknown`. Unique constraints intentionally do not // include `deviceType` so two ingest paths from the same source // still dedup on (type, source, externalId). deviceType String? @map("device_type") // v1.4.30 — iOS SyncMode foundation (R-E C-2). Monotonically // incremented on every server-side mutation of the row. iOS uses // it for last-writer-wins reconciliation when two devices write // the same logical reading. Default = 1 for the create path; the // update path is responsible for bumping it. Existing rows // start at the default after the additive migration so legacy // ingest keeps round-tripping unchanged. syncVersion Int @default(1) @map("sync_version") // v1.4.30 — soft-delete column. The DELETE path on the // by-external-ids endpoint flips this rather than removing the row // so paired iOS clients can reconcile deletions without losing the // audit trail. NULL = live row; non-NULL = tombstoned at the // recorded instant. The unique-by-externalId index keeps both // states distinguishable for the bulk-backfill UPSERT path. deletedAt DateTime? @map("deleted_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) // v1.19.0 — optional back-relation to the full ECG waveform recording(s) // paired with this EVENT row. SET NULL on the FK side keeps the trace if // the EVENT row is re-keyed / removed. ecgRecordings EcgRecording[] // Original measuredAt-based dedup — kept for legacy Withings ingest // and the manual UI's "no duplicate at the same wall-clock" guard. // // v1.4.25 W17b/c — `sleepStage` is the fifth axis so per-stage sleep // segments from a single night (one row per stage, same measuredAt) // can co-exist. Migration 0055 recreated the index with // `NULLS NOT DISTINCT` so non-sleep rows (sleepStage IS NULL) still // collide on the first four columns — same effective dedup as before // for every meastype other than SLEEP_DURATION. Future regenerations // must preserve the NULLS NOT DISTINCT clause (Prisma 7 still emits // NULLS DISTINCT by default). // v1.12.1 — these stay FULL uniques (not partial live-row uniques). // The compound-key write paths (consolidate-daily-mean, // drain-per-sample-cumulative, the Fitbit / WHOOP / Apple sync + // import) use `prisma.upsert`, which Prisma 7 compiles to native // `INSERT ... ON CONFLICT (cols)`; Postgres cannot arbiter an // `ON CONFLICT` against a partial unique without the predicate (which // Prisma does not emit), and consolidate-daily-mean deliberately // RESURRECTS a tombstoned canonical row via `update: { deletedAt: // null }`, which needs the unique to cover tombstones. So a partial // unique is unsafe here. The batch-ingest resurrection gap is closed // instead at the application layer (the existence probe filters // `deletedAt: null`). Contrast MedicationIntakeEvent, whose write path // is `create`-only and DID get the partial-unique treatment (0121). @@unique([userId, type, measuredAt, source, sleepStage]) // v1.4.23 — Apple Health batch dedup key. NULL externalId is distinct // (Postgres semantics) so manual entries don't collide here. @@unique([userId, type, source, externalId]) @@index([userId, type, measuredAt]) // v1.25 — type-less `(userId, measuredAt)` range scan. The bulk feature read // (`extractFeatures`) and consolidation scans walk a user's measurements by // time with no `type` predicate; `(userId, type, measuredAt)` can't serve // that as a prefix, so without this index Postgres scans the user's whole set // and sorts in memory. See migration 0210. @@index([userId, measuredAt]) // v1.15.13 — leads with `(userId, source)` so the management list's // source filter + recency sort is an index range scan instead of a // `userId` scan + in-memory sort. See migration 0136. @@index([userId, source, measuredAt(sort: Desc)]) @@index([externalId]) // v1.7.0 — supports the `/api/sync/changes` keyset feed, which walks // `where userId` ordered by `(updatedAt asc, id asc)` with `take // limit+1`. Without this index Postgres filters by `userId` then sorts // the user's entire measurement set per page; the delta drain allows // 120 pulls/min/user. See migration 0096. @@index([userId, updatedAt, id]) // `measurements_legacy_step_consolidation_idx` is a partial index // over `type = 'ACTIVITY_STEPS' AND deleted_at IS NULL` — see // migration 0087. It accelerates the v1.5.6 legacy step-consolidation // discovery scan. Prisma can't express a partial-predicate index, so // it lives in raw migration SQL and is intentionally omitted here. // `measurements_dense_intraday_retention_idx` is a partial index over // `type IN ('HEART_RATE_VARIABILITY','PULSE') AND deleted_at IS NULL` // — see migration 0108. It accelerates the v1.10.0 dense intra-day // retention drain's discovery + per-user scan. Same Prisma-can't- // express-a-partial-predicate reason it is omitted here. // `measurements_live_covering_idx` is a covering partial index over // `(user_id, type, measured_at DESC) WHERE deleted_at IS NULL` — see // migration 0183 (v1.18.10 P-5). It serves the DISTINCT-ON-latest, list, // and series reads for every type NOT covered by the two partials above // and removes the `deleted_at` heap re-check. Same Prisma-can't-express-a- // partial-predicate reason it is omitted here. @@map("measurements") } // ─── ECG waveform recordings (v1.19.0) ───────────────────── // // v1.18.11 captured only the device's AFib *verdict* for each on-device // ECG (one IRREGULAR_RHYTHM_NOTIFICATION Measurement EVENT row). This model // stores the full raw ECG **waveform** so a real trace can later be rendered. // // The waveform is sensitive health data, so the sample array is held // AES-256-GCM at rest in `waveformEncrypted` (Bytes) — the same Bytes-codec // precedent as `CoachMessage.encryptedContent`. The plaintext is the JSON // encoding of the integer micro-volt sample array returned by the Withings // Heart `get` endpoint (`body.signal`); decrypt → `JSON.parse` → number[]. // It NEVER lands as plaintext (fail-closed crypto in `src/lib/crypto.ts`). // // The non-sensitive descriptors stay plaintext so a future renderer can read // `samplingFrequency` / `sampleCount` / `durationSeconds` without a per-row // decrypt: `samplingFrequency` (Hz) pairs with the sample index to recover // each sample's time offset; `sampleCount` is the array length; `durationSeconds` // is `sampleCount / samplingFrequency`; `lead` is the recording lead/pathway // when the source reports it; `averageHeartRate` mirrors the source's reported // average BPM for the strip when present. // // `rhythmClassification` snapshots the same AFib verdict captured on the paired // Measurement EVENT row so the waveform self-describes without a join, and // `measurementId` links back to that row (SET NULL — the trace survives a // re-keyed/removed EVENT row). // // Idempotency: `externalRecordingId` is the source's stable per-recording id // (Withings `signalid`, or a `ts-` fallback when absent). The unique // `(userId, source, externalRecordingId)` lets a re-sync overwrite the same // recording in place rather than inserting a duplicate. // // RENDERING UI IS OUT OF SCOPE FOR v1.19.0 — this is ingest-only. A trace // view (and any analytics over the waveform) is a follow-up. model EcgRecording { id String @id @default(cuid()) userId String @map("user_id") source MeasurementSource @default(WITHINGS) /// Source's stable per-recording id (Withings `signalid`, or `ts-`). externalRecordingId String @map("external_recording_id") /// When the strip was recorded on-device (source timestamp). recordedAt DateTime @map("recorded_at") /// AES-256-GCM ciphertext of the JSON-encoded micro-volt sample array. waveformEncrypted Bytes @map("waveform_encrypted") /// Sampling rate of the signal in Hz (sample index → time offset). samplingFrequency Int @map("sampling_frequency") /// Number of samples in the (decrypted) waveform array. sampleCount Int @map("sample_count") /// `sampleCount / samplingFrequency` in seconds; NULL when frequency is 0. durationSeconds Float? @map("duration_seconds") /// Recording lead / pathway label when the source reports it (else NULL). lead String? /// Source-reported average heart rate (BPM) for the strip, when present. averageHeartRate Int? @map("average_heart_rate") /// Snapshot of the device AFib verdict for the paired EVENT row. rhythmClassification RhythmClassification? @map("rhythm_classification") /// FK to the IRREGULAR_RHYTHM_NOTIFICATION Measurement EVENT row, when known. measurementId String? @map("measurement_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) measurement Measurement? @relation(fields: [measurementId], references: [id], onDelete: SetNull) // Re-sync overwrites in place rather than inserting a duplicate. @@unique([userId, source, externalRecordingId]) @@index([userId, recordedAt(sort: Desc)]) @@map("ecg_recordings") } // ─── Measurement rollups (v1.5.0) ────────────────────────── // // Persistent rollup table — second cache tier behind the 60-s LRU. // One row per `(userId, type, granularity, bucketStart)`. The DAY // granularity is recomputed inline with every measurement write so // the next read is correct; WEEK / MONTH / YEAR granularities are // recomputed via the pg-boss `rollup-recompute` queue so the write // path stays sub-ms. The Apple Health import worker skips the per- // row hook and calls `recomputeUserRollups` once at the end of the // import to avoid quadratic write costs on 100k-row backfills. // // Stats semantics mirror the v1.4.34.1 comprehensive aggregator: // - `sd` = `STDDEV_POP(value)` (population SD, /N) // - `slope` = `REGR_SLOPE(value, EXTRACT(EPOCH FROM measured_at) / 86400.0)` // - `r2` = `REGR_R2(value, EXTRACT(EPOCH FROM measured_at) / 86400.0)` // so cached + live aggregates agree byte-for-byte. enum RollupGranularity { DAY WEEK MONTH YEAR @@map("measurement_rollup_granularity") } model MeasurementRollup { userId String @map("user_id") type MeasurementType granularity RollupGranularity bucketStart DateTime @map("bucket_start") @db.Timestamptz(3) /// v1.11.1 — source-aware grain. Rollup rows are minted per source so the /// read path can collapse overlapping sources (e.g. WHOOP + Apple Watch /// resting heart rate) to the ladder-canonical reading, while cumulative /// metrics (steps, energy) sum per source. Always non-null; migration 0115 /// purged the legacy source-blind aggregates and the boot backfill re-mints /// per-source rows. source MeasurementSource count Int mean Float minValue Float @map("min_value") maxValue Float @map("max_value") sd Float? slope Float? r2 Float? /// v1.4.39 W-SUM — cumulative-metric sum for the bucket. Populated /// alongside `mean / count` for every type; cumulative read paths /// (steps, flights, distance, daylight, active-energy) consume this /// directly instead of re-deriving from raw measurements. Nullable /// to keep the migration additive — the boot backfill fills NULLs /// for existing rows; new rows always carry the sum. sumValue Float? @map("sum_value") /// v1.20.0 F6 — per-bucket OLS regression accumulators over the same /// canonical-source raw rows the live REGR_* query reads. With /// `n = count` and `Σy = mean·count` these close the additive set so a /// windowed slope / r² / sd composes from summed accumulators across DAY /// buckets with a bit-identical result to live REGR_SLOPE / REGR_R2 / /// STDDEV_POP. The x-axis is epoch days REBASED to 2020-01-01 /// (`EXTRACT(EPOCH FROM measured_at) / 86400.0 − 18262`; v1.21.0 migration /// 0193) so `sum_xx` stays O(1e7) instead of O(1e10) and never sheds /// precision squaring a ~20 540 raw epoch-day. Slope / r² / sd are /// invariant under the x-shift, so the live (raw-epoch) REGR_* probe still /// parity-matches the cross-bucket compose. Nullable to keep migration 0190 /// additive — the boot backfill fills NULLs for pre-migration rows; new /// rows always carry them (they ride the same aggregate as mean / sum). sumX Float? @map("sum_x") sumXy Float? @map("sum_xy") sumXx Float? @map("sum_xx") sumYy Float? @map("sum_yy") computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@id([userId, type, granularity, bucketStart, source]) @@index([userId, type, granularity, bucketStart(sort: Desc)]) @@map("measurement_rollups") } // ─── Workouts (v1.4.25 W8d) ──────────────────────────────── // // HKWorkout-aligned typed columns for the analytics hot path + an // open `metadata Json?` blob for the source-specific tail (HK // metadata, Withings hr_zone_0..3, device bundle id). `sportType` is // a TEXT column rather than a Postgres enum so adding a new sport is // a Zod-only change — Apple alone carries 70+ HKWorkoutActivityType // values and the long tail will only grow. model Workout { id String @id @default(cuid()) userId String @map("user_id") /// Canonical sport string. See WorkoutSportType union in /// src/lib/validations/workout.ts when that ships. Free-text at the /// DB layer so a new sport is one Zod refinement away. sportType String @map("sport_type") startedAt DateTime @map("started_at") endedAt DateTime @map("ended_at") /// Denormalised seconds count — query convenience for the /// "longest run / cycle" PR detector + the Coach evidence chips. durationSec Int @map("duration_sec") /// Apple `totalEnergyBurned` / Withings `calories`. NULL when the /// source did not report it. totalEnergyKcal Float? @map("total_energy_kcal") /// Apple `totalDistance` / Withings `distance`. Metres. totalDistanceM Float? @map("total_distance_m") /// Apple computes the average post-hoc; Withings reports it /// directly as `hr_average`. avgHeartRate Int? @map("avg_heart_rate") maxHeartRate Int? @map("max_heart_rate") minHeartRate Int? @map("min_heart_rate") stepCount Int? @map("step_count") elevationM Float? @map("elevation_m") /// Withings `pause_duration`. Apple does not report this directly /// — derived from HKWorkoutEvent markers in the metadata blob. pauseDurationSec Int? @map("pause_duration_sec") source MeasurementSource @default(MANUAL) /// HK `HKWorkout.uuid` or Withings workout id. NULL for manual /// entries (Postgres NULL-distinct semantics keep them unique). externalId String? @map("external_id") externalSourceVersion String? @map("external_source_version") /// HKWorkoutEvent markers, Withings hr_zone_0..3, device bundle id. /// JSON only — never duck-typed at runtime; consumers run their /// own Zod schema against the slice they care about. metadata Json? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) route WorkoutRoute? samples WorkoutSamples? /// Ingest dedup. NULL externalId is distinct so two manual /// entries on the same day never collide here. /// /// Cross-source dedup (the SAME logical workout arriving from two /// paired sensors — e.g. Apple Watch + Withings ScanWatch — under /// different `source`/`externalId` pairs) is handled in two layers, /// mirroring the measurement-side `pickCanonicalSourceRows()`: /// * WRITE-time — `dedupeWorkoutBatch()` in /// src/lib/workouts/canonical-rows.ts groups an inbound batch by /// (userId, activityType, startedAt ± 90 s) and keeps one /// canonical row per logical workout BEFORE the insert, so the /// duplicate never reaches storage. /// * READ-time — `pickCanonicalWorkoutRows()` in /// src/lib/measurements/pick-canonical-workout-rows.ts collapses /// any cross-batch twins (e.g. one source's row from Monday's /// batch + the other's from Tuesday's backfill) so the dashboard /// "recent workouts" tile shows one row per logical workout. /// Both pickers resolve the user's `sourcePriorityJson` ladder. /// Note (v1.12.1): unlike Measurement / MedicationIntakeEvent, Workout /// has no `deletedAt` (workout deletes are hard deletes), so this stays /// a full unique — there is no tombstone to collide with and no /// resurrection gap to close. @@unique([userId, source, externalId]) /// Dashboard "recent workouts" query path. @@index([userId, startedAt]) /// "Show me my last 10 runs" — per-sport history. @@index([userId, sportType, startedAt]) @@map("workouts") } model WorkoutRoute { id String @id @default(cuid()) workoutId String @unique @map("workout_id") /// GeoJSON LineString — `{ "type": "LineString", coordinates: /// [[lon, lat, alt?], ...] }`. JSONB rather than PostGIS so the /// Coolify Postgres template stays extension-free (same decision /// v1.4.20 made for correlations); GPX import + Leaflet / MapLibre /// consume GeoJSON natively. geometry Json /// Optional per-sample timestamps (ISO strings) and speeds — /// parallel array to `geometry.coordinates`. NULL when the source /// only ships static GPX (e.g. Withings). sampleTimestamps Json? @map("sample_timestamps") createdAt DateTime @default(now()) @map("created_at") workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade) @@map("workout_routes") } // ─── Per-workout heart-rate (+ optional speed/power/cadence) series ── // // v1.10.0 — a route-independent companion to `WorkoutRoute`. // // The route table only exists when a workout has GPS geometry, so an // INDOOR session (treadmill, bike trainer, strength) had no home for // its per-sample heart-rate series even though HealthKit ships one. // That series is the input the training-strain engine (TRIMP / // time-in-HR-zone) needs, so decoupling it from the route is what // unblocks indoor strain. // // `samples` is a JSONB array `[{ t, hr?, speedMs?, power?, cadence? }]` // — `t` is an ISO timestamp, `hr` beats/min. Same JSONB-over-PostGIS // decision as `WorkoutRoute` (no extension dependency on a self-hosted // Postgres). 1:1 with the workout (FK `workout_id` UNIQUE) so a // re-ingested batch that wins the workout-row race but loses the // sample-row race is an idempotent no-op, exactly like the route table. // // `sample_count` is denormalised so the strain reader + observability // can size the series without deserialising the JSONB blob. The ingest // path caps the count (see MAX_WORKOUT_HR_SAMPLES in // src/lib/validations/workout.ts) to keep the per-workout payload — and // the largest new workout-side write stream — bounded. model WorkoutSamples { id String @id @default(cuid()) workoutId String @unique @map("workout_id") /// JSONB array of `{ t: ISO string, hr?: int, speedMs?, power?, /// cadence? }`. One entry per recorded sample; ordered by `t` /// ascending at ingest. Capped at MAX_WORKOUT_HR_SAMPLES. samples Json /// Denormalised `samples.length` — sized without parsing the blob. sampleCount Int @map("sample_count") createdAt DateTime @default(now()) @map("created_at") workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade) @@map("workout_samples") } // ─── Strain personal anchor cache (v1.10.3) ───────────────── // // Server-internal per-(user, day) cache that lets the nightly Strain // engine anchor the 0–100 map to the USER'S OWN recent training-day load // instead of a fixed population reference. Each row records the scored // day's day-total Banister TRIMP plus the running EWMA-smoothed personal // reference and which anchor produced that night's score (personal once // the cold-start floor is met, population before). Persisting day-total // TRIMP means the 42-day chronic window reads cheap cached values instead // of re-integrating 42 days of HR series every night. // // NOT a Measurement: it is never read through the derived registry, never // surfaced on any user-facing surface, never in the doctor PDF or FHIR // bundle, and never ingested from a client. Reusing the Measurement enum // would have pulled an internal cache type through every MeasurementType // completeness wall (chart tokens, categories, PR direction); a dedicated // table keeps it cleanly server-internal. Idempotent on `(userId, day)` so // a re-fired nightly tick overwrites the day's cache row in place. model StrainTrimpCache { id String @id @default(cuid()) userId String @map("user_id") /// The scored UTC calendar day (`YYYY-MM-DD`), matching the score row's day key. day String /// Day-total Banister TRIMP for the day (0 when no usable HR series). dayTrimp Float @map("day_trimp") /// EWMA-smoothed personal reference TRIMP in effect for this day's score. refPersonal Float @map("ref_personal") /// Which anchor produced this day's score: `personal` once enough personal /// training history accrued, `population` during cold start. anchor String /// Distinct training days (TRIMP > 0) seen in the chronic window — drives /// the cold-start gate + the confidence label. trainingDays Int @map("training_days") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, day]) @@index([userId, day(sort: Desc)]) @@map("strain_trimp_cache") } // ─── Personal records (v1.4.25 W8d — schema only) ────────── // // MAX vs MIN direction is stored on the row so the future detection // worker doesn't have to re-resolve a per-metric lookup at query time. // Some metric types deliberately have no PR (blood pressure, glucose, // raw weight without a goal direction); those never produce rows. // See src/lib/personal-records/pr-direction.ts for the lookup table. enum PersonalRecordDirection { MAX // higher is the record (steps, VO2 max, HRV, distance, daylight) MIN // lower is the record (resting HR, body fat, audio exposure) @@map("personal_record_direction") } model PersonalRecord { id String @id @default(cuid()) userId String @map("user_id") metricType MeasurementType @map("metric_type") /// Per-sport-type bucket for workout-driven PRs (e.g. /// "running_5km_time"). NULL for plain measurement-driven PRs /// where the metric type alone is the dimension. metricSlot String? @map("metric_slot") direction PersonalRecordDirection value Float unit String achievedAt DateTime @map("achieved_at") /// FK to the Measurement row that achieved the record. SET NULL /// on delete so the historical fact survives even if the /// underlying measurement is later removed. sourceMeasurementId String? @map("source_measurement_id") source MeasurementSource @default(MANUAL) externalId String? @map("external_id") createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// Idempotency guard for the detection worker — re-running it on /// the same (user, metric, slot, achievedAt) triple is a no-op. @@unique([userId, metricType, metricSlot, achievedAt]) /// Hot read pattern — "current PR for this metric": /// ORDER BY value DESC LIMIT 1 (MAX direction) / /// ASC LIMIT 1 (MIN direction). Index covers both. @@index([userId, metricType, value]) @@map("personal_records") } // ─── Medications ─────────────────────────────────────────── /// Treatment-class discriminator (v1.4.25 W4d). GLP1 turns on the /// specialist surfaces — body-map injection-site picker, weekly-cadence /// preset, titration history, GLP-1-aware Coach context, plateau /// detection. GENERIC keeps existing meds rendering exactly as before. /// Future categories (INSULIN, BIOLOGIC, FERTILITY, ...) drop into this /// enum without churning the side-table `medication_categories` that /// holds the clinical taxonomy (BLOOD_PRESSURE / VITAMIN / ...). enum MedicationCategory { GENERIC GLP1 @@map("medication_category") } /// v1.6.0 — route of administration, decoupled from `treatmentClass`. /// Drives the injection-site picker (available for any INJECTION, not /// only GLP-1) and the one-time-injection shape (`oneShot = true` + /// `deliveryForm = INJECTION`). ORAL is the backfilled default for every /// pre-v1.6.0 row. OTHER is the catch-all for topical / inhaled / nasal /// routes that don't need injection-site tracking. enum MedicationDeliveryForm { ORAL INJECTION OTHER @@map("medication_delivery_form") } /// v1.7.0 — schedule-type discriminator. SCHEDULED is the default (rrule /// / rolling / legacy cadence as today). PRN = as-needed: never /// projected, never reminded, excluded from compliance expected-count, /// still loggable. CYCLIC = N weeks on / M weeks off from the anchor; /// within an on-week the rrule / legacy cadence applies as usual. enum MedicationScheduleType { SCHEDULED PRN CYCLIC @@map("medication_schedule_type") } model Medication { id String @id @default(cuid()) userId String @map("user_id") name String dose String // e.g. "10mg" /// v1.4.25 W4d — treatment-class discriminator. GENERIC for the /// vast majority of meds; GLP1 unlocks injection-site rotation, /// dose-titration history, pen inventory, plateau detection, and /// the Coach's GLP-1-aware reply mode. Field name is intentionally /// not `category` to avoid clashing with the existing /// `medication_categories` side-table (clinical taxonomy) that the /// UI continues to read. treatmentClass MedicationCategory @default(GENERIC) @map("treatment_class") /// Doses per pen/vial — only meaningful for inventory-tracked meds /// (Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose /// pen = 1). NULL = inventory tracking off. dosesPerUnit Int? @map("doses_per_unit") /// v1.16.10 — units consumed per dose. The inventory items count UNITS /// (tablets, ampoules, puffs); a single dose may consume several of /// them (2 × 2 mg tablets for a 4 mg dose). The intake consumption /// hook multiplies by this factor; dose-level readouts divide by it. /// Default 1 = one unit per dose (the overwhelmingly common case). /// v1.16.12 (#316) — Decimal, so a dose can consume a FRACTION of a /// unit (½ / ⅓ / ¼ tablet for a split pill). The UI offers a curated /// set of common fractions; the column stores their decimal value /// (¼=0.25, ⅓≈0.3333, ½=0.5, ⅔≈0.6667, ¾=0.75) plus whole numbers. /// Existing integer rows cast losslessly. unitsPerDose Decimal @default(1) @map("units_per_dose") @db.Decimal(10, 4) active Boolean @default(true) notificationsEnabled Boolean @default(true) @map("notifications_enabled") pausedAt DateTime? @map("paused_at") snoozedUntil DateTime? @map("snoozed_until") /// v1.5 — date the medication course begins. Anchors RRULE BYDAY / /// BYMONTHDAY patterns and the rolling-interval countdown's first /// window. NULL = "active from creation" (the legacy implicit /// behaviour preserved for backwards compatibility). Required when /// `oneShot` is true. startsOn DateTime? @map("starts_on") @db.Date /// v1.5 — date the medication course ends. Reminder worker stops /// minting slots past this date. NULL = no end date (chronic). /// When `oneShot` is true, `endsOn` always equals `startsOn`. endsOn DateTime? @map("ends_on") @db.Date /// v1.5 — single-administration medication (flu shot, vaccine, /// post-op single dose, etc.). When true the medication has at /// most one MedicationIntakeEvent and `active` flips to false once /// that intake is logged. Mutually exclusive with rolling or /// recurring schedules; the form layer enforces the constraint and /// the migration backfills `oneShot = false` for every existing /// row. oneShot Boolean @default(false) @map("one_shot") /// v1.16.11 (#316) — as-needed (PRN) medication. Taken only when the /// user needs it (pain relief, rescue inhaler): no fixed schedule, /// never due, never reminded, never counted in compliance, stays /// active indefinitely — but intakes log, inventory consumes, and the /// history renders. An as-needed medication carries ZERO /// MedicationSchedule rows (the create/update routes 422 on a /// schedules array alongside `asNeeded: true`); every "never due / /// never reminded" surface follows structurally from the empty /// schedule list. Mutually exclusive with `oneShot`. asNeeded Boolean @default(false) @map("as_needed") /// v1.6.0 — route of administration. Decoupled from `treatmentClass` /// so the injection-site picker can surface for any INJECTION dose. /// A one-time injection is `oneShot = true` + `deliveryForm = /// INJECTION`. The migration backfills ORAL for every existing row. deliveryForm MedicationDeliveryForm @default(ORAL) @map("delivery_form") /// v1.8.5 — per-medication injection-site tracking opt-in. Only /// meaningful (and only offered in the UI) when `deliveryForm === /// INJECTION`. Default false: after marking a dose taken the client /// prompts for the site only when this is true, and the prompt is /// always skippable (never blocking). Deactivatable at any time. trackInjectionSites Boolean @default(false) @map("track_injection_sites") /// v1.8.5 — per-medication allowed / preferred injection sites. Empty /// = no per-medication restriction (every site is offered). The /// effective pickable set is this list MINUS the user-level /// `globalExcludedInjectionSites` deny-list (deny always wins). The /// intake write path validates a submitted site against the effective /// set and 422s on a disallowed value. allowedInjectionSites InjectionSite[] @default([]) @map("allowed_injection_sites") /// v1.7.0 — iOS Live Activity opt-in for this medication's reminders. /// Default false; existing rows backfill false. The server stores + /// echoes the flag; the iOS client owns the ActivityKit lifecycle. No /// server-side behaviour hangs off it. liveActivityEnabled Boolean @default(false) @map("live_activity_enabled") /// v1.7.0 — iOS 26 AlarmKit critical-reminder opt-in. Critical alarms /// bypass the device mute switch / Focus. Default false; the server /// stores the preference only — it does not itself authorise anything /// server-side. criticalAlarmEnabled Boolean @default(false) @map("critical_alarm_enabled") /// v1.9.0 — optional ATC classification code (active-substance class, /// e.g. `A10BX10`). User/clinician-asserted; never machine-guessed on /// the export path. NULL = no code (the FHIR exporter emits a text-only /// `medicationCodeableConcept`, the pre-v1.9.0 behaviour). The WHO URI /// `http://www.whocc.no/atc` is the emitted system. Validated on write /// against `^[A-Z]\d{2}[A-Z]{2}\d{2}$`. atcCode String? @map("atc_code") /// v1.9.0 — optional RxNorm RxCUI (US; secondary coding). Same NULL /// semantics as `atcCode`. Validated on write against `^\d+$`. Emitted /// under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any /// ATC code, never instead of the free-text `.text` anchor. rxNormCode String? @map("rxnorm_code") /// v1.16.11 — low-stock notification stamp. Set when the daily /// low-stock pass dispatched a notification for the current /// threshold crossing; cleared (re-armed) when the projected runway /// rises back to / above the user's threshold (refill). NULL = armed. lowStockNotifiedAt DateTime? @map("low_stock_notified_at") /// v1.16.11 — the user threshold (days) the stamp was written /// against. A changed threshold re-arms the medication even while /// the runway stays low, so the user hears about the new crossing. lowStockNotifiedThresholdDays Int? @map("low_stock_notified_threshold_days") /// v1.17.0 — optional per-medication reorder lead time in days (how /// long between placing a refill order and the new supply arriving). /// The low-stock alert adds this lead plus one dose-interval on top of /// the user threshold so the warning lands BEFORE the last dose for any /// cadence. NULL = inherit the per-user /// `notificationPrefs.medication.reorderLeadDays` default. Additive; no /// backfill. reorderLeadDays Int? @map("reorder_lead_days") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") schedules MedicationSchedule[] intakeEvents MedicationIntakeEvent[] phaseConfig ReminderPhaseConfig? telegramMessages TelegramReminderMessage[] doseChanges MedicationDoseChange[] inventoryEvents MedicationInventoryEvent[] /// v1.4.25 W19b — per-pen / per-vial entities (state machine + /// 30-day in-use clock). Complements the running-sum /// MedicationInventoryEvent ledger. inventoryItems MedicationInventoryItem[] /// v1.4.25 W19d — structured GLP-1 side-effect logs. Surfaced on /// the medication detail page for GLP-1 treatmentClass only; the /// taxonomy follows EMA EPAR §4.8 with HealthLog's 5-category /// roll-up (GI / METABOLIC / INJECTION_SITE / COGNITIVE / /// GLP1_SPECIFIC) and 1-5 Likert severity per entry. sideEffects MedicationSideEffect[] /// v1.4.39 W-MED — per-(medication, day) compliance rollup. Mutations /// on the linked MedicationIntakeEvent rows refresh the row inline; /// the read paths consume this instead of the per-event findMany. complianceRollups MedicationComplianceRollup[] /// v1.16.3 — archived schedule eras (effective dating). Superseded /// schedule states land here when a wholesale schedule replace changes /// cadence-relevant fields; the band minter segments historical minting /// ranges into eras so past days read against the schedule that was /// live THEN, not the current one. scheduleRevisions MedicationScheduleRevision[] /// v1.25 — durable pause intervals. A pause stamps an open era /// (`resumedAt = null`); resume closes the latest open era. The /// compliance engine drops expected slots whose anchor falls inside a /// pause interval so a resumed medication never counts the paused days /// as missed. The `pausedAt` column on this model stays the live /// single-pause marker the reminder/today surfaces read; the era table /// is the additive durable record the rate paths need. pauseEras MedicationPauseEra[] user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@index([treatmentClass]) @@map("medications") } model MedicationSchedule { id String @id @default(cuid()) medicationId String @map("medication_id") /// v1.4.25 W4e — schema-convention fix. Prior to this release the DB /// columns were the camelCase identifiers `"windowStart"` / `"windowEnd"` /// because the original 0001_init migration omitted `@map`. The /// Prisma client surface stays unchanged (`windowStart` / /// `windowEnd`); only the underlying SQL identifier is renamed to /// match the snake_case convention every other column on this model /// already follows. windowStart String @map("window_start") // HH:mm format, e.g. "08:00" windowEnd String @map("window_end") // HH:mm format, e.g. "10:00" label String? // e.g. "Morning", "Evening" dose String? // Per-schedule dose override; null = use Medication.dose /// Legacy string encoding of the cadence ("null | 1,3,5 | i2;1,3,5"). /// v1.5 writes go to `rrule` / `rollingIntervalDays` and v1.5.1 flipped /// the readers to consult the new fields first. /// /// v1.12.1 — RETAINED, not dropped. The "v1.6.0 drops this column" /// note here was never executed and is now corrected: the column is /// still DUAL-WRITTEN on every create/update (the medications POST / /// PATCH routes serialise the legacy `(daysOfWeek, intervalWeeks)` /// pair via `serializeScheduleRecurrence`) AND still read by live paths /// (`gamification/achievements`, `admin/notifications/reminder-check`, /// the medications list/detail pages parse it through /// `parseScheduleRecurrence`). Dropping it would break those callers, /// so it stays until those readers are migrated off the legacy pair. daysOfWeek String? @map("days_of_week") /// v1.5 — one or more `HH:mm` strings naming the times the dose is /// taken (in the user's timezone). Replaces the legacy single /// `windowStart` mental model — patients think in points-in-time, /// not in windows. Migration backfills `[windowStart]` for every /// pre-v1.5 row. timesOfDay String[] @default([]) @map("times_of_day") /// v1.5 — reminder grace window in minutes. Replaces the implicit /// `windowEnd - windowStart` computation for late-dose /// classification. NULL falls back to the legacy math (the /// reminder worker honours `windowEnd` when this is null). reminderGraceMinutes Int? @map("reminder_grace_minutes") /// v1.5 — RFC 5545 RRULE string for calendar-anchored cadences /// (every N weeks, monthly, quarterly, yearly). Examples: /// FREQ=DAILY /// FREQ=WEEKLY;BYDAY=MO,WE,FR /// FREQ=WEEKLY;INTERVAL=2;BYDAY=WE /// FREQ=MONTHLY;BYMONTHDAY=1 /// FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=10 /// FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1 /// Mutually exclusive with `rollingIntervalDays` — enforced by the /// `medication_schedules_rrule_xor_rolling` CHECK constraint added /// in the v1.5 migration. rrule String? @map("rrule") /// v1.5 — flexible-rolling interval in days, counted forward from /// the latest `MedicationIntakeEvent.takenAt` for the parent /// medication. NULL when the schedule is calendar-anchored. /// Mutually exclusive with `rrule`. Range 1..365 enforced at the /// Zod layer; the column itself is unconstrained at the DB so /// future extensions don't need a new migration. rollingIntervalDays Int? @map("rolling_interval_days") /// v1.7.0 — schedule-type discriminator. SCHEDULED = the existing rrule /// / rolling / legacy cadence. PRN = as-needed (never projected, /// reminded, or counted in compliance expected; still loggable). /// CYCLIC = N weeks on / M weeks off, gating whichever inner cadence /// the rrule / legacy fields describe. Backfills SCHEDULED. scheduleType MedicationScheduleType @default(SCHEDULED) @map("schedule_type") /// v1.7.0 — cyclic "on" weeks. Only meaningful when /// `scheduleType = CYCLIC`. The phase repeats (cyclicOnWeeks on, then /// cyclicOffWeeks off) from the medication's `startsOn ?? createdAt`. cyclicOnWeeks Int? @map("cyclic_on_weeks") /// v1.7.0 — cyclic "off" weeks. Only meaningful when /// `scheduleType = CYCLIC`. cyclicOffWeeks Int? @map("cyclic_off_weeks") /// v1.15.18 — per-dose on-time intake WINDOW (the configurable lever). /// A JSON array of `{ timeOfDay, start, end }` HH:mm triples, one per /// dose time the user wants an explicit range for (e.g. /// `[{ "timeOfDay": "07:00", "start": "07:00", "end": "09:00" }]`). /// The band minter reads this to build the on-time band from the /// explicit `[start, end]` range for the matching `timeOfDay`; a /// `timeOfDay` with no entry falls back to the symmetric ±1h default. /// The late tail stays cadence-derived. NULL = every slot uses the /// default derivation (unchanged behaviour). Additive + iOS-safe. doseWindows Json? @map("dose_windows") medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) telegramMessages TelegramReminderMessage[] @@map("medication_schedules") } /// v1.16.3 — schedule-revision archive (effective dating). One row per /// SUPERSEDED schedule state of a medication: when the schedule set is /// wholesale-replaced with materially different cadence fields, the old /// rows are archived here as a single revision covering /// `[validFrom, validUntil)`. The LIVE rows stay in /// `medication_schedules` — a revision never describes the current /// state, so `validUntil` is always set (no open-ended rows). /// /// The band minter segments any historical minting range into eras: /// each revision interval mints with ITS archived schedules, the /// remainder (from the newest `validUntil` onward) mints with the live /// rows. Era boundary rule: a slot belongs to the era its anchor lies /// in; an on-time/late tail may reach past the boundary, but no new /// anchor is minted beyond it. /// /// `startsOn` stays the global floor for ALL eras: the first era begins /// at `max(medication.createdAt, startsOn)`; a times-only edit must /// never move `startsOn`. model MedicationScheduleRevision { id String @id @default(cuid()) medicationId String @map("medication_id") /// Inclusive start of the era this archived state was live for. /// Chains from the previous revision's `validUntil`, or from /// `medication.createdAt` for the first revision. validFrom DateTime @map("valid_from") @db.Timestamptz(6) /// Exclusive end of the era — the instant the replace happened. validUntil DateTime @map("valid_until") @db.Timestamptz(6) /// Full snapshot of the superseded schedule rows: an array of /// `{ timesOfDay, windowStart, windowEnd, daysOfWeek, rrule, /// rollingIntervalDays, scheduleType, cyclicOnWeeks, cyclicOffWeeks, /// doseWindows, label, dose, reminderGraceMinutes }` objects — enough /// to rebuild a `CanonicalSchedule` list for era minting. payload Json /// v1.16.5 — provenance. 'ARCHIVED' = minted by the schedule-replace /// write path (immutable history). 'MANUAL' = user-entered pre-tracking /// era via POST /api/medications/{id}/schedule-revisions (deletable by /// the owner). source String @default("ARCHIVED") /// v1.16.6 — audit trail for corrected archives. When the owner /// corrects an ARCHIVED era through PATCH /// /api/medications/{id}/schedule-revisions/{revisionId}, the route /// mints a MANUAL replacement and points this column at it. The /// original row stays as the audit record; every era consumer /// (splitter, list endpoint, live-boundary reads) skips rows where /// this is set. supersededByRevisionId String? @map("superseded_by_revision_id") createdAt DateTime @default(now()) @map("created_at") medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) @@index([medicationId, validFrom]) @@map("medication_schedule_revisions") } /// v1.25 — durable medication pause intervals. The `Medication.pausedAt` /// column is a single live marker that resume clears, so the paused window /// is irrecoverable once the medication resumes. This table records each /// pause as its own row: a pause stamps an open era (`resumedAt = null`), /// resume closes the latest open era (`resumedAt = now`). The compliance /// engine reads the eras and drops expected dose slots whose anchor falls /// inside any `[pausedAt, resumedAt ?? now)` interval so a resumed /// medication never counts the paused days as missed. model MedicationPauseEra { id String @id @default(cuid()) medicationId String @map("medication_id") userId String @map("user_id") /// Inclusive start of the pause interval (the instant the medication /// was paused). pausedAt DateTime @map("paused_at") @db.Timestamptz(6) /// Exclusive end of the pause interval (the instant the medication was /// resumed). NULL = still paused; readers treat an open era as running /// to `now`. resumedAt DateTime? @map("resumed_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([medicationId]) @@index([userId]) @@map("medication_pause_eras") } enum IntakeSource { WEB API REMINDER IMPORT @@map("intake_source") } /// v1.15.20 — provenance of an intake row's slot binding. `USER_PIN` marks /// an attribution the user fixed by hand (widened in v1.16.0): either a /// deliberate "diesem Slot zuordnen" pin onto a named slot, or its released /// ("Zuordnung lösen") counterpart that stays a standalone ad-hoc take /// (`scheduledFor === takenAt`). The nightly slot dedup never folds a /// USER_PIN row into a snap cluster, so neither decision is reverted. enum IntakeAttributionSource { AUTO USER_PIN @@map("intake_attribution_source") } enum ReminderPhase { GREEN YELLOW ORANGE RED @@map("reminder_phase") } enum PhaseMode { MINUTES PERCENT @@map("phase_mode") } /// v1.4.25 W4d — eight self-injection zones for GLP-1 receptor /// agonists. Front-of-body view used by the picker. /// - Abdomen quadrants avoid a 2-inch radius around the navel per /// Eli Lilly's Mounjaro IFU + Novo Nordisk's Ozempic/Wegovy PI. /// - Thighs cover the anterior / outer surface; user-injectable. /// - Upper arms typically require assistance per the same PIs; the /// picker surfaces them but flags the constraint via i18n. enum InjectionSite { ABDOMEN_LEFT ABDOMEN_RIGHT ABDOMEN_UPPER_LEFT ABDOMEN_UPPER_RIGHT THIGH_LEFT THIGH_RIGHT UPPER_ARM_LEFT UPPER_ARM_RIGHT @@map("injection_site") } 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) /// v1.15.9 — distinguishes a never-acted forgotten dose from a /// deliberate user skip. The hourly auto-miss cron sets this (NOT /// `skipped`) on a pending row past its miss cutoff so the compliance /// engine counts it as a MISS in the denominator, while a user-initiated /// `skipped = true` stays excluded (a deliberate drug holiday). Every /// legacy row defaults to false (a user skip or a live pending row, /// neither of which is an auto-miss). autoMissed Boolean @default(false) @map("auto_missed") /// v1.15.20 — how this row's `scheduledFor` slot binding came to be. /// `AUTO` (default) = the write path attributed the take by window-band /// membership (or the row is a skip / pending mint anchored on its slot). /// `USER_PIN` = the user fixed the attribution by hand (v1.16.0): a /// deliberate pin onto a named slot ("diesem Slot zuordnen") OR the /// released ("Zuordnung lösen") row that stays deliberately ad-hoc /// (`scheduledFor === takenAt`). The read ledger binds slot-anchored /// USER_PIN rows by their `scheduledFor` anchor (like skips) instead of /// re-running takenAt-band attribution — visibly marked, counted late, /// never on-time-washed — and surfaces released rows as ad-hoc; the /// nightly dedup leaves both standalone. attributionSource IntakeAttributionSource @default(AUTO) @map("attribution_source") source IntakeSource @default(WEB) idempotencyKey String? @unique @map("idempotency_key") createdAt DateTime @default(now()) @map("created_at") /// v1.4.25 W4d — only populated for GLP-1 intake events (the picker /// is the only surface that writes this column). Existing rows stay /// NULL forever and the read paths treat NULL as "no site recorded". injectionSite InjectionSite? @map("injection_site") /// v1.16.4 — per-intake dose override. Free text (mirrors /// `Medication.dose`), populated only when the user edits the dose at /// log time (half tablet, titration day). NULL = the configured /// medication / schedule dose applies; read paths fall back to it. doseTaken String? @map("dose_taken") /// v1.16.10 — inventory consumption stamp: `[{itemId, units}]` for the /// containers this taken event decremented. NULL = never consumed /// (pending / skipped / pre-v1.16.10 rows). The stamp is the exactly- /// once gate for the consume hook (a stamped row never decrements /// again, however often a sync replays it) and the refund ledger for /// the restore hook (taken → skipped / deleted refunds per item). The /// stamp freezes history: editing `Medication.unitsPerDose` later /// never changes what an already-taken dose consumed. inventoryConsumption Json? @map("inventory_consumption") // v1.7.0 sync — server-set mutation timestamp. The model had no // `updatedAt`; the offline-sync keyset feed orders on it, so it is // added here. `@updatedAt` makes Prisma maintain it on every write. updatedAt DateTime @updatedAt @map("updated_at") // v1.7.0 sync — reconciliation counter. An intake is an immutable // fact, so a "correction" is a tombstone + re-insert rather than an // in-place edit; syncVersion still bumps on the soft-delete write so // the feed echoes a monotonic value. Default 0 for legacy rows. syncVersion Int @default(0) @map("sync_version") // v1.7.0 sync — soft-delete tombstone. The user-facing per-event // DELETE route flips this (and bumps syncVersion) rather than removing // the row, so `/api/sync/changes` surfaces the deletion as a tombstone // keyed on the server `id`. NULL = live; non-NULL = tombstoned. // Read paths (today / compliance / lists) filter `deletedAt: null`. deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) /// v1.4.39 W-SERVER-FIX-2 — both the intake route and the dashboard /// summary route project active schedules through `expandTodayIntakes` /// and idempotently `createMany`-backfill missing rows. Without a /// unique constraint the two reads could race a duplicate REMINDER /// row in for the same `(medicationId, scheduledFor)` between the /// existence probe and the insert. `source` is in the key so the /// reminder worker (REMINDER) doesn't collide with manual user /// inserts (WEB / TELEGRAM / IMPORT) on the same slot. /// v1.12.1 — this is a partial live-row unique in the DB (`WHERE /// deleted_at IS NULL`, migration 0121, replacing the 0073 table /// constraint). A slot the user previously deleted (tombstoned) no /// longer blocks a re-take: `applyCanonicalSlotWrite` can re-create the /// row instead of P2002-ing against the tombstone. Schema keeps the /// full `@@unique` for the `userId_medicationId_scheduledFor_source` /// compound where-key. @@unique([userId, medicationId, scheduledFor, source]) @@index([userId, medicationId, scheduledFor]) // v1.7.0 sync — keyset support for the `/api/sync/changes` intake feed // (`where userId` ordered by `(updatedAt asc, id asc)`). @@index([userId, updatedAt, id]) @@map("medication_intake_events") } /// v1.4.25 W4d — titration history for any medication, optimised for /// GLP-1 (Mounjaro 2.5 → 5 → 7.5 → 10 → 12.5 → 15 mg with min 4-week /// steps per Eli Lilly's PI). Append-only; current dose = latest row /// by effectiveFrom. The Medication.dose string field is the /// convenience pointer for legacy reads; this table is the history of /// record. model MedicationDoseChange { id String @id @default(cuid()) medicationId String @map("medication_id") /// When this dose started. Stored UTC; the UI renders in the user's /// timezone via the existing display helpers. effectiveFrom DateTime @map("effective_from") /// Numeric value — easier titration math (does this user step up by /// 2.5 mg every 4 weeks?). Pair with doseUnit for display. doseValue Float @map("dose_value") /// Free-text unit ("mg", "µg", "IE", etc.). Flexible because GLP-1 /// is "mg" but the column is reused for future categories. doseUnit String @map("dose_unit") /// User-entered titration note ("stepped up after 4 weeks on 5 mg, /// tolerated 7.5 mg with mild nausea day 2"). note String? /// v1.25 — AES-256-GCM ciphertext for `note`. The plaintext column /// stays for read-fallback until the backfill reports zero remaining /// rows; the plaintext drop is DEFERRED to a later release. noteEncrypted Bytes? @map("note_encrypted") createdAt DateTime @default(now()) @map("created_at") medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) @@index([medicationId, effectiveFrom]) @@map("medication_dose_changes") } /// v1.4.25 W4d — append-only inventory ledger. Positive delta when /// stock arrives ("purchased"), negative when consumed ("consumed", /// fired automatically on intake-event creation for meds with /// Medication.dosesPerUnit set), or marked out of usable stock /// ("expired", "damaged"). Current inventory is the running sum. model MedicationInventoryEvent { id String @id @default(cuid()) medicationId String @map("medication_id") delta Int // positive = added, negative = consumed/lost reason String // "purchased" / "consumed" / "expired" / "damaged" occurredAt DateTime @default(now()) @map("occurred_at") medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) @@index([medicationId, occurredAt]) @@map("medication_inventory_events") } /// v1.4.25 W19b — per-pen / per-vial inventory entity. The EMA EPAR /// §6.3 30-day in-use clock lives here as `firstUseAt` + `expiresAt`; /// the state machine (see `src/lib/medications/inventory/state-machine.ts`) /// transitions ACTIVE → IN_USE → EXPIRED | USED_UP based on first-use, /// printed expiry, and dose depletion. /// /// Coexists with `MedicationInventoryEvent`: that ledger is the /// running-sum consumption stream; this model is the entity-level /// view that the UI tile renders ("Pen #2 of 3 — 12 days left"). enum MedicationInventoryState { ACTIVE IN_USE EXPIRED USED_UP @@map("medication_inventory_state") } /// v1.16.10 — what kind of physical container an inventory item is. /// Display-level only (no state-machine behaviour hangs off it); the /// register dialog defaults it from the medication's delivery form /// (INJECTION → PEN, ORAL → BLISTER, else OTHER). enum MedicationContainerType { PEN AMPOULE BLISTER INHALER BOTTLE OTHER @@map("medication_container_type") } model MedicationInventoryItem { id String @id @default(cuid()) userId String @map("user_id") medicationId String @map("medication_id") state MedicationInventoryState @default(ACTIVE) /// v1.16.10 — container kind (pen / ampoule / blister pack / …). /// Backfilled PEN for items on INJECTION medications, OTHER otherwise. containerType MedicationContainerType @default(OTHER) @map("container_type") /// Units the container shipped with (v1.16.10 — renamed from /// `dosesTotal`; the DB column stays `doses_total`). A unit is one /// tablet / ampoule / puff; `Medication.unitsPerDose` maps units to /// doses. Mounjaro KwikPen = 4, Trulicity single-dose = 1. /// v1.16.12 (#316) — Decimal so a split-pill consumption can leave a /// fractional remainder (30 tablets dosed at ½ each reads 29.5 after /// one dose). Existing integer rows cast losslessly. unitsTotal Decimal @map("doses_total") @db.Decimal(12, 4) /// Remaining units; decremented by the intake consumption hook /// (v1.16.10 — renamed from `dosesRemaining`; column stays /// `doses_remaining`). v1.16.12 (#316) — Decimal for fractional dosing. unitsRemaining Decimal @map("doses_remaining") @db.Decimal(12, 4) /// NULL until the pen is first opened. Sets the 30-day in-use /// clock per EMA EPAR §6.3. firstUseAt DateTime? @map("first_use_at") /// Persisted = MIN(firstUseAt + 30 days, printedExpiry). Persisting /// rather than deriving keeps the daily "expire-stale" cron a /// single indexed scan. expiresAt DateTime? @map("expires_at") /// The carton-printed expiry date the user enters at creation. printedExpiry DateTime? @map("printed_expiry") purchasedAt DateTime? @map("purchased_at") /// Free-text note, application-layer max 200 chars. notes String? /// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column /// stays for read-fallback until the backfill reports zero remaining /// rows; the plaintext drop is DEFERRED to a later release. notesEncrypted Bytes? @map("notes_encrypted") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) @@index([userId, medicationId, state]) @@index([userId, expiresAt]) @@map("medication_inventory_items") } /// v1.4.25 W19d — structured GLP-1 side-effect log. /// /// Categorical 21-entry × 5-category taxonomy derived from EMA EPAR /// §4.8 ("Undesirable effects") clustered into the five surfaces the /// HealthLog UI uses (GI / METABOLIC / INJECTION_SITE / COGNITIVE / /// GLP1_SPECIFIC). Severity is a 1-5 Likert — chosen over the EMA /// 3-level (mild / moderate / severe) convention to give the user /// finer-grained signal for trend charting; the UI maps each integer /// to a translated semantic label (mild → moderate → significant → /// severe → very-severe). /// /// Daily capture, not per-shot. Delayed-onset symptoms (nausea on /// day 4 post-tirzepatide is common) are captured at their actual /// onset time, not back-filled onto the dose record. The Coach reads /// these timestamps against MedicationDoseChange + MedicationIntakeEvent /// to surface patterns ("nausea has clustered around day 2-3 post-shot /// for the last 4 weeks") without making a clinical claim. /// /// Surface gating: the medication detail page mounts the logbook only /// for `Medication.treatmentClass = GLP1`. The schema deliberately /// allows non-GLP-1 medications to carry side-effect rows for future /// drug classes (e.g. insulin hypoglycaemia symptoms) without a /// migration; UI gating keeps the v1.4.25 release scope tight. /// /// Indexes: /// - (userId, medicationId, occurredAt DESC) — drug-detail timeline /// read path; the dominant query is "last N entries for this med". /// - (userId, occurredAt DESC) — Coach snapshot aggregation read /// path; correlation across medications is per-user, not per-drug. model MedicationSideEffect { id String @id @default(cuid()) userId String @map("user_id") medicationId String @map("medication_id") occurredAt DateTime @default(now()) @map("occurred_at") category MedicationSideEffectCategory entry MedicationSideEffectEntry /// 1 = mild, 2 = moderate, 3 = significant, 4 = severe, 5 = very-severe. /// Constrained to [1, 5] at the application layer (Zod) and the DB /// (CHECK constraint via migration). Not an enum to keep cardinality /// trivially numeric for chart aggregation. severity Int /// Optional free-text note, hard-capped to 280 chars at the /// application layer. The DB column is unbounded TEXT for /// consistency with sibling "notes" columns on this schema. notes String? /// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column /// stays for read-fallback until the backfill reports zero remaining /// rows; the plaintext drop is DEFERRED to a later release. notesEncrypted Bytes? @map("notes_encrypted") 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, occurredAt]) @@index([userId, occurredAt]) @@map("medication_side_effects") } /// Five rolled-up surface categories. The pure-mapping helper at /// src/lib/medications/side-effects/taxonomy.ts is the single source /// of truth for which `MedicationSideEffectEntry` belongs to which /// `MedicationSideEffectCategory`; the DB stores the category /// denormalised so range / category-filter queries don't need a JOIN. enum MedicationSideEffectCategory { GI METABOLIC INJECTION_SITE COGNITIVE GLP1_SPECIFIC @@map("medication_side_effect_category") } /// 21 EMA-EPAR-§4.8-derived entries grouped by surface category. /// Order in this enum matches the picker UI order; rename / reorder /// is a breaking change for serialised rows. New entries append. enum MedicationSideEffectEntry { // GI (5) NAUSEA VOMITING DIARRHEA CONSTIPATION ABDOMINAL_PAIN // Metabolic (4) HYPOGLYCEMIA_SYMPTOMS DEHYDRATION ANOREXIA ELECTROLYTE_FATIGUE // Injection-site (4) INJECTION_REDNESS INJECTION_SWELLING INJECTION_BRUISING INJECTION_INDURATION // Cognitive (4) BRAIN_FOG DIZZINESS LOW_MOOD LOW_ENERGY // GLP-1 specific (4) EARLY_SATIETY GASTROPARESIS_LIKE DYSGEUSIA GALLBLADDER_DISCOMFORT @@map("medication_side_effect_entry") } 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 /// v1.7.0 — time-of-day this reminder belongs to (HH:mm, user-local). /// Empty string = the single legacy window (pre-v1.7 rows + single-time /// schedules). Part of the dedup key so a multi-time schedule dispatches /// once per time-of-day per phase per day instead of once per day. timeOfDay String @default("") @map("time_of_day") medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) schedule MedicationSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade) @@unique([medicationId, scheduleId, date, phase, timeOfDay], map: "telegram_reminder_messages_med_sched_date_phase_tod_key") @@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") } // v1.19.0 — force-reply correlation for the interactive mood reminder. // // When a user taps "Notiz…" on a mood prompt the bot sends a force_reply // message; the user's free-text reply then arrives as a normal `message` // whose `reply_to_message.message_id` points back at that prompt. This // row maps `(chatId, promptMessageId)` to the mood entry the note should // attach to, so the inbound note handler can resolve "what was this a // reply to" without trusting any client-supplied id. Rows are short-lived // (`expiresAt`) and best-effort deleted once consumed; the deletion sweep // is keyed on `expiresAt` so an unanswered note prompt self-cleans. model TelegramPromptContext { id String @id @default(cuid()) userId String @map("user_id") chatId String @map("chat_id") // The bot's prompt message the user is expected to reply to. promptMsgId Int @map("prompt_msg_id") // What the reply attaches to. "mood_note" carries `refId = moodEntryId`. kind String refId String @map("ref_id") expiresAt DateTime @map("expires_at") createdAt DateTime @default(now()) @map("created_at") @@unique([chatId, promptMsgId]) @@index([expiresAt]) @@map("telegram_prompt_contexts") } // ─── 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) // The MCP OAuth connection this access token belongs to, when it was minted // through the `/api/mcp/oauth` bridge. Null for manually-minted tokens and // native-client tokens. Lets a connection revoke kill every access row it // ever issued (H2), and lets the connector token list exclude the transient // 60-minute access rows so it does not flood (M2). mcpConnectionId String? @map("mcp_connection_id") user User @relation(fields: [userId], references: [id], onDelete: Cascade) mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull) @@index([userId]) @@index([mcpConnectionId]) @@map("api_tokens") } // MCP OAuth connection — the revocable, theft-detectable anchor for a remote // connector's refresh-token chain (Phase 3 security fix, H2). // // The stateless `hlrt_` refresh artifact carries this row's `id` (`cid`) plus // the rotation `jti`; the row is the persistent server state the artifact alone // lacks. `currentJti` is the only refresh `jti` valid for the connection at any // moment — every rotation advances it. A presented refresh whose `jti` is not // `currentJti` is a replay of an already-rotated token (theft signal) and // revokes the connection (reuse-detection family revocation, mirroring // `refresh-token.ts`). `revokedAt` is the kill switch the settings card flips, // so revoking a connection stops every future refresh and (via the // `ApiToken.mcpConnectionId` back-link) kills its outstanding access tokens. model McpOAuthConnection { id String @id @default(cuid()) userId String @map("user_id") clientId String @map("client_id") clientName String @map("client_name") scope String resource String currentJti String @map("current_jti") createdAt DateTime @default(now()) @map("created_at") lastUsedAt DateTime? @map("last_used_at") revokedAt DateTime? @map("revoked_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) accessTokens ApiToken[] @@index([userId]) @@map("mcp_oauth_connections") } // v1.15.20 — registration invite tokens. When the operator has disabled // open registration (`AppSettings.registrationEnabled = false`), a valid, // unexpired invite token still admits a signup. The raw `hlv_` token // is shown exactly once at mint time; only its HMAC-SHA256 hash (keyed by // `API_TOKEN_HMAC_KEY`, the same scheme as `ApiToken.tokenHash`) is // stored. `expiresAt` is mandatory and capped at 30 days by the admin // route. `uses`/`maxUses` make multi-use invites possible; the register // path consumes a use with an atomic guarded increment so a concurrent // signup can never overshoot `maxUses`. `usedBy` tracks the most recent // consumer (informational; SetNull on account deletion so the invite // audit row outlives the account). model InviteToken { id String @id @default(cuid()) tokenHash String @unique @map("token_hash") createdBy String @map("created_by") createdAt DateTime @default(now()) @map("created_at") expiresAt DateTime @map("expires_at") usedAt DateTime? @map("used_at") usedBy String? @map("used_by") maxUses Int @default(1) @map("max_uses") uses Int @default(0) // v1.16.0 — soft revocation. A revoked invite keeps its redemption // history visible in the admin table instead of vanishing; the // consume path refuses it like an expired one. revokedAt DateTime? @map("revoked_at") creator User @relation("InviteCreator", fields: [createdBy], references: [id], onDelete: Cascade) consumer User? @relation("InviteConsumer", fields: [usedBy], references: [id], onDelete: SetNull) redemptions InviteRedemption[] @@index([expiresAt]) @@map("invite_tokens") } // v1.16.0 — one row per signup an invite admitted. `InviteToken.usedBy` // keeps only the LAST consumer (it overwrites on multi-use invites); // this ledger carries the full history so the admin table can show // every redemption with its timestamp. `userId` survives account // deletion as NULL — the row still documents that a use was consumed. model InviteRedemption { id String @id @default(cuid()) inviteId String @map("invite_id") userId String? @map("user_id") redeemedAt DateTime @default(now()) @map("redeemed_at") invite InviteToken @relation(fields: [inviteId], references: [id], onDelete: Cascade) user User? @relation("InviteRedemptions", fields: [userId], references: [id], onDelete: SetNull) @@index([inviteId]) @@map("invite_redemptions") } // ─── Refresh Tokens (native client short-lived access + rotating refresh) ─ model RefreshToken { id String @id @default(cuid()) userId String @map("user_id") tokenHash String @unique @map("token_hash") /// Optional device fingerprint — links the refresh token to a specific /// `Device` row when the caller advertised one (X-Device-Id header). Used /// to revoke all tokens issued for a stolen/lost device. deviceId String? @map("device_id") /// HMAC-SHA-256 (matching `ApiToken.tokenHash` format, keyed by /// `API_TOKEN_HMAC_KEY`) of the access token issued in the same response. /// When the refresh token is rotated we revoke the matching access token /// via `ApiToken.tokenHash`. Null if the access token was issued /// out-of-band. accessTokenHash String? @map("access_token_hash") expiresAt DateTime @map("expires_at") createdAt DateTime @default(now()) @map("created_at") revokedAt DateTime? @map("revoked_at") /// When this token was used (one-time-use). Set during rotation; a second /// use of the same token is a hard fail and triggers reuse-detection. usedAt DateTime? @map("used_at") /// Pointer to the row that replaced this one during rotation. Helps audit /// trail and reuse-detection ("this token was already exchanged into X"). replacedById String? @map("replaced_by_id") userAgent String? @map("user_agent") ipAddress String? @map("ip_address") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) /// Composite index — every reuse-detection / device-scoped lookup filters /// on (userId, deviceId), so a single-column device_id index would never /// be used while costing writes. Multiple live rows per (userId, deviceId) /// are intentional: rotated rows linger in `revokedAt != null` state for /// audit and reuse-detection. @@index([userId, deviceId]) @@index([expiresAt]) @@map("refresh_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") /// v1.4.25 W5d — comma-separated OAuth scope string returned by the /// Withings authorisation flow. Legacy v1.4.24- connections sit on /// `user.metrics` only; v1.4.25 starts requesting /// `user.metrics,user.activity` for new + reconnecting users so the /// activity / sleep endpoints unlock. Null = legacy connection that /// has never re-authed since v1.4.25 — the UI surfaces a banner /// inviting them to reconnect. scope String? @map("scope") /// v1.17.1 — one-shot marker for the sleep-stamp backfill. Pre-fix Withings /// sleep rows were stamped with the segment START; the reader treats /// `measuredAt` as the END, shifting every night one segment-length earlier. /// The boot-time backfill deletes the affected WITHINGS / SLEEP_DURATION rows /// and re-syncs with the corrected END stamp, then sets this so the discovery /// query drops the connection. Null = backfill not yet run. sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_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") } /// v1.4.47 W6 — short-lived ledger of in-flight OAuth state nonces. /// /// The legacy state shape `${user.id}:${random16}` (v1.4.42-) persisted /// the user id in plaintext on both the URL state param and the /// `withings_state` cookie. The cookie is httpOnly + Secure + /// sameSite:lax today so XSS exfiltration is closed, but the user id /// is still recoverable from any request-log entry where the cookie /// isn't redacted and from a network-traffic capture of the redirect. /// The v1.4.43 security audit (L-1) recommended decoupling the two by /// switching the state to a fully-random 16-byte nonce backed by this /// short-lived row — the callback resolves the user via the row's /// `userId` instead of the cookie value. /// /// Lifecycle: /// * `connect` mints a row with `expiresAt = now() + 10 min` and /// sets the cookie to JUST the nonce (no user id). /// * `callback` looks up the row, asserts `expiresAt > now()`, then /// DELETEs it in the same transaction — replay of the same nonce /// fails the second time because the row is gone. /// * A daily cleanup cron sweeps any abandoned rows whose /// `expiresAt` has passed (user closed the Withings tab without /// completing the handshake). model WithingsOAuthState { nonce String @id userId String @map("user_id") createdAt DateTime @default(now()) @map("created_at") expiresAt DateTime @map("expires_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([expiresAt]) @@map("withings_oauth_states") } // ─── WHOOP ───────────────────────────────────────────────── // // v1.11.0 — structural copy of the Withings connection/oauth-state pair. // WHOOP ships solo on copy-of-Withings rails (no provider-adapter // abstraction yet). Tokens are encrypted at app level via `encrypt()` / // `decrypt()` — the column names mirror the Withings `access_token` / // `refresh_token` convention (not `*Encrypted`) so the rotation CLI's // column registry extends the same way. model WhoopConnection { id String @id @default(cuid()) userId String @unique @map("user_id") whoopUserId String @map("whoop_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") /// Space-separated OAuth scope string granted by the WHOOP authorisation /// flow (`offline read:recovery read:sleep read:workout read:cycles /// read:profile read:body_measurement`). `offline` is required to receive /// a refresh token. Null = legacy connection that predates scope tracking. scope String? @map("scope") lastSyncedAt DateTime? @map("last_synced_at") /// v1.16.16 (iOS #17) — per-resource sync cursors. A JSON map /// `{ "recovery": "", "sleep": "", "workout": "", /// "cycle": "" }` of each resource's own last-synced instant. The /// incremental `start` for a resource is `cursor − overlap`; a resource /// advances ONLY its own key on a successful fetch+upsert, so a slow or /// 403-ing collection never drags the cursor forward for its siblings and /// its own late-arriving records stay inside the window. Null / a missing /// key falls back to the shared `lastSyncedAt` (legacy connection). resourceCursors Json? @map("resource_cursors") /// Set once the self-converging history backfill walks every collection to /// completion. Null = backfill still in flight (or never started). backfillCompletedAt DateTime? @map("backfill_completed_at") /// v1.17.1 — one-shot marker for the sleep-timeline backfill. Pre-fix WHOOP /// sleep rows stamped all five stage totals on the one sleep-END instant, so /// the hypnogram stacked them on the night's right edge. The boot-time /// backfill deletes the legacy summary rows and re-syncs the reconstructed /// per-segment timeline, then sets this so the discovery query drops the /// connection. Null = backfill not yet run. sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at") /// Max heart rate from the WHOOP body-measurement profile — a profile /// constant, not a time series, so it lives here rather than as a /// Measurement row. maxHeartRate Int? @map("max_heart_rate") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("whoop_connections") } /// v1.11.0 — short-lived `(nonce → userId)` ledger backing the WHOOP OAuth /// state check, identical in shape + lifecycle to `WithingsOAuthState`. /// /// Lifecycle: /// * `connect` mints a row with `expiresAt = now() + 10 min` and sets the /// state cookie to JUST the random base64url nonce (no user id). /// * `callback` looks up the row, asserts `expiresAt > now()`, then DELETEs /// it in the same transaction — replay of the same nonce fails because /// the row is gone. /// * A daily cleanup cron sweeps abandoned rows whose `expiresAt` passed. model WhoopOAuthState { nonce String @id userId String @map("user_id") createdAt DateTime @default(now()) @map("created_at") expiresAt DateTime @map("expires_at") /// v1.12.2 — optional native custom-scheme (e.g. `dev.healthlog.app`) the /// connect route validated against the strict allowlist. Carried here so it /// survives the OAuth round-trip server-side (never in the URL/cookie); the /// callback reads it off the consumed row to drive its FINAL redirect to /// `://whoop?…` instead of the web settings URL. Null = web redirect. returnScheme String? @map("return_scheme") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([expiresAt]) @@map("whoop_oauth_states") } /// v1.12.2 — one-time, Bearer-mintable WHOOP connect ticket. Lets a purely /// Bearer-authenticated native client (no web-session cookie) start the WHOOP /// OAuth handshake: it mints a ticket via `POST /api/whoop/connect/ticket`, /// then opens `GET /api/whoop/connect?ticket=` in an in-app web /// session. The connect route resolves the user from the unconsumed/unexpired /// ticket IN LIEU of a session cookie. /// /// Security shape (mirrors the Bearer-token storage pattern): /// * The raw ticket is returned to the client exactly once and NEVER stored; /// only its HMAC-SHA256 hash (`tokenHash`, keyed by `API_TOKEN_HMAC_KEY`) /// lands in the table — a DB read cannot recover a usable ticket. /// * Single-use: `consumedAt` is stamped by an atomic conditional update at /// the connect route; a second presentation finds the row already consumed /// and is rejected. /// * Short-lived (~60s `expiresAt`); the daily WHOOP OAuth-state cleanup /// cron also sweeps expired/consumed ticket rows. model WhoopConnectTicket { id String @id @default(cuid()) userId String @map("user_id") /// HMAC-SHA256 of the opaque raw ticket. The raw value never persists. tokenHash String @unique @map("token_hash") createdAt DateTime @default(now()) @map("created_at") expiresAt DateTime @map("expires_at") /// Stamped on first (and only) successful consumption. Null = still usable. consumedAt DateTime? @map("consumed_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([expiresAt]) @@map("whoop_connect_tickets") } /// v1.12.0 — Fitbit/Pixel connection over the Google Health API. Mirrors /// `WhoopConnection` 1:1 per user. Tokens are encrypted at the app level /// before persistence. Unlike WHOOP, Google refresh tokens do not rotate per /// use, so `refreshToken` is overwritten only when a token response carries a /// fresh one. model FitbitConnection { id String @id @default(cuid()) userId String @unique @map("user_id") fitbitUserId String @map("fitbit_user_id") // Google/Fitbit external 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") /// Space-separated OAuth scope string granted by the Google authorisation /// flow. Null = legacy connection that predates scope tracking. scope String? @map("scope") lastSyncedAt DateTime? @map("last_synced_at") /// Set once the self-converging history backfill walks every collection to /// completion. Null = backfill still in flight (or never started). backfillCompletedAt DateTime? @map("backfill_completed_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("fitbit_connections") } /// v1.12.0 — short-lived `(nonce → userId)` ledger backing the Fitbit OAuth /// state check, identical in shape + lifecycle to `WhoopOAuthState`. /// /// v1.20.0 — the classic Fitbit Web API (`api.fitbit.com`) authorises with PKCE /// (Authorization Code + S256), so the connect route mints a `code_verifier` /// alongside the nonce and stashes it here; the callback reads it back to present /// on the token exchange. Nullable + additive: a legacy row (or a non-PKCE path) /// simply carries no verifier. model FitbitOAuthState { nonce String @id userId String @map("user_id") /// PKCE code_verifier carried from connect to callback (v1.20.0). Null when no /// PKCE handshake is in flight (legacy rows). codeVerifier String? @map("code_verifier") createdAt DateTime @default(now()) @map("created_at") expiresAt DateTime @map("expires_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([expiresAt]) @@map("fitbit_oauth_states") } // ─── Mood Entries (moodLog) ─────────────────────────────────── model MoodEntry { id String @id @default(cuid()) userId String @map("user_id") date String // YYYY-MM-DD — anchored to `tz` when not null, else legacy Europe/Berlin mood String // SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG score Int // 1-5 tags String? // JSON array of tag keys /// v1.4.30 — first-class free-text note. Replaces the /// `tags: ["note:"]` workaround the iOS app shipped through /// v0.4.1.1 (per R-E H-5); the prior pattern contaminated the /// tags axis with prose the Coach evidence shelf then read as /// taxonomic labels. Nullable for the additive-only migration /// path; the bulk-backfill endpoint pulls existing `note:...` tag /// rows into this column via the migration script. /// /// LEGACY plaintext — superseded by `noteEncrypted` (v1.23). New writes go to /// the encrypted column and null this; the boot-time backfill migrates the /// remaining rows. Column drop is a follow-up release once the backfill /// reports zero remaining. note String? @db.Text /// v1.23 — free-text mood note, AES-256-GCM at rest (Bytes, matching the /// IllnessDayLog / CycleDayLog / LabResult note convention). Registered in /// ENCRYPTED_COLUMNS + the rotation script. noteEncrypted Bytes? @map("note_encrypted") source String @default("MOODLOG") // WEB, TELEGRAM, DAYLIO // v1.12.1 — stable per-source dedup id. NULL for native / MANUAL // entries (those dedup on the legacy `(userId, date, moodLoggedAt)` // key). When an importer receives a source-stable id (the moodLog // upstream entry id, a Daylio row id) it writes it here and upserts on // `@@unique([userId, source, externalId])`, so a re-import is // idempotent even when `moodLoggedAt` re-rounds / re-zones. Postgres // treats NULLs as distinct in that unique, so MANUAL rows never // collide on it. See migration 0122. externalId String? @map("external_id") moodLoggedAt DateTime @map("mood_logged_at") /// v1.4.25 W7b — per-row IANA timezone that the `date` string is /// anchored to. Nullable for backward compatibility: legacy rows /// (`tz IS NULL`) were written under the Europe/Berlin assumption /// and read-paths interpret them as such. New rows always carry the /// user's `displayTimezone` at write time. tz String? syncedAt DateTime @default(now()) @map("synced_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // v1.7.0 sync — LWW reconciliation counter for the offline-sync feed. // Monotonically bumped on every server-side mutation (update + // soft-delete). Mood is last-writer-wins by syncVersion: the higher // value wins. Default 0 for the additive migration so legacy rows // round-trip unchanged. syncVersion Int @default(0) @map("sync_version") // v1.7.0 sync — soft-delete tombstone. The user-facing DELETE route // flips this (and bumps syncVersion) rather than removing the row, so // the `/api/sync/changes` feed surfaces the deletion as a tombstone // keyed on the server `id` to paired clients offline at delete time. // NULL = live row; non-NULL = tombstoned at the recorded instant. // Every list / detail / analytics / rollup read filters // `deletedAt: null` so tombstoned rows never surface in normal reads. deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) // v1.8.5 — structured-tag links. Additive alongside the legacy flat // `tags` JSON string: an entry can carry both (a free-text tag the user // typed AND a structured tag they picked from the taxonomy). The // aggregates read whichever is present; existing rows keep their flat // `tags` untouched. tagLinks MoodEntryTagLink[] @@unique([userId, date, moodLoggedAt]) // v1.12.1 — NULL-distinct external dedup key (migration 0122). Importers // that carry a source-stable id upsert on this; native/MANUAL entries // (NULL externalId) are distinct here and stay on the legacy key above. @@unique([userId, source, externalId]) @@index([userId, date]) // v1.12.1 (P1-3) — mood-insights reads filter `{ userId, deletedAt: // null, moodLoggedAt: { gte } }` ordered by `moodLoggedAt desc`; this // index turns that into a range scan. See migration 0123. @@index([userId, moodLoggedAt]) // v1.15.13 — leads with `(userId, source)` so the mood management // list's source filter + recency sort is an index range scan. See // migration 0136. @@index([userId, source, moodLoggedAt(sort: Desc)]) // v1.7.0 sync — keyset support for the `/api/sync/changes` mood feed // (`where userId` ordered by `(updatedAt asc, id asc)`). @@index([userId, updatedAt, id]) @@map("mood_entries") } // ─── Mood Tag Taxonomy (v1.8.5 W6-tax) ──────────────────────── // // Structured, icon-bearing tag catalog modelled on the standalone mood // diary's Category → TagItem shape. Global reference data (one shared // catalog for the deployment, not per-user): a self-hoster's account // inherits the seeded categories the same way the metric-type enum is // shared. `MoodEntryTagLink` is the per-entry join. // // Backward-compat: this is purely ADDITIVE. The legacy flat // `MoodEntry.tags` (a JSON string array of free-text keys) keeps working // unchanged — the GLP-1 quick-tag chips, the Telegram/MoodLog sync, and // every existing row stay on the flat axis. The structured taxonomy is a // second, richer capture surface that the tag-frequency / lift breakdown // folds in next to the flat tags (`mood-aggregates.ts`). // // `labelKey` carries an i18n key (resolved client-side against all six // locales) rather than a hard-coded German label, so the seeded catalog // renders localised everywhere. `icon` is a Lucide icon name rendered by // the capture UI + breakdown. model MoodTagCategory { id String @id @default(cuid()) /// Stable machine key (e.g. `feelings`). Unique across the catalog so /// the seed migration can upsert idempotently. key String @unique /// i18n message key for the category label (`mood.tagCategory.`). labelKey String @map("label_key") /// Lucide icon name for the category header. icon String? sortOrder Int @default(0) @map("sort_order") /// Soft-hide a category without deleting its historical links. isActive Boolean @default(true) @map("is_active") /// v1.17.0 — owner of a per-user custom group. NULL = the global seeded /// catalogue (resolves `labelKey` against the locale). Set = a user's own /// group, carrying a `customcat:` key and a free-text label held in /// `labelEncrypted` (mirrors the MoodTag custom-tag precedent). userId String? @map("user_id") /// v1.17.0 — AES-256-GCM ciphertext of a custom group's free-text label /// (user prose, possibly health-adjacent). NULL for seeded rows. labelEncrypted String? @map("label_encrypted") tags MoodTag[] user User? @relation("UserMoodTagCategories", fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) @@map("mood_tag_categories") } model MoodTag { id String @id @default(cuid()) categoryId String @map("category_id") /// Stable machine key (e.g. `happy`). Unique across the catalog. key String @unique /// i18n message key for the tag label (`mood.tag.`). labelKey String @map("label_key") /// Lucide icon name for the tag chip. icon String? sortOrder Int @default(0) @map("sort_order") isActive Boolean @default(true) @map("is_active") /// v1.12.0 — capture-kind discriminator. `BINARY` (default) is a /// present/absent flag — every tag seeded by 0101 / 0118. `RATED` is a /// mood *factor* the user scores per entry (e.g. work, social, sleep /// quality); the score rides on `MoodEntryTagLink.rating`. Keeps one /// taxonomy + one ingestion path for both flavours. kind String @default("BINARY") @map("kind") // "BINARY" | "RATED" /// v1.12.0 — inclusive lower / upper bound of a RATED factor's scale /// (e.g. 1..5, or 1..2 for a Yes/No factor). Ignored for BINARY tags. /// The server validates each submitted rating against this range. scaleMin Int @default(1) @map("scale_min") scaleMax Int @default(5) @map("scale_max") /// v1.12.0 — inverse-scaled factor (e.g. stress / conflict) where a /// HIGHER rating means a WORSE day. Capture stays literal; the (later) /// analytics layer flips the sign so "up" always reads as "better". inverse Boolean @default(false) @map("inverse") /// v1.13.0 — owner of a per-user custom tag. NULL = the global catalogue /// (every row seeded by migration; resolves `labelKey` against the locale). /// Set = a user's own custom tag, carrying a `custom:` key and a /// free-text label held in `labelEncrypted`. userId String? @map("user_id") /// v1.13.0 — AES-256-GCM ciphertext of a custom tag's free-text label /// (user prose, possibly health-adjacent — e.g. "Migräne", "Therapie"). /// NULL for catalogue rows. Decrypted server-side for the effective read. labelEncrypted String? @map("label_encrypted") category MoodTagCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade) user User? @relation("UserMoodTags", fields: [userId], references: [id], onDelete: Cascade) links MoodEntryTagLink[] hiddenBy MoodTagHidden[] @@index([categoryId, sortOrder]) @@index([userId]) @@map("mood_tags") } /// v1.13.0 — per-user hide of a CATALOGUE tag. Present = hidden from that /// user's effective picker; absent = visible. A custom tag is hidden by /// flipping its own `MoodTag.isActive` instead, never via this table. model MoodTagHidden { userId String @map("user_id") moodTagId String @map("mood_tag_id") createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) moodTag MoodTag @relation(fields: [moodTagId], references: [id], onDelete: Cascade) @@id([userId, moodTagId]) @@index([moodTagId]) @@map("mood_tag_hidden") } // Join: which structured tags a mood entry carries. Composite PK keeps // the pairing unique; cascade on both sides so deleting an entry (or a // catalog tag) cleans up its links. Distinct from the flat `tags` JSON // column on `MoodEntry`. model MoodEntryTagLink { moodEntryId String @map("mood_entry_id") moodTagId String @map("mood_tag_id") createdAt DateTime @default(now()) @map("created_at") /// v1.12.0 — per-entry score for a RATED `MoodTag`. NULL for BINARY /// links (the present/absent flag carries the signal). Bounded to the /// tag's `scaleMin..scaleMax` at ingest (server-validated, not a DB /// CHECK, so a future scale change never strands historical rows). rating Int? @map("rating") moodEntry MoodEntry @relation(fields: [moodEntryId], references: [id], onDelete: Cascade) moodTag MoodTag @relation(fields: [moodTagId], references: [id], onDelete: Cascade) @@id([moodEntryId, moodTagId]) @@index([moodTagId]) @@map("mood_entry_tag_links") } // ─── Mood Reminder Dispatch Ledger (v0.5.4 ios-coord) ───── // // Idempotency guard for the daily 22:00 MOOD_REMINDER push. The // reminder cron fires every 15 min — when it lands inside the 22:00 // window the handler looks up `(userId, date)` here and bails if a row // already exists, so the user sees exactly one nudge per local day. // `date` is a YYYY-MM-DD string anchored to the user's timezone at // dispatch time (same encoding convention as MoodEntry.date). model MoodReminderDispatch { id String @id @default(cuid()) userId String @map("user_id") date String // YYYY-MM-DD anchored to user.timezone dispatchedAt DateTime @default(now()) @map("dispatched_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, date]) @@index([userId, dispatchedAt]) @@map("mood_reminder_dispatches") } // ─── Mood Entry Rollup (v1.4.39 W-MOOD) ─────────────────────── // // Second cache tier behind the 60-s LRU on `/api/mood/analytics`. // One row per `(userId, granularity, bucketStart)` carrying the // `count` + `mean` + min/max + population SD over the bucket window. // `RollupGranularity` from the measurement rollup tier is reused so // the worker / future cross-granularity readers share a single enum. // // Write path: every MoodEntry create/update/delete fires // `recomputeMoodBucketsForEntry` which upserts the DAY bucket for // the entry's `(userId, dayKey)` inline. WEEK / MONTH / YEAR are // enqueued through pg-boss for the worker to fold so the write path // stays sub-ms. // // Read path: `/api/mood/analytics` reads `prisma.moodEntryRollup. // findMany({ where: { userId, granularity: 'DAY' } })` and runs the // existing summarize() logic against the rollup instead of pulling // every MoodEntry into Node. // // Stats semantics (population, not sample): `sd = STDDEV_POP(score)` // over the bucket window so two-tier cached / live reads agree // byte-for-byte the same way the MeasurementRollup tier does. model MoodEntryRollup { userId String @map("user_id") granularity RollupGranularity bucketStart DateTime @map("bucket_start") @db.Timestamptz(3) count Int mean Float minScore Int @map("min_score") maxScore Int @map("max_score") sd Float? computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@id([userId, granularity, bucketStart]) @@index([userId, granularity, bucketStart(sort: Desc)]) @@map("mood_entry_rollups") } // ─── Medication Compliance Rollup (v1.4.39 W-MED) ───────────── // // Per-(userId, medicationId, day) compliance ledger. Replaces the // unbounded MedicationIntakeEvent findMany on // `/api/medications/intake?scope=compliance` and the equivalent // fan-out inside `computeUserHealthScoreFastPath`. // // Day-key is anchored to the user's IANA timezone (TEXT, encoded as // `YYYY-MM-DD`) so the read path can collapse a date range into a // straight WHERE clause without re-deriving the boundary per row. // // Write path: every MedicationIntakeEvent create / update / delete // (and the scheduler that mints fresh `scheduledFor` rows) fires // `recomputeMedicationComplianceForDay` which upserts the // `(user, medication, day)` row. Worker handles weekly / monthly // re-fold off the hot path. model MedicationComplianceRollup { userId String @map("user_id") medicationId String @map("medication_id") day String // YYYY-MM-DD anchored to user.timezone scheduled Int taken Int skipped Int computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3) user User @relation(fields: [userId], references: [id], onDelete: Cascade) medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) @@id([userId, medicationId, day]) @@index([userId, day(sort: Desc)]) @@map("medication_compliance_rollups") } // ─── Integration Status (v1.4.15) ───────────────────────── // // Per-(user, integration) row that captures the most recent sync attempt // outcome so the Settings → Integrations UI can show: // - state ("connected", "error_reauth", "error_transient", "disconnected") // - last successful sync timestamp // - last attempted sync timestamp // - last error message (encrypted at rest if non-null — see crypto.ts) // - consecutive failure counter (resets on success) // // `recordSyncSuccess` / `recordSyncFailure` helpers in // `src/lib/integrations/status.ts` are the only writers. The per-kind // `consecutiveFailuresByKind` JSON bucket feeds the admin-Telegram // alerting threshold (default 3) via `Math.max(...Object.values(...))`. // State `error_reauth` parks scheduled syncs until the user reconnects // (the sync entry-points consult this row before doing any external // work). // // `integration` values are bare strings ("withings", "moodlog") rather // than an enum so future integrations don't require a migration. model IntegrationStatus { id String @id @default(cuid()) userId String @map("user_id") integration String // "withings" | "moodlog" state String @default("connected") // "connected" | "error_reauth" | "error_transient" | "disconnected" | "parked" lastSuccessAt DateTime? @map("last_success_at") lastAttemptAt DateTime? @map("last_attempt_at") lastError String? @map("last_error") // AES-256-GCM encrypted (may contain token-fragment metadata) // v1.4.47 W1 — legacy single-column `consecutiveFailures` integer was // dropped (migration 0077). The per-kind bucket below is now the // sole source of truth for the alert ladder. consecutiveFailuresByKind Json? @map("consecutive_failures_by_kind") // v1.4.43 W14 — { transient, reauth_required, persistent } persistentFailureStartedAt DateTime? @map("persistent_failure_started_at") // v1.4.43 W14 — start of the current persistent streak (drives >24h park) alertedAt DateTime? @map("alerted_at") // last time admin Telegram alert fired (idempotency guard) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, integration]) @@index([userId]) @@index([state]) @@map("integration_statuses") } // ─── App Settings ───────────────────────────────────────── model AppSettings { id String @id @default("singleton") registrationEnabled Boolean @default(true) @map("registration_enabled") // v1.23 — instance-wide "require a second factor" policy. When true, every // account without an active second factor (no confirmed TOTP and no // registered security key) is sent through a forced-enrollment interstitial // after sign-in until it enrolls one. A per-user override lives on // `User.mfaEnforced`; the effective requirement is the OR of the two. The // operator/CLI escape hatch (`scripts/disable-mfa.ts`, toggling this off) // keeps the policy from locking anyone out irrecoverably. mfaRequired Boolean @default(false) @map("mfa_required") 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") // 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") // v1.4.16 phase B5e — daily-aggregated rec-feedback summary written // by `feedback-aggregator.ts`. Shape: // { // "generatedAt": "2026-05-09T04:00:00.000Z", // "windowDays": 30, // "buckets": [ // { "severity": "important", "metricSourceType": "bloodPressure", // "providerType": "codex", "promptVersion": "4.16.0", // "helpful": 12, "notHelpful": 3, "total": 15, "helpfulRate": 0.8 } // ] // } // Null = aggregator hasn't run yet OR there's no feedback in the window. adminAiInsightsFeedbackSummary Json? @map("admin_ai_insights_feedback_summary") // v1.4.25 W7 — server-wide default timezone for new accounts that // don't carry a browser-detected zone in the registration payload. // The resolver in `src/lib/tz/resolver.ts` falls back to // "Europe/Berlin" when this column is NULL, so existing instances // keep behaving identically to v1.4.24 without an admin touching // anything. Validated against `Intl.supportedValuesOf('timeZone')` // at the admin write path. Length cap matches IANA's longest // canonical zone identifier ("America/Argentina/ComodRivadavia" at // 32 chars) plus headroom. defaultUserTimezone String? @map("default_user_timezone") @db.VarChar(64) // v1.4.31 — Assistant-surface operator feature flags. The master // gates every LLM-driven surface app-wide; five sub-flags carve // the visibility cut per surface (Coach drawer + chat, Daily // Briefing + advisor, per-metric status cards, correlation // narration, Health-Score delta explainer). Defaults preserve the // v1.4.30 behaviour (every surface visible) so upgrades require // no admin action. The runtime resolver in // `src/lib/feature-flags/index.ts` forces every sub-flag false // when the master is off. Locked per // `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5: the same // flag matrix gates server-routed AND iOS on-device surfaces, so // operators can opt out of every assistant surface regardless of // where the model runs. assistantEnabled Boolean @default(true) @map("assistant_enabled") assistantCoachEnabled Boolean @default(true) @map("assistant_coach_enabled") assistantBriefingEnabled Boolean @default(true) @map("assistant_briefing_enabled") assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled") assistantCorrelationsEnabled Boolean @default(true) @map("assistant_correlations_enabled") assistantHealthScoreExplainerEnabled Boolean @default(true) @map("assistant_health_score_explainer_enabled") // v1.18.0 — operator-level server-wide module availability. A DISABLED // allowlist mirroring the per-user `User.modulePreferencesJson`: absent / // empty / `true` ⇒ available, an explicit `false` ⇒ disabled for EVERY // account regardless of personal preference. This is the second layer of // the two-layer module model (operator AND user), exactly like the coach // master flag (`assistant_coach_enabled`) sits above `User.disableCoach`. // NULL = every module available (no admin action needed on upgrade). Core // domains (weight, BP, pulse, medications) are never written here and the // admin write endpoint refuses them, so the measurement engine + meds can // never be disabled. Resolved in `src/lib/modules/gate.ts`. moduleAvailabilityJson Json? @map("module_availability_json") @@map("app_settings") } // ─── 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 // v1.4.27 B3 — resolved at audit-creation time from the bundled // GeoLite2-ASN MMDB. `asn` is the autonomous-system number, // `carrier` is the organisation string (e.g. "Deutsche Telekom AG"). // Both nullable so older rows, private-IP rows, and offline-miss // rows stay valid. asn Int? // autonomous-system number carrier String? // autonomous-system organisation createdAt DateTime @default(now()) @map("created_at") user User? @relation(fields: [userId], references: [id], onDelete: SetNull) @@index([userId, createdAt]) @@index([action, createdAt]) // v1.12.1 (P1-2) — status-cache reads filter `{ userId, action }` // ordered by `createdAt desc` on every insight-status card mount; // this three-column index serves both the findFirst and the take:5 as // an index range scan over the highest-churn table. See migration 0123. @@index([userId, action, createdAt(sort: Desc)]) @@map("audit_logs") } // ─── Notification System ────────────────────────────────────── model NotificationChannel { id String @id @default(cuid()) userId String @map("user_id") type String // "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS" 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") // ── v1.4.15 Phase B3: reliability fields ───────────────────── // Reason the channel was auto-disabled (e.g. "hard_reject_410", // "telegram_blocked_by_user", "telegram_chat_not_found", // "give_up_after_5_failures"). NULL when the channel is healthy // OR was disabled manually (in which case `enabled=false` alone // is enough — no automated process should treat that as // re-enable-able). disabledReason String? @map("disabled_reason") consecutiveFailures Int @default(0) @map("consecutive_failures") lastSuccessAt DateTime? @map("last_success_at") lastFailureAt DateTime? @map("last_failure_at") lastFailureReason String? @map("last_failure_reason") // The next time the dispatcher is allowed to attempt a send for // this channel after a transient failure. NULL = no cooldown. // Hard rejects flip `enabled=false` instead of writing this. nextRetryAt DateTime? @map("next_retry_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") } // ─── Apple Health export.zip import (v1.4.34) ─────────────── // // Mirrors a `pg-boss` `apple-health-import` job's lifecycle so the // polling endpoint can read live per-phase progress without // round-tripping through pg-boss internals (`output` only populates // on completion in v12). The `pgBossJobId` link lets the worker // reconcile a row by the boss id it just received from `boss.send`. // // Status state machine: // queued → unpacking → parsing → upserting → done // ↘ failed // // `progress` carries the live snapshot the worker writes every // 1 000 records (see `ImportJobProgress` in // `src/lib/measurements/import-apple-health-export.ts`). `result` // carries the terminal `ImportJobResult` envelope. model ImportJob { id String @id @default(cuid()) userId String @map("user_id") /// Admin who triggered the import on behalf of `userId` (admin /// variant only). NULL when the user kicked off their own import. triggeredByAdminId String? @map("triggered_by_admin_id") /// `pg-boss` job id assigned by `boss.send`. Allows the worker to /// look up its mirror row by the boss id rather than passing the /// HealthLog id through the payload. pgBossJobId String? @unique @map("pg_boss_job_id") /// `queued | unpacking | parsing | upserting | done | failed`. status String @default("queued") /// Free-text reason on `failed`; null otherwise. failureReason String? @map("failure_reason") /// Bytes uploaded — surfaced to the operator audit trail. uploadBytes Int @map("upload_bytes") /// SHA-256 hex digest of the uploaded `export.zip`. Lets the /// kick-off endpoint short-circuit a re-upload of the exact same /// file back to the existing job id without re-queueing. uploadSha256 String? @map("upload_sha256") /// Apple's `ExportDate` attribute string-formatted, parsed during /// the unpack phase. NULL until the parser sees it. exportedAt DateTime? @map("exported_at") startedAt DateTime @default(now()) @map("started_at") completedAt DateTime? @map("completed_at") /// JSON envelope mirroring `ImportJobProgress` (`src/lib/measurements/ /// import-apple-health-export.ts`). Updated mid-run. progress Json @default("{}") /// JSON envelope mirroring `ImportJobResult`. NULL until terminal /// state. Carries per-type counts, workout counts, clinical / /// deferred / unknown buckets. result Json? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, startedAt]) @@index([status]) @@map("import_jobs") } // ─── Rate Limiting ────────────────────────────────────────── model RateLimit { key String @id count Int @default(1) resetAt DateTime @map("reset_at") @@index([resetAt]) @@map("rate_limits") } /// v1.11.0 W1 — durable, multi-instance provider-health ledger. /// /// Promotes the volatile per-worker `lastWorkingCache` /// (`provider-runner.ts`) to Postgres, modelled on the atomic-upsert /// rate-limiter pattern so every worker shares one signal. One row per /// (user, providerType): the runner records each outcome and reads the /// row to (a) deprioritise / skip a provider inside its backoff window /// instead of re-burning a dead round-trip every call, and (b) hold a /// durable negative-cache for an auth-class failure (401/403 = /// credential dead) far longer than the old 1h in-memory TTL, surfacing /// it rather than silently retrying. /// /// `nextRetryAt` is the backoff gate: while `lastResult = "auth_failed"` /// and NOW() < `nextRetryAt`, the provider is skipped (negative cache). /// On a success the row clears to `ok` with a null `nextRetryAt`. /// Server-internal: never a Measurement, never ingested from a client. model ProviderHealth { id String @id @default(cuid()) userId String @map("user_id") providerType String @map("provider_type") // ProviderChainType tag lastResult String @map("last_result") // "ok" | "hard_failed" | "auth_failed" lastStatus Int? @map("last_status") // HTTP status of the last failure; null on ok/network consecutiveFailures Int @default(0) @map("consecutive_failures") lastOkAt DateTime? @map("last_ok_at") lastFailureAt DateTime? @map("last_failure_at") nextRetryAt DateTime? @map("next_retry_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, providerType]) @@index([userId, lastResult]) @@map("provider_health") } // ─── 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 is rejected with /// 409 by `POST /api/devices` (cross-user-hijack guard). /// /// `apnsToken` and `apnsEnvironment` are nullable because today's iOS /// builds register through a generic `token` first; a follow-up call /// from the iOS app populates the APNs-specific pair once the device /// receives its first push token from `application:didRegisterForRemoteNotificationsWithDeviceToken:`. /// Loose-typed `apnsEnvironment` (sandbox|production) leaves headroom /// for whatever new gateway labels Apple ships next without a forced /// migration. 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? apnsToken String? @map("apns_token") apnsEnvironment String? @map("apns_environment") // "sandbox" | "production" /// v1.7.0 — per-device medication-delivery override. NULL = inherit the /// user-level roaming default /// (`User.notificationPrefs.medication.deliveryDefault`). "server" /// forces server APNs for this device; "client" forces local. Stored + /// echoed in v1.7.0; cron suppression stays user-level (APNs fans out to /// all the user's devices and iOS dedupes locally). medicationDelivery String? @map("medication_delivery") /// v1.17.1 (#22) — APNs push token for the iOS Live Activity update/end /// channel. ActivityKit issues a per-Activity push token distinct from the /// device APNs token; the server stores the most recent one so the worker /// can push a Live Activity content-state update or an end signal. NULL when /// the device has no active Live Activity. The iOS client owns the /// ActivityKit lifecycle and registers/clears this token; no server-side /// behaviour hangs off it beyond addressing the update push. liveActivityPushToken String? @map("live_activity_push_token") 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]) // `devices_apns_token_unique` is a partial UNIQUE index over // `apns_token IS NOT NULL` — see migration 0041. Prisma can't express // a partial index in the schema language, so it's introduced via raw // SQL and intentionally omitted here. Do not re-add `@@index([apnsToken])` // (the non-unique sibling) — the partial unique index supersedes it. @@map("devices") } // ─── Measurement / Vorsorge reminders ──────────────────────── /// v1.17.1 — preventive-care ("Vorsorge") reminders: "measure BP every 7 /// days", "annual blood panel", "log weight weekly". Reuses the existing /// medication-reminder cadence vocabulary (`intervalDays` rolling cadence OR /// an RFC-5545 `rrule`) so the recurrence engine drives the due-date math, and /// rides the existing notification dispatcher + `push_attempts` ledger. The /// completion target is either a `measurementType` (auto-resolved when a /// matching measurement lands) or a free-text `label` for non-measurement /// Vorsorge that only resolves on a manual check-off. `nextDueAt` is /// server-computed + stored (server-authoritative — iOS consumes the resolved /// value, never recomputes). Soft-deleted. model MeasurementReminder { id String @id @default(cuid()) userId String @map("user_id") /// User-facing copy: "Blutdruck messen", "Großes Blutbild". label String /// Auto-resolve target. NULL = free-text / checklist Vorsorge (a blood /// panel, a dermatology check) with no measurement to match against; those /// resolve only on a manual satisfy. Non-NULL = the cron advances /// `lastSatisfiedAt` when a matching Measurement of this type lands. measurementType MeasurementType? @map("measurement_type") /// Rolling cadence: fire every N days (7 / 30 / 365). Maps to the recurrence /// engine's `rollingIntervalDays`. Mutually exclusive with `rrule` in /// practice; both NULL is a one-shot reminder anchored on `anchorDate`. intervalDays Int? @map("interval_days") /// Optional RFC-5545 recurrence (e.g. `FREQ=YEARLY`). Maps straight through /// the same recurrence engine the medication schedules use. rrule String? /// First-due anchor; the resolver falls back to `lastSatisfiedAt ?? createdAt` /// when NULL. anchorDate DateTime? @map("anchor_date") /// Optional course-window end. NULL = open-ended cadence (the existing /// behaviour). Non-NULL bounds a finite cadence: the recurrence engine /// (`medication.endsOn` precedent) stops producing occurrences past this /// instant, so a Coach-suggested "measure BP morning+evening for a week" /// self-expires. Additive; every existing reminder keeps `NULL` and stays /// open-ended. endsOn DateTime? @map("ends_on") /// Who produced this reminder. `VORSORGE` = the user created it through the /// Vorsorge surface (the existing, default behaviour). `COACH` = the AI /// coach minted it from an evidence-based cadence suggestion, posted to the /// SAME `POST /api/measurement-reminders` endpoint — one engine, not two. /// Lets the UI label provenance and lets Coach dedupe its own suggestions. origin ReminderOrigin @default(VORSORGE) /// Local hour (0–23) to fire the nudge, mirroring the mood-reminder hour. /// DST-safe windowing is applied at the cron via the existing TZ helpers. notifyHour Int @default(9) @map("notify_hour") /// Free-text "wo": "Hausarzt", "Labor Dr. X". Optional. location String? /// Server-computed canonical next-due instant (server-authoritative). nextDueAt DateTime? @map("next_due_at") /// When the reminder was last fulfilled (a matching measurement or a manual /// satisfy). Re-anchors the next-due computation. lastSatisfiedAt DateTime? @map("last_satisfied_at") /// Master on/off for this reminder. Distinct from soft-delete: a disabled /// reminder is retained + editable but never fires. enabled Boolean @default(true) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, deletedAt]) @@index([userId, nextDueAt]) // v1.18.1 — structural accept-dedup for COACH-minted reminders: at most // one LIVE COACH reminder per (user, metric). Enforced as a PARTIAL unique // index (`WHERE origin = 'COACH' AND deleted_at IS NULL`) created by the // 0172 migration — Prisma's schema DSL cannot express a filtered unique // index, so the migration owns the constraint and this is a pointer to it. @@map("measurement_reminders") } /// Provenance of a MeasurementReminder. `VORSORGE` = user-created through the /// Vorsorge surface (default); `COACH` = minted by the AI coach from a cadence /// suggestion. Both feed the same engine / endpoint / cron. enum ReminderOrigin { VORSORGE COACH @@map("reminder_origin") } // ─── Lab results ───────────────────────────────────────────── /// v1.17.1 — minimal structured lab store pairing with the Vorsorge "annual /// blood panel" reminder. HealthLog has no first-class blood-biomarker type /// beyond glucose, so a self-hoster who gets a panel back had nowhere to record /// LDL / HbA1c / ferritin / TSH. This is the lean storage row: one analyte per /// row, optional panel grouping, optional reference range. The doctor-report / /// charting wiring lands in sibling work — this model only owns the shape. /// Soft-deleted. model LabResult { id String @id @default(cuid()) userId String @map("user_id") /// Optional grouping label, e.g. "Großes Blutbild". NULL = standalone value. panel String? /// The biomarker name, e.g. "LDL", "HbA1c", "Ferritin". analyte String /// Numeric reading. NULL for a qualitative result (see `valueText`). Exactly /// one of `value` / `valueText` is set per row: `value IS NOT NULL` marks a /// quantitative reading, `valueText IS NOT NULL` a qualitative one. value Float? /// v1.18.9 — qualitative result as free text, e.g. "negativ" / "positiv" / /// "grenzwertig" / "nicht nachweisbar". NULL for a numeric reading. The /// reference bounds + range verdict are meaningless for a qualitative row. valueText String? @map("value_text") unit String /// Optional reference-range bounds as reported by the lab. referenceLow Float? @map("reference_low") referenceHigh Float? @map("reference_high") /// When the sample was taken (UTC instant; wall-clock anchoring is the /// reader's concern per the schema's UTC-only convention). takenAt DateTime @map("taken_at") /// v1.19.0 — optional link to the user-scoped Biomarker catalog. NULL on /// every existing row; a backfill job grouping by `lower(analyte)` will /// populate it later (this migration only adds the column + relation). Once /// linked, server reads resolve the canonical unit + reference bounds from /// the Biomarker, ending the per-row free-text divergence ("LDL" vs "ldl" /// vs "LDL-C", `mg/dL` vs `mg/dl`). The legacy `analyte` / `unit` / /// `reference*` columns stay as historical truth for one release. biomarkerId String? @map("biomarker_id") /// Provenance: "MANUAL" today; import paths set their own source string. source String @default("MANUAL") /// Optional free-text note, AES-256-GCM encrypted at rest via /// `src/lib/crypto.ts` (Bytes column, `*Encrypted` suffix per convention). noteEncrypted Bytes? @map("note_encrypted") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull) @@index([userId, analyte, takenAt]) @@index([userId, deletedAt]) @@index([biomarkerId]) @@map("lab_results") } // ─── Biomarker catalog ─────────────────────────────────────── /// v1.19.0 — user-scoped catalog of blood/lab biomarkers. Defining a marker /// once (canonical name, unit, reference bounds, optional context note) and /// recording LabResult values against it ends the per-row free-text /// divergence the lean v1.17.1 LabResult model invites (re-typing analyte / /// unit / both bounds on every reading). The reference range + unit are /// authoritative on the catalog row, not on the entry; server reads resolve /// from here and the resolved values flow into the DTO (iOS renders, never /// recomputes). Unique per user + name so there is never two "LDL" /// definitions. model Biomarker { id String @id @default(cuid()) userId String @map("user_id") /// Canonical display name, e.g. "LDL Cholesterol". name String /// Canonical unit for every reading against this marker, e.g. "mg/dL". unit String /// Optional reference-range bounds. The lab/sex/age-dependent "normal" /// window; the server's range verdict is computed against these. lowerBound Float? @map("lower_bound") upperBound Float? @map("upper_bound") /// Optional per-marker context ("what this means"), AES-256-GCM encrypted /// at rest via `src/lib/crypto.ts` (Bytes column, `*Encrypted` suffix per /// convention). contextEncrypted Bytes? @map("context_encrypted") /// Optional panel grouping moved off the per-entry row, e.g. /// "Großes Blutbild". NULL = standalone marker. panel String? /// A marker the user no longer needs: dropped from the active catalog list /// and from the lab-entry pickers, but never deleted — its readings and /// canonical unit/range definition survive. hidden Boolean @default(false) @map("hidden") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) results LabResult[] @@unique([userId, name]) @@index([userId]) @@map("biomarkers") } // ─── Custom metrics ────────────────────────────────────────── /// v1.25.5 — user-defined custom metrics. A self-hoster defines an arbitrary /// measurement (name, unit, optional target window, decimals, description), /// logs values against it, and charts them. Deliberately a SEPARATE generic /// store from the closed `MeasurementType` enum + `Measurement` table: custom /// metrics are NOT synced (Withings / Apple Health), NOT in FHIR, NOT in AI /// insights / correlations / health-score / dashboard tiles — log + chart only. /// `name` / `unit` / `description` are PLAINTEXT (they may hold personal text /// but, like the Biomarker catalog, stay clear-text so the list can sort + /// search). Unique per user + name so there is never a second definition. /// Soft-deleted. model CustomMetric { id String @id @default(cuid()) userId String @map("user_id") /// Free-text display name, e.g. "Morning grip strength". name String /// Free-text unit label, e.g. "kg", "reps", "score". unit String /// Optional target window. Both independently optional; when both present /// the low bound must not exceed the high bound (charted as a reference band). targetLow Float? @map("target_low") targetHigh Float? @map("target_high") /// Optional fixed decimal places for display (null = sensible default). decimals Int? /// Optional free-text description ("what this measures"). Plaintext. description String? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) entries CustomMetricEntry[] @@unique([userId, name]) @@index([userId]) @@map("custom_metrics") } /// v1.25.5 — a single logged value against a `CustomMetric`. The `unit` is a /// snapshot of the metric's unit at write time (historical truth, mirroring the /// LabResult per-row unit). Charts read these LIVE (no rollup tier). model CustomMetricEntry { id String @id @default(cuid()) userId String @map("user_id") customMetricId String @map("custom_metric_id") value Float /// Unit snapshot from the metric at write time. unit String measuredAt DateTime @map("measured_at") note String? createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) metric CustomMetric @relation(fields: [customMetricId], references: [id], onDelete: Cascade) @@index([userId, customMetricId, measuredAt]) @@map("custom_metric_entries") } // ─── 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") } // ─── Host Metrics (admin system-status host-load chart) ────── /// Per-minute snapshot of host-process load + memory captured by the /// pg-boss `host-metric-sample` job. Read by `/api/admin/host-metrics` /// to render the host-load trend above the system-status section. /// 7-day retention is enforced inside the sampler — we keep the table /// small enough that a single `findMany` over the last 2 hours stays /// well under 200 rows. model HostMetric { id String @id @default(cuid()) capturedAt DateTime @default(now()) @map("captured_at") loadAvg1 Float @map("load_avg_1") loadAvg5 Float @map("load_avg_5") loadAvg15 Float @map("load_avg_15") memUsedBytes BigInt @map("mem_used_bytes") memTotalBytes BigInt @map("mem_total_bytes") // Optional rolling-window byte counters from /proc/diskstats. Null // outside Linux (macOS dev machines, Windows). The API endpoint // converts deltas into bytes-per-second client-side. diskReadBytes BigInt? @map("disk_read_bytes") diskWriteBytes BigInt? @map("disk_write_bytes") @@index([capturedAt]) @@map("host_metrics") } // ─── Recommendation Feedback (v1.4.16 phase B5e) ─────────────── // // Per-recommendation thumbs-up / thumbs-down rating. Captured client- // side under each rec card; persisted with provider attribution // (providerType + promptVersion) so the daily aggregator can slice // helpful-rate per (severity x provider x prompt) for the admin AI // quality dashboard. // // Default-on for the single-user case (only the user's ratings shape // the user's future prompts); cross-user aggregate stays admin-only // (no prompt-tuning flow yet — that ratchet is v1.4.17). // // Privacy: server-side fills providerType + promptVersion from the // user's most recent cached payload (User.insightsCachedText) so the // client can't tamper with the attribution. // // `(userId, recommendationId, recommendationText)` is the dedup key. // recommendationText snapshots the rec at submission time so a re- // generation that rewrites the same id with new text counts as a // different row — that's intentional, the aggregator wants per-text // signal not per-id signal. model RecommendationFeedback { id String @id @default(cuid()) userId String @map("user_id") recommendationId String @map("recommendation_id") // Nullable since v1.4.23 reconcile (Sr-H3): the recommendation // surface still snapshots the prose here (recommendations are // unencrypted on the wire, so plaintext is the source-of-truth); // the Coach surface leaves it NULL and references the message via // `coachMessageId` so the assistant prose stays encrypted-at-rest // in `coach_messages.encryptedContent`. recommendationText String? @map("recommendation_text") recommendationSeverity String @map("recommendation_severity") metricSourceType String @map("metric_source_type") metricSourceTimeRange String @map("metric_source_time_range") helpful Boolean providerType String @map("provider_type") promptVersion String @map("prompt_version") createdAt DateTime @default(now()) @map("created_at") // v1.4.23 H7 — polymorphic target so Coach replies share the table. // "recommendation" (default) is the v1.4.16 B5e shape; "coach" is a // Coach assistant message. The aggregator slices on targetType to // bucket each surface independently. Default value lets every // pre-v1.4.23 row read as "recommendation" without a backfill. targetType String @default("recommendation") @map("target_type") // Optional free-form reason — populated when the user clicks // "unhelpful" and selects a category from the small chip strip. // Capped at 200 chars upstream so the table can't bloat from a // pasted essay. reason String? @map("reason") // v1.4.23 reconcile (Sr-H3) — Coach surface references the source // message instead of snapshotting its prose into // `recommendationText`. FK + cascade so a deleted message drops the // rating row too. Always NULL for `targetType="recommendation"`. coachMessageId String? @map("coach_message_id") coachMessage CoachMessage? @relation(fields: [coachMessageId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, recommendationId, recommendationText], map: "rec_feedback_user_rec_text_key") @@index([userId, createdAt]) @@index([recommendationSeverity, helpful, createdAt]) @@index([targetType, createdAt]) @@index([coachMessageId]) @@map("recommendation_feedback") } // ─── AI Coach (v1.4.20) ──────────────────────────────────── // // Conversation thread between a user and the AI Coach. Messages are // stored encrypted at rest via AES-256-GCM (`src/lib/crypto.ts`) under // the active `ENCRYPTION_KEYS` entry — the Coach inherits the same // key-rotation tooling as the rest of the app. GDPR cascade through // User keeps the model deletion-clean. // // `title` is a short snippet of the first user message (≤80 chars, // non-encrypted) so the history rail can render without paying the // per-message decrypt cost. The snippet is plain text; do not put PII // the user explicitly opted out of into it. The summarisation helper // in `src/lib/ai/coach/persistence.ts` truncates with an ellipsis. model CoachConversation { id String @id @default(cuid()) userId String @map("user_id") title String createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // v1.11.0 — rolling encrypted summary of the elided older turns, so a long // conversation keeps memory of what came before without re-sending every // turn to the provider. AES-256-GCM (same codec as CoachMessage.encrypted). summaryEncrypted Bytes? @map("summary_encrypted") summaryUpdatedAt DateTime? @map("summary_updated_at") summaryTurnCount Int @default(0) @map("summary_turn_count") user User @relation(fields: [userId], references: [id], onDelete: Cascade) messages CoachMessage[] @@index([userId, updatedAt]) @@map("coach_conversations") } // One message in a Coach conversation. `role` is "user" or "assistant" // — same shape as the OpenAI/Anthropic chat-completion APIs. // // `encryptedContent` holds the AES-256-GCM ciphertext of the message // body. The encrypt/decrypt helpers stamp the active key id into the // ciphertext so rotation works without a backfill. // // `metricSourceJson` is the provenance envelope ("based on 14d HRV + // 6 BP readings") attached to assistant messages — labels only, NEVER // raw values. The frontend renders source-chip rows from this field. // It is intentionally NOT encrypted: the labels are window+metric // names ("last30days", "bloodPressure", "n=12") and carry no PII; the // stable shape lets future analytics queries slice without a key. // // `providerType` and `promptVersion` mirror the recommendation- // feedback attribution columns so the cross-feature aggregator can // slice helpful-rate per (provider × prompt) for Coach replies too. model CoachMessage { id String @id @default(cuid()) conversationId String @map("conversation_id") role String // "user" | "assistant" encryptedContent Bytes @map("encrypted_content") metricSourceJson String? @map("metric_source_json") providerType String? @map("provider_type") promptVersion String? @map("prompt_version") // v1.18.9 — per-turn token usage + model, persisted so the quiet // per-message token footer survives a conversation reload. Both // nullable: user turns, refusals, and pre-feature rows carry NULL. tokensUsed Int? @map("tokens_used") model String? createdAt DateTime @default(now()) @map("created_at") conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) // v1.4.23 reconcile (Sr-H3) — back-relation for the FK that lets // Coach feedback rows reference their source message instead of // snapshotting plaintext prose into `recommendation_feedback`. feedback RecommendationFeedback[] @@index([conversationId, createdAt]) // v1.18.6 (L1) — the FAB nudge-status poll and the cron's persisted-nudge // frequency gate both scan a user's assistant messages newest-first // (optionally filtered to provider_type = 'nudge'). This index lets the // planner do an index-backed per-conversation merge instead of gathering + // sorting every assistant row in the user's threads on each global poll. @@index([conversationId, role, createdAt(sort: Desc)]) @@map("coach_messages") } // v1.11.1 (Epic B, B-W7) — durable, user-scoped personal facts the Coach // extracts from conversations: stable preferences, conditions the user states // about themselves, goals, constraints, durable life context. The fact TEXT is // encrypted at rest (AES-256-GCM, the same codec as CoachMessage). `category`, // `confidence`, and `sourceConversationId` stay plain so the injection picker // can rank without paying a per-row decrypt. Descriptive, never diagnostic. // Soft-delete via `deletedAt` so a user "forget this" action is reversible / // auditable and re-extraction can avoid resurrecting it. model CoachFact { id String @id @default(cuid()) userId String @map("user_id") factEncrypted Bytes @map("fact_encrypted") // App-side closed enum (not a DB enum, to keep the migration purely additive // and avoid an enum-migration per new category): preference | condition | // goal | constraint | context. category String // 0..100 server-assigned extraction confidence. confidence Int @default(50) // Provenance — nullable so a future manual-entry surface can leave it null. sourceConversationId String? @map("source_conversation_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // Soft-delete: hides the fact from injection but keeps the row for audit. deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, deletedAt, confidence]) @@map("coach_facts") } // v1.21.3 (B1) — durable, user-scoped goal / if-then implementation plans the // Coach proposes and the user CONFIRMS. The Coach extractor writes a plan as // status "proposed"; only the user-facing PATCH activates it (status "active") // — there is no silent self-edit of the user's plan. Free-text fields (the // if-cue, the then-action, and the optional target) are encrypted at rest // (AES-256-GCM, the same Bytes codec as CoachFact). `metric`, `status`, the // dates and the source-conversation id stay plain so the injection picker can // rank / filter without paying a per-row decrypt. Soft-delete via `deletedAt` // so an abandoned plan is hidden from injection but kept for audit. // // `status` is an APP-SIDE closed enum (NOT a DB enum, to keep the migration // purely additive): proposed | active | met | abandoned | review_due | reviewed. // The last two are the v1.22 (W9, C2) n-of-1 experiment lifecycle: an // "experiment" is just a CoachPlan with a reviewDate; when the review fires and // the read-back is generated, `outcomeEncrypted` carries the grounded result. model CoachPlan { id String @id @default(cuid()) userId String @map("user_id") // The metric this plan moves (e.g. WEIGHT, SLEEP, BLOOD_PRESSURE). Plain so // the picker can filter / group without a decrypt. metric String // Free-text "if " and "then " — implementation-intention prose, // encrypted at rest. Never a number / measurement (those live in the data). ifCueEncrypted Bytes @map("if_cue_encrypted") thenActionEncrypted Bytes @map("then_action_encrypted") // Optional free-text target ("70 kg by August", "lights out by 23:00"). Free // prose like the cue/action, so encrypted at rest as well. targetEncrypted Bytes? @map("target_encrypted") // App-side closed enum: proposed | active | met | abandoned | review_due | // reviewed (the last two are the W9/C2 experiment lifecycle). status String @default("proposed") // Optional review checkpoint the Coach can recall ("how did this go by …"). reviewDate DateTime? @map("review_date") // v1.22 (W9, C2) — the n-of-1 read-back prose, grounded in the user's own // before/after numbers and encrypted at rest like the cue/action. Written // once when an experiment's review is processed; surfaced behind a flag. outcomeEncrypted Bytes? @map("outcome_encrypted") // Provenance — nullable so a future manual-entry surface can leave it null. sourceConversationId String? @map("source_conversation_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") // Soft-delete: hides the plan from injection but keeps the row for audit. deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, status, deletedAt]) @@map("coach_plans") } // v1.22 (B2/B6) — durable, user-scoped EPISODIC reminder memory: a // time-anchored "bring this back later" intent the Coach captured from a // conversation ("remind me about my sleep next week"). This is the middle // memory type that neither CoachFact (durable traits) nor CoachPlan (standing // if-then habits) covers — an episode with a due moment and a done state. // // The free-text note is the user's own framing, encrypted at rest (AES-256-GCM // via the shared Bytes codec, exactly like CoachPlan's cue/action). `metric`, // `status`, the trigger fields and the dates stay plain so the daily sweep and // the injection picker can rank / filter without paying a per-row decrypt. // // `status` is an APP-SIDE closed enum (NOT a DB enum, to keep the migration // purely additive): proposed | active | due | surfaced | done | dismissed. // `triggerKind` is likewise app-side closed: date | context. Soft-delete via // `deletedAt` so a "forget this" action is reversible / auditable. model CoachReminder { id String @id @default(cuid()) userId String @map("user_id") // What to bring back — the user's own framing, encrypted at rest. noteEncrypted Bytes @map("note_encrypted") // Optional link to the metric / plan it concerns; plain for filtering. metric String? relatedPlanId String? @map("related_plan_id") // Trigger model — app-side closed enum (date | context). For `date`, `dueAt` // carries the fire moment; for `context`, `contextCue` names a closed cue. triggerKind String @default("date") @map("trigger_kind") dueAt DateTime? @map("due_at") contextCue String? @map("context_cue") // Lifecycle: proposed | active | due | surfaced | done | dismissed. status String @default("active") // Provenance — how it was captured (sentinel | extractor | manual | action). source String sourceConversationId String? @map("source_conversation_id") lastSurfacedAt DateTime? @map("last_surfaced_at") // How many times the sweep has surfaced this without the user acting; capped // before auto-dismiss so an ignored reminder cannot nag indefinitely. surfaceCount Int @default(0) @map("surface_count") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) // The daily sweep scans active reminders whose dueAt has passed; the status + // deleted_at columns are part of the predicate so the read stays index-backed. @@index([userId, status, dueAt, deletedAt]) @@index([userId, triggerKind, status]) @@map("coach_reminders") } // v1.15.20 — user-authored "about me" self-description the AI surfaces // (Coach system prompt + daily-briefing user prompt) inject as a clearly // delimited, user-provided context block. 1:1 with User and lazily // created on first save so existing accounts pay nothing. The free text // is encrypted at rest (AES-256-GCM via the shared Bytes codec, exactly // like `CoachFact.factEncrypted`) — it is unstructured health prose and // must never land in the table as plaintext. A cleared profile keeps the // row with a NULL payload so `updatedAt` documents the deletion instant. model UserHealthProfile { id String @id @default(cuid()) userId String @unique @map("user_id") // Free text, hard-capped at 4 000 chars by the route's Zod schema // BEFORE encryption. NULL = cleared / never written. aboutMeEncrypted Bytes? @map("about_me_encrypted") // v1.16.0 — structured self-context companions to the free text. // Short answers (each capped at 500 chars BEFORE encryption). // Age/gender deliberately do NOT live here — they come from the User // profile and are merged into the prompt block at assembly time so // the data is never duplicated. conditionsEncrypted Bytes? @map("conditions_encrypted") allergiesEncrypted Bytes? @map("allergies_encrypted") coachFocusEncrypted Bytes? @map("coach_focus_encrypted") // v1.16.0 — up to 3 clarifying questions the server derives after a // save (AI-generated when a provider + budget allow, deterministic // hints otherwise). Encrypted JSON string[]; the Coach composer // renders them as tappable suggestion chips. NULL = none pending. pendingQuestionsEncrypted Bytes? @map("pending_questions_encrypted") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("user_health_profiles") } // Per-user, per-day token-spend ledger for the AI Coach. The dispatcher // reads `totalTokens` against `MAX_TOKENS_PER_USER_PER_DAY` before // hitting any provider, so a runaway client cannot drain the operator's // LLM budget. `dateKey` is the UTC `YYYY-MM-DD` bucket; the unique // constraint enforces one row per (user, day). `messageCount` is a // secondary signal used by the frontend to render a "≈ N replies left" // hint without exposing raw token figures. model CoachUsage { id String @id @default(cuid()) userId String @map("user_id") dateKey String @map("date_key") totalTokens Int @default(0) @map("total_tokens") messageCount Int @default(0) @map("message_count") updatedAt DateTime @updatedAt @map("updated_at") createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId, dateKey]) @@index([userId, dateKey]) @@map("coach_usage") } // ─── Consent Receipts (v1.4.40 SB-10) ───────────────────────── // // Per-user audit trail of AI-processing consent grants. The iOS app // posts a fresh row whenever the user opts in to off-device AI flows // during onboarding or via Settings; append-only so a revoke + re-grant // cycle reads chronologically. Persists the raw signed artefact (PDF // receipt OR signed JWT — caller's choice) the App-Store Guideline // 5.1.2(i) audit needs to be verifiable. The latest row per user is // the source of truth for "is AI active right now" decisions; older // rows are kept for the GDPR Art. 7 burden of proof. // // `kind` is a free-text discriminator the iOS / web client uses to // distinguish "ai_full" / "ai_insights_only" / future scopes without // requiring a schema migration per new tier. model ConsentReceipt { id String @id @default(cuid()) userId String @map("user_id") kind String /// Raw consent artefact — either a base64-encoded PDF or a signed /// JWT. The server does not parse this payload; the audit trail is /// the storage itself plus the `signedAt` timestamp. artefact String @db.Text /// Wall-clock the client recorded the user's tap. UTC ISO-8601. signedAt DateTime @map("signed_at") /// Optional revocation marker — set when a follow-up "revoke" row /// is minted so the latest-per-user lookup can shortcut to "no /// active consent" without diffing the kind discriminator. revokedAt DateTime? @map("revoked_at") createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, createdAt(sort: Desc)]) // `consent_receipts_user_id_kind_active_key` is a partial UNIQUE index // over (user_id, kind) `WHERE revoked_at IS NULL` — see migration 0159. // It enforces at most one *active* receipt per (user, kind) so two // concurrent web-grant heals can't both mint an active row. Prisma can't // express a partial-predicate unique in the schema language, so it's // introduced via raw SQL and intentionally omitted here. The append-only // history (revoked rows) stays unconstrained because revoked rows carry a // non-NULL `revoked_at` and fall outside the predicate. @@map("consent_receipts") } // ─── Push attempt ledger (v1.4.49) ─────────────────────────── // // Per-attempt push-delivery history. Every sender on the dispatcher // cascade (APNS, WEB_PUSH, TELEGRAM, NTFY) writes one fire-and-forget // row per dispatch so the admin diagnostic endpoint can surface the // trailing 20 attempts per user without needing DB shell access. // // The write is best-effort: a DB hiccup on the insert must not break // the actual push delivery, so every sender wraps the `create()` in a // `void … .catch(() => {})` block. There is no retry — a single // missing row is acceptable. // // Retention: a daily cron at 03:35 Europe/Berlin (between the // audit-log cleanup at 03:15 and the mood-reminder cleanup at 03:25) // deletes rows older than 90 days. See `handlePushAttemptCleanup` in // `src/lib/jobs/reminder-worker.ts`. // // `eventType` mirrors the dispatcher's `NotificationPayload.eventType` // string (`MEDICATION_REMINDER`, `MOOD_REMINDER`, `PERSONAL_RECORD`, // `MEASUREMENT_ANOMALY`, `SYSTEM_ALERT`, …); stored as plain text so // future event-type additions never require a schema migration. // // `channel` is the dispatcher `ChannelType` string (`APNS`, // `WEB_PUSH`, `TELEGRAM`, `NTFY`). // // `result` is one of `"ok"` | `"error"` | `"skipped"` — `skipped` // covers the "no recipient" / "config missing" soft cases. // // `reason` is the failure reason on `error` or `skipped` results, and // null on `ok`. For APNS this is Apple's reason code // (`BadDeviceToken`, `Unregistered`, …); for Web Push it carries the // classified `web_push_*` reason; Telegram / NTFY likewise mirror // their sender-side `SendOutcome.reason`. model PushAttempt { id String @id @default(cuid()) userId String @map("user_id") channel String // "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY" eventType String @map("event_type") // dispatcher event-type string result String // "ok" | "error" | "skipped" reason String? // sender-side reason on error/skip; null on ok createdAt DateTime @default(now()) @map("created_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, createdAt(sort: Desc)]) @@map("push_attempts") } // ─── Clinician share links (scoped, time-limited, revocable read view) ─── // // v1.11.0 — a user mints a link they hand a clinician (email, paper, QR). // The clinician opens it in any browser with no account and sees a // read-only view of the health record. The raw `hls_` token is stored // ONLY as an HMAC-SHA256 hash (`tokenHash`, same scheme as // `ApiToken.tokenHash`, keyed by `API_TOKEN_HMAC_KEY`); the plaintext is // returned to the owner exactly once at creation and never persisted. // // The scope is FROZEN at creation: `rangeStart`, `sectionsJson` and // `resourceTypes` are write-once and a share can NEVER widen later — only // `revokedAt`, `lastAccessAt` and `accessCount` mutate after mint. This // mirrors the append-only spirit of `ConsentReceipt`. `expiresAt` is // mandatory (capped at `SHARE_LINK_MAX_DAYS`, default 90) — there is no // never-expiring share. Deleting the user cascades every share away so // the GDPR-erasure path stays clean. model ClinicianShareLink { id String @id @default(cuid()) userId String @map("user_id") /// HMAC-SHA256 of the raw `hls_` token (same scheme as `ApiToken.tokenHash`). tokenHash String @unique @map("token_hash") /// v1.18.7 — HMAC-SHA256 of the passphrase second factor (same key as /// `tokenHash`). NULL for legacy links minted before the passphrase gate; /// those keep the possession-only contract (the public view renders without /// a gate). A non-null value forces the unlock gate before the record paints. /// The raw passphrase is returned exactly once at create time and stored only /// as this hash — there is no recovery path. passphraseHash String? @map("passphrase_hash") /// Human label the owner sets (e.g. a clinic/specialty note). Plaintext, bounded. label String // ── Frozen scope (write-once at creation; never widened) ── /// Reporting-window start (absolute, not "last N days"). Frozen so a /// rolling share can never reach data older than the owner chose. rangeStart DateTime @map("range_start") /// Window end. Null = rolling ("up to now"); set = frozen snapshot window. rangeEnd DateTime? @map("range_end") /// Section toggles (the `DoctorReportPrefs` shape the export uses). JSON. sectionsJson Json @map("sections_json") /// FHIR resource types this link may serve (a subset of the catalogue), /// e.g. `["Observation","MedicationStatement"]`. resourceTypes String[] @map("resource_types") /// Whether the FHIR API is reachable via this link at all (vs the view only). allowFhirApi Boolean @default(false) @map("allow_fhir_api") /// REQUIRED — no never-expiring share (capped at `SHARE_LINK_MAX_DAYS`). expiresAt DateTime @map("expires_at") createdAt DateTime @default(now()) @map("created_at") // ── Mutable lifecycle/access counters (the only columns that change) ── revokedAt DateTime? @map("revoked_at") lastAccessAt DateTime? @map("last_access_at") accessCount Int @default(0) @map("access_count") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, createdAt(sort: Desc)]) @@index([expiresAt]) @@map("clinician_share_links") } // v1.11.0 W3 — period-narrative cache (Pillar P1). // // One durable, typed row per (user, period, locale) carrying the latest // generated period summary ("how your week/month went"). Delete + // regenerate clean: the unique constraint enforces a single row per slot, // so a regeneration upserts in place and there is never a stale duplicate // to disambiguate. The generated prose is held AES-256-GCM at rest in // `encryptedContent` (Bytes), following the `CoachMessage.encryptedContent` // precedent — the body is descriptive health narrative and is treated as // sensitive even though it carries labels-plus-numbers, not raw rows. // // `provenanceJson` is the labels-only provenance envelope the surface // renders as ⓘ chips (metric names + the read window + the FDR footer); // it carries no PII so it stays plaintext for a stable, queryable shape, // matching `CoachMessage.metricSourceJson`. `providerType` + `promptVersion` // mirror the attribution columns so the cross-feature aggregator can slice // quality per (provider × prompt). `period` is `week` | `month`; `dateKey` // is the UTC `YYYY-MM-DD` boundary the row was generated for, so the // stale-while-revalidate read can tell "today's" narrative from an older // one without decrypting. model InsightNarrative { id String @id @default(cuid()) userId String @map("user_id") /// "week" | "month". period String /// Resolved UI locale of the prose ("de" | "en"). locale String /// UTC YYYY-MM-DD boundary the narrative was generated for. dateKey String @map("date_key") /// AES-256-GCM ciphertext of the generated narrative prose. encryptedContent Bytes @map("encrypted_content") /// Labels-only provenance envelope (metric names, window, FDR footer). provenanceJson String? @map("provenance_json") providerType String? @map("provider_type") promptVersion String? @map("prompt_version") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) // One row per (user, period, locale) — regeneration upserts in place. @@unique([userId, period, locale]) @@index([userId, updatedAt(sort: Desc)]) @@map("insight_narratives") } // ─── Cycle Tracking (v1.15.0) ───────────────────────────────── // // Self-hosted reproductive-health tracking: period + flow, symptom // taxonomy, BBT / OPK / cervical-mucus capture, ranged-confidence // prediction. The capture rows (MenstrualCycle, CycleDayLog) carry the // same soft-delete + syncVersion shape as MoodEntry so they ride the // `/api/sync/changes` delta feed. CycleProfile + CyclePrediction are // settings / derived rows and do NOT tombstone. // // Sensitivity: free-text notes are always AES-256-GCM at rest // (`notesEncrypted`, the `*Encrypted` convention). The default-ON // `sensitiveCategoryEncryption` flag drives whether the intent-revealing // fields (pregnancy / contraception / sexual activity) also encrypt at // the application layer — those stay queryable-plaintext-physiology // (flow, BBT, OPK, mucus, dates, phase) untouched so the rollup + // correlation tier keeps working. /// Per-user goal that gates every cycle copy + the fertile-window /// surfacing. `OFF` disables the category entirely; `GENERAL_HEALTH` / /// `PERIMENOPAUSE` hide conception framing; only `TRYING_TO_CONCEIVE` /// (and, with a stronger caveat, `AVOID_PREGNANCY`) surface fertile-window /// language. enum CycleTrackingGoal { GENERAL_HEALTH AVOID_PREGNANCY TRYING_TO_CONCEIVE PERIMENOPAUSE OFF @@map("cycle_tracking_goal") } /// Menstrual-flow intensity for a logged day. Mirrors the HealthKit /// `HKCategoryValueMenstrualFlow` ladder (unspecified collapses to the /// presence of a row; `NONE` is an explicit no-flow marker). enum FlowLevel { NONE SPOTTING LIGHT MEDIUM HEAVY @@map("flow_level") } /// Ovulation predictor-kit (OPK) reading. `POSITIVE_LH_SURGE` is the /// LH-surge positive; `ESTROGEN_SURGE` is the estrogen-rise pre-peak /// some dual-hormone kits report. Mirrors HealthKit /// `HKCategoryValueOvulationTestResult`. enum OvulationTest { NEGATIVE POSITIVE_LH_SURGE ESTROGEN_SURGE INDETERMINATE @@map("ovulation_test") } /// Cervical-mucus quality, fertility-awareness ladder (dry → egg-white /// peak). Mirrors HealthKit `HKCategoryValueCervicalMucusQuality`. enum CervicalMucus { DRY STICKY CREAMY WATERY EGG_WHITE @@map("cervical_mucus") } /// Generic home-test result for the pregnancy / progesterone test /// fields. Mirrors HealthKit `HKCategoryValuePregnancyTestResult` / /// `HKCategoryValueProgesteroneTestResult`. enum HomeTestResult { NEGATIVE POSITIVE INDETERMINATE @@map("home_test_result") } /// Contraceptive method logged for a day. `UNSPECIFIED` mirrors the /// HealthKit `unspecified` member distinct from `NONE` (an explicit /// no-method marker). Mirrors HealthKit `HKCategoryValueContraceptive`. enum ContraceptiveKind { NONE UNSPECIFIED IMPLANT INJECTION IUD INTRAVAGINAL_RING ORAL PATCH EMERGENCY @@map("contraceptive_kind") } /// Derived cycle phase used by the prediction engine + the CYCLE_PHASE /// correlation channel. Computed from day-of-cycle + the back-calculated /// luteal length, never stored on the capture rows. enum CyclePhase { MENSTRUAL FOLLICULAR OVULATORY LUTEAL @@map("cycle_phase") } /// Which symptothermal SECONDARY sign the user observes alongside the /// temperature primary sign for the Sensiplan double-check. `MUCUS` /// (cervical-mucus quality) is the default; `CERVIX` swaps in the cervix /// observation (position / firmness / opening). The temperature primary /// sign is unchanged either way — only the second indicator of the /// double-check differs. Mirrors the `drip` app's Settings → /// Customization → Secondary symptom choice. enum SecondarySymptom { MUCUS CERVIX @@map("secondary_symptom") } /// Cervix vertical position, a Sensiplan cervix sign. Fertile = `HIGH` /// (the cervix rises around ovulation); infertile = `LOW`. enum CervixPosition { LOW HIGH @@map("cervix_position") } /// Cervix firmness, a Sensiplan cervix sign. Fertile = `SOFT` (the cervix /// softens around ovulation); infertile = `FIRM`. enum CervixFirmness { FIRM SOFT @@map("cervix_firmness") } /// Cervix os / opening, a Sensiplan cervix sign. Fertile = `OPEN`; /// infertile = `CLOSED`. enum CervixOpening { CLOSED OPEN @@map("cervix_opening") } /// Broad condition class for an IllnessEpisode (v1.19.0). Widened beyond an /// infection log so the journal covers any condition the user keeps for /// themselves. enum IllnessType { INFECTION ALLERGY INJURY MENTAL_HEALTH AUTOIMMUNE CHRONIC OTHER @@map("illness_type") } /// Where an IllnessEpisode sits in its lifecycle (v1.19.0). `CHRONIC_ONGOING` /// has no "recovered" date and is excluded from the recovery-gap math; /// `FLARE` rows hang off a parent condition via `parentConditionId`. enum IllnessLifecycle { ACUTE CHRONIC_ONGOING RECURRING FLARE @@map("illness_lifecycle") } /// Which method produced a cached CyclePrediction. `CALENDAR` is the /// median+MAD variability predictor; `SYMPTOTHERMAL` folds the /// sensiplan BBT + mucus confirmation; `TEMPERATURE_TREND` is the /// retrospective wrist/skin-temperature ovulation; `BLENDED` combines /// them. enum PredictionMethod { CALENDAR SYMPTOTHERMAL TEMPERATURE_TREND BLENDED @@map("prediction_method") } /// One settings row per user, lazily upserted on first cycle-surface /// access (goal defaults to GENERAL_HEALTH). Not soft-deleted — it is a /// profile row, like the other per-user settings columns on User. model CycleProfile { id String @id @default(cuid()) userId String @map("user_id") /// Drives all cycle copy + fertile-window gating (see CycleTrackingGoal). goal CycleTrackingGoal @default(GENERAL_HEALTH) /// Per-user feature enable. NULL = derive from gender (`FEMALE` → on, /// everything else → off) so the column never needs a backfill; an /// explicit `true` opts a non-FEMALE account in, an explicit `false` /// opts a FEMALE account out. Resolved through `isCycleEnabled()` — /// every `/api/cycle/*` route 403s with `errorCode:"cycle.disabled"` /// when the resolved value is false. cycleTrackingEnabled Boolean? @map("cycle_tracking_enabled") /// Optional user-supplied priors. The engine applies a luteal default /// of 14 days in code (NOT a DB default) when `lutealPhaseLength` is /// NULL, so a future constant change never strands historical rows. typicalCycleLength Int? @map("typical_cycle_length") typicalPeriodLength Int? @map("typical_period_length") lutealPhaseLength Int? @map("luteal_phase_length") /// Which symptothermal SECONDARY sign the user observes for the Sensiplan /// double-check: `MUCUS` (default) or `CERVIX`. Lives behind the advanced /// cycle settings — the casual user keeps temperature + mucus and never /// sees the choice. The temperature primary sign is unchanged either way. secondarySymptom SecondarySymptom @default(MUCUS) @map("secondary_symptom") /// When false, the app computes / stores NO predictions — pure logging /// (the Read-Your-Body posture for users who distrust algorithmic /// interpretation). predictionEnabled Boolean @default(true) @map("prediction_enabled") /// Raw-chart mode — surfaces logged values without the interpreted /// prediction overlays. rawChartMode Boolean @default(false) @map("raw_chart_mode") /// When true, every cycle push body is generic ("HealthLog reminder") /// so no cycle-event naming reaches the lock screen. discreetNotifications Boolean @default(false) @map("discreet_notifications") /// Default-ON for this category (post-Dobbs threat model): drives /// whether the intent-revealing day-log fields encrypt at the /// application layer in addition to the always-encrypted notes. sensitiveCategoryEncryption Boolean @default(true) @map("sensitive_category_encryption") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([userId]) @@map("cycle_profiles") } /// One row per menstrual cycle (a period-start anchor + derived span). /// `isPredicted` rows are forward-minted forecasts; observed rows are /// confirmed from logged flow. Soft-deleted + syncVersion-bumped so the /// row rides the `/api/sync/changes` feed as a tombstone. model MenstrualCycle { id String @id @default(cuid()) userId String @map("user_id") /// YYYY-MM-DD — period start, anchored to `tz` when non-null, else the /// legacy Europe/Berlin assumption (the MoodEntry.date convention). startDate String @map("start_date") /// YYYY-MM-DD — cycle end (= day before the next cycle's start). endDate String? @map("end_date") /// YYYY-MM-DD — last bleeding day of this cycle's period. periodEndDate String? @map("period_end_date") /// Observed cycle length in days (start→next start). NULL until the /// next cycle anchors it. lengthDays Int? @map("length_days") /// YYYY-MM-DD — confirmed or estimated ovulation day. ovulationDate String? @map("ovulation_date") /// True once ovulation is confirmed (symptothermal / temperature-trend), /// as opposed to a calendar estimate. ovulationConfirmed Boolean @default(false) @map("ovulation_confirmed") /// Forward-minted forecast row vs an observed cycle. isPredicted Boolean @default(false) @map("is_predicted") /// Per-row IANA timezone the date strings are anchored to (NULL = /// legacy Europe/Berlin), mirroring MoodEntry.tz. tz String? syncVersion Int @default(0) @map("sync_version") deletedAt DateTime? @map("deleted_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) dayLogs CycleDayLog[] @@unique([userId, startDate]) @@index([userId, startDate]) // Keyset support for the `/api/sync/changes` cycle feed // (`where userId` ordered by `(updatedAt asc, id asc)`). @@index([userId, updatedAt, id]) @@map("menstrual_cycles") } /// One canonical row per (user, day): the HealthKit sync unit + the /// manual log-day surface. Dual-unique exactly like MoodEntry — the /// canonical-day key plus the NULL-distinct external-dedup key for /// HealthKit re-ingest. Soft-deleted + syncVersion-bumped. model CycleDayLog { id String @id @default(cuid()) userId String @map("user_id") /// YYYY-MM-DD — anchored to `tz` when non-null, else legacy /// Europe/Berlin (the MoodEntry.date convention). date String /// Optional link to the owning MenstrualCycle (set once the day is /// attributed to a cycle span). cycleId String? @map("cycle_id") /// Menstrual-flow intensity (NULL = not logged this day). flow FlowLevel? /// Spotting / bleeding outside the period window. intermenstrualBleeding Boolean @default(false) @map("intermenstrual_bleeding") /// Basal body temperature in Celsius. Queryable-plaintext — feeds the /// symptothermal confirmation. BBT also lands in Measurement /// (BODY_TEMPERATURE) for charting; this column is the cycle-local copy. basalBodyTempC Float? @map("basal_body_temp_c") /// Whether this day's BBT is a disturbed/unreliable reading (fever, /// illness, alcohol, a late or short-sleep measurement) the user marked /// excluded. When true the symptothermal engine drops the reading from the /// cover-line baseline AND the shift evaluation, so a spike neither /// fabricates nor masks a temperature rise (Sensiplan: the cover line is /// drawn over the last six *unbracketed* values). temperatureExcluded Boolean @default(false) @map("temperature_excluded") /// Ovulation predictor-kit (OPK) reading. ovulationTest OvulationTest? @map("ovulation_test") /// Cervical-mucus quality. cervicalMucus CervicalMucus? @map("cervical_mucus") /// Cervix observation — the three standard Sensiplan cervix signs, used /// as the symptothermal secondary indicator when /// CycleProfile.secondarySymptom is CERVIX. All NULL = not observed. /// The fertile combination is HIGH + SOFT + OPEN; the infertile /// (closure) combination is LOW + FIRM + CLOSED. Queryable-plaintext like /// the other physiology signs (mucus / BBT / OPK). cervixPosition CervixPosition? @map("cervix_position") cervixFirmness CervixFirmness? @map("cervix_firmness") cervixOpening CervixOpening? @map("cervix_opening") /// Intent-revealing fields. When CycleProfile.sensitiveCategoryEncryption /// is ON these are encrypted at the application layer (the dispatch /// envelope), default-ON for this category. sexualActivity Boolean @default(false) @map("sexual_activity") protectedSex Boolean? @map("protected_sex") pregnancyTest HomeTestResult? @map("pregnancy_test") progesteroneTest HomeTestResult? @map("progesterone_test") contraceptive ContraceptiveKind? @map("contraceptive") /// AES-256-GCM ciphertext envelope of the intent-revealing fields /// (sexualActivity / protectedSex / pregnancyTest / progesteroneTest / /// contraceptive). Populated INSTEAD of the plaintext columns above when /// CycleProfile.sensitiveCategoryEncryption is ON: the plaintext columns /// are NULLed and this carries the JSON envelope, so the most sensitive /// reproductive data is encrypted at rest. Those five fields then drop /// out of the rollup / correlation tier (queryable plaintext is the /// trade for at-rest secrecy). Decrypted fail-soft on read. sensitiveEncrypted String? @map("sensitive_encrypted") /// AES-256-GCM ciphertext envelope of the day's free-text note (always /// encrypted — the `*Encrypted` String? convention, matching /// MoodTag.labelEncrypted + InsightNarrative). notesEncrypted String? @map("notes_encrypted") /// Provenance. MANUAL (web / single POST) or APPLE_HEALTH (the batch /// ingest); mirrors MoodEntry.source. The wire spelling is APPLE_HEALTH. source MeasurementSource @default(MANUAL) /// Stable per-source dedup id. NULL for native / MANUAL entries (those /// dedup on the canonical `(userId, date)` key); a HealthKit re-ingest /// upserts on `(userId, source, externalId)`. Postgres treats NULLs as /// distinct so MANUAL rows never collide on it. externalId String? @map("external_id") tz String? syncVersion Int @default(0) @map("sync_version") deletedAt DateTime? @map("deleted_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) cycle MenstrualCycle? @relation(fields: [cycleId], references: [id], onDelete: SetNull) symptomLinks CycleSymptomLink[] @@unique([userId, date]) // NULL-distinct external dedup key for HealthKit re-ingest (the // MoodEntry dual-unique precedent). @@unique([userId, source, externalId]) @@index([userId, date]) // Keyset support for the `/api/sync/changes` cycle-day feed. @@index([userId, updatedAt, id]) @@map("cycle_day_logs") } // ─── Cycle Symptom Taxonomy (v1.15.0) ───────────────────────── // // Clones the MoodTagCategory → MoodTag → MoodEntryTagLink trio: a global, // icon-bearing, labelKey-i18n, isActive/sortOrder catalog plus a per-day // join. `CycleSymptom` additionally carries `userId?` + `labelEncrypted?` // so a future release can mint per-user custom symptoms (the MoodTag // custom-tag precedent); v1.15.0 ships seeded-catalogue-only. model CycleSymptomCategory { id String @id @default(cuid()) /// Stable machine key (e.g. `physical`). Unique so the seed migration /// can upsert idempotently. key String @unique /// i18n message key for the category label (`cycle.symptomCategory.`). labelKey String @map("label_key") /// Lucide icon name for the category header. icon String? sortOrder Int @default(0) @map("sort_order") /// Soft-hide a category without deleting its historical links. isActive Boolean @default(true) @map("is_active") symptoms CycleSymptom[] @@map("cycle_symptom_categories") } model CycleSymptom { id String @id @default(cuid()) categoryId String @map("category_id") /// Stable machine key (e.g. `cramps`). Unique across the catalog. key String @unique /// i18n message key for the symptom label (`cycle.symptom.`). labelKey String @map("label_key") /// Lucide icon name for the symptom chip. icon String? sortOrder Int @default(0) @map("sort_order") isActive Boolean @default(true) @map("is_active") /// Owner of a per-user custom symptom. NULL = the global seeded /// catalogue (resolves `labelKey` against the locale). Set = a user's /// own custom symptom carrying a `custom:` key and a free-text /// label held in `labelEncrypted` (the MoodTag custom-tag precedent). userId String? @map("user_id") /// AES-256-GCM ciphertext of a custom symptom's free-text label. NULL /// for catalogue rows. Decrypted server-side for the effective read. labelEncrypted String? @map("label_encrypted") category CycleSymptomCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade) user User? @relation("UserCycleSymptoms", fields: [userId], references: [id], onDelete: Cascade) links CycleSymptomLink[] @@index([categoryId, sortOrder]) @@index([userId]) @@map("cycle_symptoms") } /// Join: which symptoms a cycle day-log carries. Composite PK keeps the /// pairing unique; cascade on both sides so deleting a day-log (or a /// catalog symptom) cleans up its links. model CycleSymptomLink { dayLogId String @map("day_log_id") symptomId String @map("symptom_id") /// Optional 1-4 Likert intensity for this symptom on this day. NULL = a /// plain presence link (the pre-severity contract). Surfaced in /// CycleDayLogDTO so the log-day sheet can pre-fill the severity chips. severity Int? @map("severity") createdAt DateTime @default(now()) @map("created_at") dayLog CycleDayLog @relation(fields: [dayLogId], references: [id], onDelete: Cascade) symptom CycleSymptom @relation(fields: [symptomId], references: [id], onDelete: Cascade) @@id([dayLogId, symptomId]) @@index([symptomId]) @@map("cycle_symptom_links") } // ─── Illness / condition journal (v1.19.0) ──────────────────── // // A CONDITION journal, not a flu log: any illness/condition kept for // oneself, with a retrospective lens ("how did it announce itself?", // what dropped, recovery time). Retrospective + correlated, NEVER a // predictor/diagnoser. Born-gated by the module system (the gate is // app-layer; there is no enable flag in the schema). The daily timeline // mirrors the Cycle three-table shape (DayLog + Symptom catalog + Link). /// One illness/condition episode. `lifecycle` distinguishes an acute /// bout from an ongoing chronic condition, a recurring one, or a flare /// hanging off a parent condition (`parentConditionId`). CHRONIC_ONGOING /// has no "recovered" date and is excluded from the recovery-gap math. model IllnessEpisode { id String @id @default(cuid()) userId String @map("user_id") /// User-facing label for the episode, e.g. "Erkältung", "Heuschnupfen". label String /// Broad condition class. Widened beyond an infection log to cover the /// condition journal's scope. type IllnessType /// Where this episode sits in its lifecycle. FLARE rows reference a /// parent condition via `parentConditionId`. lifecycle IllnessLifecycle @default(ACUTE) /// When the episode began (UTC instant; wall-clock anchoring is the /// reader's concern per the schema's UTC-only convention). onsetAt DateTime @map("onset_at") /// When the episode resolved. NULL = still ongoing. CHRONIC_ONGOING /// episodes stay NULL by design. resolvedAt DateTime? @map("resolved_at") /// Self-FK for flares: a FLARE/RECURRING bout hangs off the parent /// condition episode so the history threads under one condition. parentConditionId String? @map("parent_condition_id") /// Optional free-text note, AES-256-GCM encrypted at rest via /// `src/lib/crypto.ts` (Bytes column, `*Encrypted` suffix per /// convention). noteEncrypted Bytes? @map("note_encrypted") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// Parent condition for a flare/recurrence (self-relation). parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull) flares IllnessEpisode[] @relation("IllnessFlares") dayLogs IllnessDayLog[] @@index([userId, deletedAt]) @@index([userId, onsetAt]) @@index([parentConditionId]) @@map("illness_episodes") } /// One row per day of an illness episode (mirrors CycleDayLog): the /// day's free-text note (encrypted), a coarse functional-impact slider, /// an optional fever reading, plus the symptom links. Soft-deleted. model IllnessDayLog { id String @id @default(cuid()) userId String @map("user_id") episodeId String @map("episode_id") /// YYYY-MM-DD — anchored to `tz` when non-null, else legacy /// Europe/Berlin (the CycleDayLog / MoodEntry date convention). date String /// Coarse self-rated functional impact for the day (0 = no impact / /// fully functional … 3 = bedbound). NULL = not logged. A slider, not a /// symptom — kept on the day row like CycleDayLog's per-day signs. functionalImpact Int? @map("functional_impact") /// Optional body temperature for the day, in Celsius. Queryable /// plaintext (the CycleDayLog.basalBodyTempC precedent): the fever /// signal feeds the retrospective curve. NULL = not measured. feverC Float? @map("fever_c") /// AES-256-GCM ciphertext envelope of the day's free-text note (always /// encrypted — the `*Encrypted` Bytes convention). noteEncrypted Bytes? @map("note_encrypted") /// Optional timezone the `date` is anchored to (the CycleDayLog /// precedent). tz String? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) episode IllnessEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade) symptomLinks IllnessSymptomLink[] @@unique([episodeId, date]) @@index([userId, date]) @@index([episodeId]) @@map("illness_day_logs") } /// Seeded catalog of illness symptoms (Jackson / WURSS-derived). Mirrors /// CycleSymptom: a global, icon-bearing, labelKey-i18n, isActive/sortOrder /// catalog joined per day. Severity is captured 0–3 on the link. model IllnessSymptom { id String @id @default(cuid()) /// Stable machine key (e.g. `cough`). Unique across the catalog so the /// seed migration can upsert idempotently. key String @unique /// i18n message key for the symptom label (`illness.symptom.`). labelKey String @map("label_key") /// Lucide icon name for the symptom chip. icon String? sortOrder Int @default(0) @map("sort_order") /// Soft-hide a symptom without deleting its historical links. isActive Boolean @default(true) @map("is_active") links IllnessSymptomLink[] @@index([sortOrder]) @@map("illness_symptoms") } /// Join: which symptoms an illness day-log carries, with a 0–3 severity. /// Composite PK keeps the pairing unique; cascade on both sides so /// deleting a day-log (or a catalog symptom) cleans up its links. model IllnessSymptomLink { dayLogId String @map("day_log_id") symptomId String @map("symptom_id") /// 0–3 severity for this symptom on this day (the Jackson/WURSS scale: /// 0 = none, 1 = mild, 2 = moderate, 3 = severe). NULL = a plain /// presence link. severity Int? @map("severity") createdAt DateTime @default(now()) @map("created_at") dayLog IllnessDayLog @relation(fields: [dayLogId], references: [id], onDelete: Cascade) symptom IllnessSymptom @relation(fields: [symptomId], references: [id], onDelete: Cascade) @@id([dayLogId, symptomId]) @@index([symptomId]) @@map("illness_symptom_links") } // ─── Structured health records (v1.25 W-RECORDS) ────────────────── // // Allergies + family history kept as STRUCTURED records (not time-series // signals — they are NOT in the signal registry). Reference data the user // curates once and edits rarely, surfaced in the Settings "Anamnese" // (medical-history) section and exported to FHIR (AllergyIntolerance / // FamilyMemberHistory). Patient-reported throughout — never a clinical // diagnosis the app asserts. Sensitive free-text fields are AES-256-GCM // encrypted at rest (`*Encrypted` Bytes columns, the shared note codec); // the structured enum fields stay queryable plaintext. Soft-deleted. /// Broad allergen category for an Allergy (maps to FHIR /// AllergyIntolerance.category — food / medication / environment / biologic). enum AllergyCategory { FOOD MEDICATION ENVIRONMENT BIOLOGIC OTHER @@map("allergy_category") } /// Distinguishes an immune-mediated allergy from a non-immune intolerance /// (FHIR AllergyIntolerance.type allergy | intolerance). enum AllergyType { ALLERGY INTOLERANCE @@map("allergy_type") } /// Worst-reaction severity (FHIR AllergyIntolerance.reaction.severity / /// criticality). NULL = not assessed. enum AllergySeverity { MILD MODERATE SEVERE @@map("allergy_severity") } /// Clinical status of the record (FHIR AllergyIntolerance.clinicalStatus). enum AllergyStatus { ACTIVE INACTIVE RESOLVED @@map("allergy_status") } /// One self-recorded allergy/intolerance. `substance` is the queryable /// human label (the AllergyIntolerance `code.text` anchor — never a /// machine-guessed SNOMED/RxNorm code). The free-text reaction description /// and notes are encrypted at rest. Patient-reported; the export carries a /// "not a clinical diagnosis" guard rail. model Allergy { id String @id @default(cuid()) userId String @map("user_id") /// User-facing substance/allergen name, e.g. "Penicillin", "Peanuts". /// Queryable plaintext label (the medication.name / illness.label /// precedent); rides FHIR `code.text`. substance String category AllergyCategory @default(OTHER) type AllergyType @default(ALLERGY) /// Worst observed reaction severity. NULL = not assessed. severity AllergySeverity? status AllergyStatus @default(ACTIVE) /// When the allergy was first noticed (UTC instant). NULL = unknown. onsetAt DateTime? @map("onset_at") /// AES-256-GCM ciphertext of the free-text reaction/manifestation /// description (e.g. "anaphylaxis", "hives"). Bytes via the shared codec. reactionEncrypted Bytes? @map("reaction_encrypted") /// AES-256-GCM ciphertext of an optional free-text note. Bytes column. notesEncrypted Bytes? @map("notes_encrypted") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, deletedAt]) @@map("allergies") } /// Relationship of a family member to the user (maps to FHIR /// FamilyMemberHistory.relationship). Lateralised grandparents let the /// maternal/paternal line be recorded where the user knows it. enum FamilyRelationship { MOTHER FATHER SISTER BROTHER DAUGHTER SON GRANDMOTHER_MATERNAL GRANDFATHER_MATERNAL GRANDMOTHER_PATERNAL GRANDFATHER_PATERNAL AUNT UNCLE COUSIN HALF_SIBLING OTHER @@map("family_relationship") } /// One condition recorded for one relative (a FHIR FamilyMemberHistory with /// a single condition). `condition` is the queryable human label (the /// FamilyMemberHistory.condition `code.text` anchor — never a machine-guessed /// code); `ageAtOnset` is the relative's age when the condition began. The /// optional free-text note is encrypted at rest. Patient-reported. model FamilyHistoryEntry { id String @id @default(cuid()) userId String @map("user_id") relationship FamilyRelationship /// User-facing condition name, e.g. "Type 2 diabetes", "Breast cancer". /// Queryable plaintext label; rides FHIR `condition.code.text`. condition String /// The relative's age (years) when the condition began. NULL = unknown. ageAtOnset Int? @map("age_at_onset") /// AES-256-GCM ciphertext of an optional free-text note. Bytes column. notesEncrypted Bytes? @map("notes_encrypted") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, deletedAt]) @@map("family_history_entries") } /// One cached forecast per user, regenerated debounced / /// stale-while-revalidate (the InsightNarrative + v1.8.7 assessment /// cache pattern), NOT per-read. Derived — not soft-deleted. model CyclePrediction { id String @id @default(cuid()) userId String @map("user_id") /// Which method produced this forecast. method PredictionMethod /// YYYY-MM-DD — predicted next period start (point estimate) plus the /// ranged Low / High band (rendered as a band, never a single date). nextPeriodStart String @map("next_period_start") nextPeriodStartLow String @map("next_period_start_low") nextPeriodStartHigh String @map("next_period_start_high") /// YYYY-MM-DD — fertile-window bounds (goal-gated at the read surface). fertileWindowStart String? @map("fertile_window_start") fertileWindowEnd String? @map("fertile_window_end") /// YYYY-MM-DD — predicted ovulation day. predictedOvulation String? @map("predicted_ovulation") /// 0..1 confidence, shrinking with cycle-length variance + sparse /// logging + < MIN_CYCLES observed. confidence Float /// How many observed cycles the forecast was computed from. cyclesObserved Int @map("cycles_observed") /// When the forecast was generated (drives the stale-while-revalidate /// debounce). generatedAt DateTime @map("generated_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]) @@map("cycle_predictions") } // ───────────────────────────────────────────────────────────────────────── // v1.25 (W-DOCS-IN) — inbound clinical documents. // // A self-hoster uploads a doctor report / discharge letter they RECEIVED. // The raw document is stored encrypted at rest; the dedicated OCR/vision // provider (the v1.22 document-scan rails) transcribes STRUCTURED FACTS into // a review-then-confirm staging area. The app reproduces what the clinician // wrote — it never interprets, never range-flags, never diagnoses, never // links a condition to a medication. A fact only reaches the structured // stores (labs / conditions / medications) once the user explicitly approves // it; low-confidence facts fail closed (surfaced for manual review, never // auto-committed). Opt-in module, off by default. // ───────────────────────────────────────────────────────────────────────── /// What kind of clinical document was uploaded (free choice by the user; /// drives no interpretation — it is a label only). The richer set /// (lab/imaging/prescription/insurance/vaccination) makes the library /// browsable by category; the value still drives no interpretation. enum InboundDocumentKind { DOCTOR_REPORT DISCHARGE_LETTER LAB_RESULT IMAGING PRESCRIPTION INSURANCE VACCINATION OTHER @@map("inbound_document_kind") } /// Lifecycle of an uploaded document. STORED is the library default — the /// file is uploaded + encrypted at rest with no extraction run. EXTRACTING /// while the provider runs, EXTRACTED once facts are staged for review, /// FAILED on a provider/parse miss, CONFIRMED once the user has acted on /// every fact, DISCARDED on a soft delete. enum InboundDocumentStatus { STORED EXTRACTING EXTRACTED FAILED CONFIRMED DISCARDED @@map("inbound_document_status") } /// The FHIR resource an extracted fact maps to (STATED status only): a /// diagnosis → Condition, a measured value → Observation, a medication the /// patient is/was taking → MedicationStatement. enum ExtractedFactType { CONDITION OBSERVATION MEDICATION_STATEMENT @@map("extracted_fact_type") } /// Review state of a staged fact. PENDING until the user decides; APPROVED /// once committed to a structured store; REJECTED when the user discards it. enum ExtractedFactStatus { PENDING APPROVED REJECTED @@map("extracted_fact_status") } model InboundDocument { id String @id @default(cuid()) userId String @map("user_id") kind InboundDocumentKind @default(OTHER) /// User-given label. Deliberately plaintext (like `filename`) so the library /// list can ILIKE-search + ORDER BY in SQL. The document BODY stays /// encrypted; only this short, user-authored title is clear-text. Tradeoff: /// a title the user types MAY contain PHI (e.g. "HIV result") and is then /// cleartext at rest / in backups — the same exposure the pre-existing /// plaintext `filename` already carries. Accepted to keep server-side search /// and sort. title String? /// Original filename as uploaded (sanitised, optional). filename String? /// Magic-byte-sniffed MIME type the document was stored as. mimeType String @map("mime_type") /// Stored byte length of the original (pre-encryption) document. byteSize Int @map("byte_size") /// AES-256-GCM ciphertext of the raw document bytes (base64 of the binary /// → `encrypt()` string → UTF-8 BYTEA), the `*_encrypted` Bytes convention /// shared with the note columns. The original is sensitive and never /// stored in the clear or logged. contentEncrypted Bytes @map("content_encrypted") status InboundDocumentStatus @default(STORED) /// Provider type that ran the extraction (e.g. "anthropic", "openai", /// "local"). Diagnostic only. Null on a store-only document. providerType String? @map("provider_type") /// The document's stated report/collection date (YYYY-MM-DD at UTC), as /// transcribed by the model — never inferred, never user-set. reportDate DateTime? @map("report_date") /// Effective filing date the library sorts, groups, and range-filters on /// (editable). Distinct from `reportDate`, which is the model-transcribed /// value. On store it defaults to the upload day (now) when the user does /// not supply one, so a stored row's filing date is never NULL and display /// == sort == filter. It stays user-editable and MAY be cleared back to /// NULL via PATCH (the list then sorts such rows last). documentDate DateTime? @map("document_date") /// Failure reason when status = FAILED (a short code, never the document). errorReason String? @map("error_reason") facts ExtractedFact[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = discarded. deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, createdAt]) // The list always filters `deletedAt: null` and sorts by `documentDate`, so // deletedAt leads the sort column to keep the scan covered. This composite is // a superset of the plain `(userId, deletedAt)` index, which is therefore // redundant for read planning and dropped. @@index([userId, deletedAt, documentDate]) @@map("inbound_documents") } model ExtractedFact { id String @id @default(cuid()) documentId String @map("document_id") userId String @map("user_id") factType ExtractedFactType @map("fact_type") status ExtractedFactStatus @default(PENDING) /// Overall extraction confidence 0..1 (the lowest per-field score). The /// review screen gates approval of a low-confidence fact behind a manual /// edit (fail-closed). confidence Float @default(0) /// True when the fact scored below the confidence floor and must be /// reviewed/edited before it can be approved. Cleared once the user edits /// the fact (the values then become user-asserted). needsReview Boolean @default(true) @map("needs_review") /// The FHIR-staged fields, STATED status only, shaped per `factType` /// (Condition / Observation / MedicationStatement). Codes (SNOMED / ICD-10 /// / LOINC / RxNorm / ATC) are present only when the document writes them. /// Stored encrypted: the JSON payload (diagnosis text, lab values, /// medication names, stated codes) is clinical PHI transcribed verbatim from /// the source document, so it rides the AES-256-GCM `*_encrypted` BYTEA /// convention shared with the note columns rather than as plaintext JSONB. dataEncrypted Bytes @map("data_encrypted") /// Per-field provenance: the verbatim source span the value came from, an /// optional page index, and per-field confidence. Makes "extract what's /// written" auditable against the original. The source span is a verbatim /// excerpt of the clinical document (PHI), so it is stored encrypted with the /// same codec as `dataEncrypted`. provenanceEncrypted Bytes @map("provenance_encrypted") /// The id of the structured-store row created when the fact was approved /// (LabResult / IllnessEpisode / Medication), or NULL while PENDING / /// REJECTED. committedRecordId String? @map("committed_record_id") /// The structured store the approved fact landed in ("labResult" / /// "illnessEpisode" / "medication"), or NULL. committedRecordType String? @map("committed_record_type") document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@index([documentId]) @@index([userId]) @@map("extracted_facts") }