mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
5152 lines
267 KiB
Plaintext
5152 lines
267 KiB
Plaintext
// 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")
|
||
|
||
// 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": ["<medicationId>", ...] }
|
||
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": ["<categoryKey>", ...],
|
||
// "placements": { "<categoryKey>": ["<tagKey>", ...] } }
|
||
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 — `<LayoutCoachFab>`, `<LayoutCoachMount>`, the inline
|
||
// `<CoachLaunchButton>` 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 `<Switch>` 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: { <key>: 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[]
|
||
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.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.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")
|
||
}
|
||
|
||
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).
|
||
|
||
@@map("measurement_type")
|
||
}
|
||
|
||
/// 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:<UTC-hour>`), 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-<unix>` 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-<unix>`).
|
||
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_<hex>` 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": "<iso>", "sleep": "<iso>", "workout": "<iso>",
|
||
/// "cycle": "<iso>" }` 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
|
||
/// `<scheme>://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=<opaque>` 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:<text>"]` 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.<key>`).
|
||
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:<uuid>` 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.<key>`).
|
||
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:<cuid>` 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")
|
||
}
|
||
|
||
// ─── 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 <cue>" and "then <action>" — 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.<key>`).
|
||
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.<key>`).
|
||
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:<cuid>` 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.<key>`).
|
||
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")
|
||
}
|
||
|
||
/// 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")
|
||
}
|