Files
HealthLog/prisma/schema.prisma
T
Marc-André Bombeck 5d62236412 feat(arrivals): add the data-arrival event spine
Every ingest path in the product can now emit a typed, salience-classified
event into pg-boss, consumed by one worker that claims a durable per-day
reaction marker and fans out to the reaction surfaces.

The recency classifier runs at the seam, before anything is enqueued, so a
mass backfill — a multi-year Apple Health export, a provider re-sync, a
30-day catch-up — emits nothing at all. Non-salient writes are annotated and
dropped.

The worker makes no provider call by construction: it imports no provider
module, writes only markers, and enqueues nothing that is not budget-gated
downstream. Business refusals return a status rather than throwing, so a
refusal is never retried against a ceiling that will not move until the local
day rolls over.

Seams: sleep (one hook, inherited by all seven transports), workouts (the
batch route, the four provider syncs, the export importer), weight and blood
pressure (both measurement routes), labs (manual, OCR commit, document fact
commit).

The provider workout syncs could not previously tell a created row from an
updated one, so a re-sync of a recent session would have re-fired on every
poll; they now recover the distinction from the row the upsert already
returns.

Reaction lines are AES-256-GCM at rest, following the InsightNarrative
precedent, and the column is registered for key rotation. Markers are pruned
after 14 days by a daily maintenance job.
2026-07-19 13:17:34 +02:00

6510 lines
340 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
// OIDC SSO — the (issuer, sub) pair pins the account to exactly one IdP
// identity. Stamped on first OIDC login (auto-provision or one-time link
// by verified email); every later OIDC login matches on this pair, never
// on the email, so an IdP-side email change can update the display email
// but can never re-point the login at a different account.
oidcIssuer String? @map("oidc_issuer")
oidcSub String? @map("oidc_sub")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Profile
heightCm Float? @map("height_cm")
dateOfBirth DateTime? @map("date_of_birth")
gender String? @map("gender") // "MALE" | "FEMALE" | "OTHER" | null = keine Angabe
timezone String @default("Europe/Berlin")
// v1.25 (W-ENV) — opt-in environmental-context module home location. A coarse,
// user-set city (rounded lat/lon, ~city granularity — never a GPS track) the
// nightly weather fetch resolves against when no travel override covers the
// day. City-level granularity is the privacy floor: the weather model only
// needs ~10–25 km. All nullable — an account without the module enabled (or
// without a home set) simply has no environment rows. `homeTimezone` anchors
// the day-key an observation is stored under (it can differ from the display
// `timezone` above for an account that tracks weather for another locale).
homeLat Float? @map("home_lat")
homeLon Float? @map("home_lon")
homeLabel String? @map("home_label")
homeTimezone String? @map("home_timezone")
// v1.25 — effective-from instant for the home location. Stamped the moment a
// home is set/updated. Day resolution attributes a past day to home ONLY when
// the day is on/after this instant; earlier days are skipped (never fabricated
// from the current home). To fill the deep past the user adds an explicit
// dated location period instead. Backfill defaults its start to this instant.
homeSince DateTime? @map("home_since")
// Codex OAuth (ChatGPT subscription)
codexAccessTokenEncrypted String? @map("codex_access_token_encrypted")
codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted")
codexTokenExpiresAt DateTime? @map("codex_token_expires_at")
codexConnectedAt DateTime? @map("codex_connected_at")
codexConnectionStatus String @default("disconnected") @map("codex_connection_status")
// Per-user opt-in to the operator's shared central Codex (ChatGPT
// subscription) connection stored on `AppSettings.adminCodex*`. OFF by
// default; only takes effect when the operator has actually connected the
// central Codex. Routing through it is external egress on a shared,
// train-by-default account bound by the operator's rate limits — consent-gated
// exactly like any other server-managed egress.
useCentralCodex Boolean @default(false) @map("use_central_codex")
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")
// Morning-freshness marker: the user-local YYYY-MM-DD (their profile
// timezone) for which the event-driven morning refresh has run — i.e. last
// night's sleep has arrived AND the sleep-dependent insight/score cache was
// regenerated with it. The daily digest reads `final` when this equals the
// user's current local date, `provisional` otherwise. Stamped by the
// `morning-digest-refresh` job the sleep-arrival trigger enqueues; the
// string form makes the "same local morning" comparison timezone-honest
// without a second timestamp read.
morningDigestRefreshedOn String? @map("morning_digest_refreshed_on")
// 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.27.0 — Google Health API OAuth credentials (per-user BYO-keys, encrypted
// at app level). A separate provider from the classic Fitbit Web API columns
// above: the CASA-free "Testing" consent path caps a single OAuth client at
// ≤100 test users, so each self-hoster registers their own Google Cloud OAuth
// client and pastes the client id/secret into Settings.
googleHealthClientIdEncrypted String? @map("google_health_client_id_encrypted")
googleHealthClientSecretEncrypted String? @map("google_health_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")
// v1.28.x — Strava OAuth credentials (per-user BYO-app, encrypted at app
// level). Strava caps every newly-created API app at single-player mode
// (athlete capacity 1), so a single shared app cannot serve many
// self-hosters — each registers their own Strava app and pastes the client
// id/secret into Settings, mirroring the WHOOP/Oura/Polar posture. Strava
// rotates its refresh token on EVERY refresh, so both tokens are persisted
// (Oura's reactive-refresh + compare-and-set model, no token-expiry column —
// the sync refreshes reactively on a 401).
stravaClientIdEncrypted String? @map("strava_client_id_encrypted")
stravaClientSecretEncrypted String? @map("strava_client_secret_encrypted")
stravaAccessTokenEncrypted String? @map("strava_access_token_encrypted")
stravaRefreshTokenEncrypted String? @map("strava_refresh_token_encrypted")
// Strava's numeric athlete id (plaintext opaque id): pins the granted
// account so a cross-account re-auth can be rejected, and is the fan-out key
// if webhooks are added later. Mirrors polarUserId / fitbitUserId intent.
stravaAthleteId String? @map("strava_athlete_id")
// Incremental-sync cursor: the start instant of the newest activity already
// imported. The poll walks `/athlete/activities?after=<epoch>` from here.
stravaLastActivityAt DateTime? @map("strava_last_activity_at")
// First-connect full-history backfill marker (self-converging discovery
// predicate `strava_backfill_completed_at IS NULL`), mirroring WHOOP/Fitbit.
stravaBackfillCompletedAt DateTime? @map("strava_backfill_completed_at")
// 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")
// Automatic AI reading of uploaded vault documents. OFF by default: the vault
// stays local-first — a newly stored document is only read externally when the
// user explicitly taps a per-document AI action (which still needs an active
// consent receipt). When `true`, the auto-index job may read a freshly
// uploaded document through the user's configured external provider with no
// per-document tap: the toggle IS the standing consent (the write that flips
// it on also mints an `ai_full` receipt for the audit trail). Mirrors
// `labsLocalOcrEnabled`; all AI provider config is per-user, so the operator
// flips it for their own account. Default `false` is the entire privacy
// contract for every other self-hoster.
documentsAutoAiRead Boolean @default(false) @map("documents_auto_ai_read")
// 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")
// Document vault — per-user storage-quota override in bytes. NULL = the
// instance default (`AppSettings.documentQuotaBytes`) applies. Admin-set
// only (the admin user PUT); resolved by
// `resolveDocumentLimits()` in `src/lib/documents/upload-policy.ts`.
documentQuotaBytes BigInt? @map("document_quota_bytes")
// 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[]
// v1.30.x — one-time native OIDC SSO handoff codes. Each row bridges the
// browser-context OIDC callback to the cookie-less native token exchange.
oidcNativeHandoffs OidcNativeHandoff[]
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[]
// v1.27.0 — Google Health API integration (separate provider from the classic
// Fitbit Web API above). Mirrors the connection (1:1) + OAuth-state ledger
// (1:N) relations.
googleHealthConnection GoogleHealthConnection?
googleHealthOAuthStates GoogleHealthOAuthState[]
moodEntries MoodEntry[]
mentalHealthAssessments MentalHealthAssessment[]
moodTagsCustom MoodTag[] @relation("UserMoodTags")
moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
moodTagsHidden MoodTagHidden[]
notificationChannels NotificationChannel[]
pushSubscriptions PushSubscription[]
dataBackups DataBackup[]
achievements UserAchievement[]
recommendationFeedback RecommendationFeedback[]
idempotencyKeys IdempotencyKey[]
devices Device[]
integrationStatuses IntegrationStatus[]
coachConversations CoachConversation[]
coachFacts CoachFact[]
coachPlans CoachPlan[]
coachReminders CoachReminder[]
coachUsage CoachUsage[]
// v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings
// getworkouts / future Strava). Schema lands now so the v1.5 iOS
// session can build against a stable DTO. Routes hang off Workout
// (1:1), not User — the User → WorkoutRoute path is two hops by
// design.
workouts Workout[]
// v1.4.25 W8d — personal records (schema only; detection worker
// lands in a later release). The GET /api/personal-records route
// ships with v1.4.25 so the iOS app can build its query path
// against a stable contract.
personalRecords PersonalRecord[]
// Today rail dismiss ledger — observational PriorityItem kinds only.
dismissedPriorityItems DismissedPriorityItem[]
// 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.31.0 — the data-arrival spine's durable per-day reaction marker. One
// row per (kind, local day); the unique index IS the once-per-kind-per-day
// claim every reaction surface reads. Retained 14 days.
arrivalReactions ArrivalReaction[]
// v1.17.1 — preventive-care / measurement (Vorsorge) reminders. One row
// per user-created reminder ("BP every 7 days", "annual blood panel").
// Server computes the canonical `nextDueAt`; the cron fires through the
// existing notification dispatcher. Soft-deleted.
measurementReminders MeasurementReminder[]
// v1.17.1 — minimal structured lab store (LDL, HbA1c, ferritin, TSH, …)
// that pairs with the Vorsorge "annual blood panel" reminder so a result
// has somewhere to land. Soft-deleted.
labResults LabResult[]
// v1.19.0 — user-scoped biomarker catalog (canonical name/unit/range per
// marker); LabResult rows link to a Biomarker for the resolved range/unit.
biomarkers Biomarker[]
// v1.25.5 — user-defined custom metrics. A separate generic store
// (catalog + values), fully isolated from the closed MeasurementType
// system: not synced, not in FHIR, not in AI insights — log + chart only.
customMetrics CustomMetric[]
customMetricEntries CustomMetricEntry[]
// v1.19.0 — illness / condition journal (born-gated by the module system).
// IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the
// per-day timeline mirroring the CycleDayLog shape.
illnessEpisodes IllnessEpisode[]
illnessDayLogs IllnessDayLog[]
// v1.25 (W-RECORDS) — structured health-record entries. Allergies map to
// FHIR AllergyIntolerance; family-history entries map to FHIR
// FamilyMemberHistory. Reference records (not time-series signals); the
// sensitive free-text fields are encrypted at rest.
allergies Allergy[]
familyHistory FamilyHistoryEntry[]
// v1.25 (W-ENV) — opt-in environmental-context module. `EnvironmentContext`
// is the per-day weather/daylight observation keyed to the resolved location;
// `EnvironmentTravelLocation` holds the manual date-range travel overrides
// that win over the home location for the days they cover.
environmentContexts EnvironmentContext[]
environmentTravelLocations EnvironmentTravelLocation[]
// v1.28 — micronutrient intake daily totals synced from Apple Health
// (vitamins, minerals, water, caffeine). Opt-in `nutrients` module, off
// by default; the ingest route refuses while the module is off.
nutrientIntakeDays NutrientIntakeDay[]
// v1.25 (W-DOCS-IN) — inbound clinical documents (doctor reports /
// discharge letters) the user uploads. The raw document is encrypted at
// rest; extracted facts land in a review-then-confirm staging area
// (ExtractedFact) and only reach the structured stores on explicit
// approval. Opt-in module, off by default.
inboundDocuments InboundDocument[]
extractedFacts ExtractedFact[]
documentConditionLinks DocumentConditionLink[]
documentContentIndexes DocumentContentIndex[]
documentThumbnails DocumentThumbnail[]
// 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")
// One account per IdP identity — the (issuer, sub) pair is the durable
// OIDC key (both NULL on accounts that never signed in via SSO; Postgres
// unique indexes ignore NULL pairs).
@@unique([oidcIssuer, oidcSub])
@@map("users")
}
// ─── v1.25 (W-ENV) Environmental-context module ──────────────────────────────
/// How the location for an environment observation was resolved. HOME = the
/// user's configured home location; TRAVEL = an explicit dated location period
/// (a past trip OR a corrected stretch of history) that overrides home for the
/// days it covers; DEVICE = a future client-supplied coarse per-day location.
/// DEVICE is the priority forward path for the iOS v2 client: the value exists
/// so the resolver and the wire contract already accept it, but nothing writes
/// it server-side yet.
enum EnvironmentLocationSource {
HOME
TRAVEL
DEVICE
}
/// A per-day environmental observation (daily weather / daylight) for the
/// location resolved for that day. Day-keyed exactly like the rollup tier:
/// `date` is a YYYY-MM-DD string anchored to the location's IANA timezone, so a
/// late-evening reading does not mis-bucket under UTC. One row per (user, day);
/// a re-fetch upserts (the archive feed settles over a few days). All weather
/// fields are nullable so a partial feed still stores what it has. The location
/// stored here is coarse (city granularity) — never a GPS track.
model EnvironmentContext {
id String @id @default(cuid())
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// YYYY-MM-DD anchored to the resolved location's timezone.
date String
/// Coarse resolved location for the day (rounded lat/lon + label + the source
/// that won precedence).
lat Float
lon Float
locationLabel String @map("location_label")
source EnvironmentLocationSource
/// Daily temperature (°C). Min doubles as the overnight-low proxy.
tempMin Float? @map("temp_min")
tempMax Float? @map("temp_max")
tempMean Float? @map("temp_mean")
/// Daily apparent ("feels-like") mean (°C).
apparentMean Float? @map("apparent_mean")
/// Sunshine + daylight, stored in seconds (the feed's native unit).
sunshineSec Int? @map("sunshine_sec")
daylightSec Int? @map("daylight_sec")
/// Precipitation total (mm) over the local day.
precipSum Float? @map("precip_sum")
/// Pressure aggregated from the hourly feed: daily mean (hPa) + the intraday
/// delta (max−min, hPa) — the derived feature for the headache/symptom overlay.
pressureMean Float? @map("pressure_mean")
pressureDelta Float? @map("pressure_delta")
/// Relative humidity mean (%) and cloud-cover mean (%) from the hourly feed.
humidityMean Float? @map("humidity_mean")
cloudMean Float? @map("cloud_mean")
/// WMO weather code for the day (descriptive condition lookup).
weatherCode Int? @map("weather_code")
/// When the row was last fetched/refreshed from the upstream feed.
fetchedAt DateTime @default(now()) @map("fetched_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([userId, date])
@@index([userId, date])
@@map("environment_contexts")
}
/// An explicit dated LOCATION PERIOD: for any day in `[startDate, endDate]` the
/// weather fetch resolves against this location instead of the home location.
/// Declared, not inferred — it dominates the home fallback so a stretch spent
/// elsewhere is not mis-attributed to the home city. Two uses share this one
/// model: a discrete trip away from home, AND the mechanism to correct history
/// (e.g. a whole-history home city for the years before the current home became
/// effective). Coarse location only. (Stored under `source = TRAVEL`.)
model EnvironmentTravelLocation {
id String @id @default(cuid())
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// Inclusive YYYY-MM-DD range (anchored to the user's home timezone for the
/// day-key the override matches against).
startDate String @map("start_date")
endDate String @map("end_date")
lat Float
lon Float
label String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([userId, startDate, endDate])
@@map("environment_travel_locations")
}
model Passkey {
id String @id @default(cuid())
userId String @map("user_id")
name String @default("Passkey")
credentialId String @unique @map("credential_id")
credentialPublicKey Bytes @map("credential_public_key")
counter BigInt @default(0)
credentialDeviceType String @map("credential_device_type")
credentialBackedUp Boolean @default(false) @map("credential_backed_up")
transports String[] // e.g. ["usb", "ble", "nfc", "internal"]
createdAt DateTime @default(now()) @map("created_at")
// v1.23 — stamped each time the credential's counter is bumped on a
// successful assertion, so the management UI can surface "last used".
lastUsedAt DateTime? @map("last_used_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("passkeys")
}
model Session {
id String @id @default(cuid())
userId String @map("user_id")
// v1.30.32 — HMAC-SHA256 of the session cookie's secret. The cookie carries
// a 32-byte CSPRNG token (`hls_<hex>`), never this row's cuid: a cuid is
// collision-resistant, not unguessable (embedded timestamp + counter), and a
// session identifier is a bearer credential. Hash-at-rest mirrors the
// ApiToken / RefreshToken posture — a database read yields no usable cookie.
//
// Nullable for the transition only: rows minted before this column existed
// carry NULL and are still resolvable by id until they expire. See the
// resolution comment in `src/lib/auth/session.ts`.
tokenHash String? @unique @map("token_hash")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
// v1.23 — step-up freshness stamp. Set when the session completes a second
// factor (or re-confirms it); `requireFreshMfa` (a later wave) reads it.
mfaVerifiedAt DateTime? @map("mfa_verified_at")
// v1.23 — sliding "last seen" for the user-facing active-session list.
lastActiveAt DateTime? @map("last_active_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
// v1.18.7 — the sliding-30-day session reaper sweeps on `expiresAt`; without
// this index the cleanup degrades to a sequential scan on a busy instance.
@@index([expiresAt])
@@map("sessions")
}
// ─── v1.23 second-factor (MFA) substrate ───────────────────
// These tables back the TOTP / WebAuthn-second-factor / recovery-code flows
// that later waves build on. Phase F lands the empty stores only.
// Single-use step-up ticket minted after a valid password when the account
// has a second factor enabled. The partial "password OK, awaiting factor 2"
// state lives in this row — never in a half-built Session — so no session or
// token is issued before the second factor passes. The ticket is presented as
// an opaque value; only its hash is stored.
model MfaChallenge {
id String @id @default(cuid())
userId String @map("user_id")
// Which factor the ticket is being exercised against ("totp" | "webauthn" |
// "recovery_code"). A free-form column; the verify route validates it.
kind String
ticketHash String @unique @map("ticket_hash") // hashToken(ticket)
attempts Int @default(0)
expiresAt DateTime @map("expires_at")
consumedAt DateTime? @map("consumed_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
// The verify path and the expiry sweep both filter on `expiresAt`.
@@index([expiresAt])
@@map("mfa_challenges")
}
// One row per single-use recovery code. The code is Argon2id-hashed at rest
// (same primitive as the password); `usedAt` burns it. Regeneration deletes the
// prior set in a transaction.
model MfaRecoveryCode {
id String @id @default(cuid())
userId String @map("user_id")
codeHash String @map("code_hash") // Argon2id
usedAt DateTime? @map("used_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("mfa_recovery_codes")
}
// Second-factor WebAuthn credential (typically a roaming security key), kept
// SEPARATE from `Passkey`: a `Passkey` row is a passwordless-primary login
// credential, whereas an MFA credential only ever runs after the password has
// identified the account. Mirrors the `Passkey` field encoding.
model WebauthnMfaCredential {
id String @id @default(cuid())
userId String @map("user_id")
name String @default("Security key")
credentialId String @unique @map("credential_id")
credentialPublicKey Bytes @map("credential_public_key")
counter BigInt @default(0)
transports String[] // e.g. ["usb", "ble", "nfc", "internal"]
createdAt DateTime @default(now()) @map("created_at")
lastUsedAt DateTime? @map("last_used_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("webauthn_mfa_credentials")
}
// v1.23 — login-alert dedupe ledger. One row per (user, coarse device
// fingerprint). The fingerprint is a one-way SHA-256 hash of
// `userId | normalised-User-Agent | coarse-location-or-asn` — never the raw
// User-Agent or IP, so the table holds no directly identifying request
// metadata (`deviceHash` is salted by the userId, so the same browser on two
// accounts yields two unrelated hashes). `label` is a coarse, human-readable
// descriptor for the activity surface (e.g. "Firefox on macOS — Berlin, DE");
// it intentionally omits the IP. First sighting of a hash fires one new-device
// notification through the dispatcher; later sightings only bump `lastSeenAt`.
model UserKnownDevice {
id String @id @default(cuid())
userId String @map("user_id")
deviceHash String @map("device_hash")
label String?
firstSeenAt DateTime @default(now()) @map("first_seen_at")
lastSeenAt DateTime @default(now()) @map("last_seen_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Dedupe is per-user: the same coarse fingerprint can never be inserted
// twice for one account, so the "first sighting → alert" decision is a
// single unique-constrained upsert.
@@unique([userId, deviceHash])
@@index([userId])
@@map("user_known_devices")
}
// v1.23 — "remember this device" trusted-device token.
//
// At the second-factor step the user may opt in to trusting the browser for 30
// days. On opt-in a random token is minted, set in an httpOnly+Secure cookie,
// and only its HMAC hash (`tokenHash`, keyed by `API_TOKEN_HMAC_KEY`) is stored
// here — a leaked row cannot reconstruct a usable cookie. On a later sign-in
// from the same browser the password is still required (factor 1); a matching,
// unexpired trusted-device row lets the login skip factor 2 only. The token is
// NEVER accepted for step-up (`requireFreshMfa`) — a stolen cookie cannot reach
// a destructive action. Revocable from the device list and revoked wholesale
// when the second factor is removed or the user signs out everywhere.
model TrustedDevice {
id String @id @default(cuid())
userId String @map("user_id")
tokenHash String @unique @map("token_hash")
label String?
createdAt DateTime @default(now()) @map("created_at")
expiresAt DateTime @map("expires_at")
lastUsedAt DateTime @default(now()) @map("last_used_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
// The reaper for expired trusted devices sweeps on `expiresAt`.
@@index([expiresAt])
@@map("trusted_devices")
}
model AuthChallenge {
id String @id @default(cuid())
userId String? @map("user_id") // null for registration
challenge String
type String // "registration" | "authentication"
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
// v1.18.7 — challenges are swept by expiry; index the column the reaper
// filters on so the cleanup stays index-backed.
@@index([expiresAt])
@@map("auth_challenges")
}
// ─── Measurements ──────────────────────────────────────────
enum MeasurementType {
WEIGHT
BLOOD_PRESSURE_SYS
BLOOD_PRESSURE_DIA
PULSE
BODY_FAT
SLEEP_DURATION // v1.4.23: unit semantics shifted from hours to minutes so HealthKit category samples (per-stage rows) survive without precision loss.
ACTIVITY_STEPS
BLOOD_GLUCOSE // Canonical storage: mg/dL. Display unit (mg/dL or mmol/L) is user preference.
TOTAL_BODY_WATER // Canonical storage: kilograms (kg of water). Matches Withings' hydration measure (type 77) and Health Connect's TotalBodyWaterRecord. Display can compute % from same-day weight if desired.
BONE_MASS // Canonical storage: kilograms (kg). Matches Withings' bone mass measure (type 88).
OXYGEN_SATURATION // Canonical storage: percent (0..100). Withings ScanWatch reports type 54. Health Connect's OxygenSaturationRecord uses Percentage (already 0..100). HealthKit uses HKQuantityTypeIdentifierOxygenSaturation as a fraction (0..1) — iOS DTO must multiply by 100 before submitting.
// ── v1.4.23 Apple Health additions ──
HEART_RATE_VARIABILITY // ms — HealthKit `heartRateVariabilitySDNN` (typically one sample per night).
RESTING_HEART_RATE // bpm — HealthKit `restingHeartRate` (one sample per day; distinct from spot PULSE).
ACTIVE_ENERGY_BURNED // kcal — HealthKit `activeEnergyBurned`, cumulative; daily-rollup pattern.
FLIGHTS_CLIMBED // count — HealthKit `flightsClimbed`, cumulative.
WALKING_RUNNING_DISTANCE // metres — HealthKit `distanceWalkingRunning`; SI metres in DB, UI converts per locale.
VO2_MAX // mL/(kg·min) — HealthKit `vo2Max`; one sample every few days at most.
BODY_TEMPERATURE // celsius — HealthKit `bodyTemperature`; iOS DTO already encodes this enum value.
// ── v1.4.25 W5d Withings full coverage ──
FAT_FREE_MASS // kg — Withings meastype 5 (Body+, Body Cardio, Body Comp, Body Scan). Algorithmically = weight − fat mass.
FAT_MASS // kg — Withings meastype 8. Mass form of BODY_FAT (kg, not %).
MUSCLE_MASS // kg — Withings meastype 76. Body+ family.
SKIN_TEMPERATURE // celsius — Withings meastype 73 (ScanWatch dermal reading). DISTINCT from BODY_TEMPERATURE — surface temps run ~32 °C, core ~37 °C; sharing a bucket would corrupt analytics.
PULSE_WAVE_VELOCITY // m/s — Withings meastype 91 (Body Cardio / Body Scan). Indicator of arterial stiffness.
VASCULAR_AGE // years — Withings meastype 155 (Body Scan). Composite of PWV + age.
VISCERAL_FAT // rating — Withings meastype 170 (Body Comp / Body Scan). Withings' rating scale (1–12); not directly comparable to a percent.
// ── v1.4.25 W8d Apple Health server-prep ──
AUDIO_EXPOSURE_ENV // dBA — HealthKit `environmentalAudioExposure`; ambient noise sample (e.g. concert, traffic). Watch + iPhone microphone.
AUDIO_EXPOSURE_HEADPHONE // dBA — HealthKit `headphoneAudioExposure`; AirPods listening-volume sampling.
TIME_IN_DAYLIGHT // minutes — HealthKit `timeInDaylight` (iOS 17+). Sun-exposure daily rollup; mood + sleep correlate.
// ── v1.4.30 R-F T1.4 + T1.5 Tier-1 additions ──
WALKING_STEADINESS // percent (0-100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `appleWalkingSteadiness` (iOS 15+). Daily rollup. Falls + mobility-risk signal Apple's own Health app shows prominently in the Mobility section.
AUDIO_EXPOSURE_EVENT // count (event flag: 1 per fired loud-listening event) — HealthKit `environmentalAudioExposureEvent` + `headphoneAudioExposureEvent` category-events. Apple fires these when the rolling 7-day average crosses the WHO 80-dBA threshold. The notes field carries the source identifier (env vs headphone).
// ── v1.5.5 iOS-coord additions — six identifiers the deferred list refused to map. ──
// Each one was emitted by the iOS app, dropped server-side with a
// per-entry `skipped:"unmappable_identifier"`, and ack-200'd to the
// client. iOS read 200 as success, advanced its sync anchor, and the
// samples were lost forever. Now mapped end-to-end.
RESPIRATORY_RATE // count/min — HealthKit `respiratoryRate`. Watch + iPhone sleep + workout sampling.
BODY_MASS_INDEX // kg/m² — HealthKit `bodyMassIndex`. iOS computes it from weight + height before upload; we still store it as a first-class metric for trend display.
LEAN_BODY_MASS // kg — HealthKit `leanBodyMass`. Body-composition counterpart to FAT_MASS; pairs with WEIGHT − FAT_MASS for sanity checks.
WALKING_HEART_RATE_AVERAGE // bpm — HealthKit `walkingHeartRateAverage`. Daily rollup; cardio-fitness signal distinct from RESTING_HEART_RATE and spot PULSE.
WALKING_ASYMMETRY // percent (0-100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `walkingAsymmetryPercentage`. Mobility section sibling to WALKING_STEADINESS.
WALKING_DOUBLE_SUPPORT // percent (0-100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `walkingDoubleSupportPercentage`. Mobility section sibling.
// ── v1.5.5 iOS-coord follow-up — gait raw-SI pair ──
// Both identifiers flow over the wire in raw SI units (no ×100
// scaling). The percent gait metrics above use the server-side
// scaling path because Apple ships them as 0..1 fractions; the two
// below are already in metres and metres-per-second respectively
// and pass through `convertToDbUnit` as identity.
WALKING_STEP_LENGTH // metres — HealthKit `walkingStepLength`. Mobility section gait metric; adult walking typically 0.5–0.8 m.
WALKING_SPEED // metres per second — HealthKit `walkingSpeed`. Mobility section gait metric; healthy adult casual gait ≈ 1.2–1.4 m/s.
// v1.10.0 — additive HealthKit signals (WX-A)
// Seven previously-deferred quantity identifiers, wired end-to-end with
// the generic metric-status assessment treatment. Each rides the same
// server-side `convertToDbUnit` + plausibility-range path as the rest of
// the HealthKit surface; see `src/lib/measurements/apple-health-mapping.ts`.
CARDIO_RECOVERY // bpm — HealthKit `heartRateRecoveryOneMinute` (iOS 16+). The drop in heart rate one minute after peak exercise; a larger drop signals fitter autonomic recovery (higher-better).
WRIST_TEMPERATURE // celsius — HealthKit `appleSleepingWristTemperature` (iOS 16+). Overnight wrist temperature; Apple surfaces it as a baseline deviation, we store the absolute reading. DISTINCT from BODY_TEMPERATURE (core) and SKIN_TEMPERATURE (Withings dermal spot).
FALL_COUNT // count — HealthKit `numberOfTimesFallen`. Hard-fall detections; cumulative daily count, lower-better.
SIX_MINUTE_WALK_DISTANCE // metres — HealthKit `sixMinuteWalkTestDistance`. Apple's estimated 6-minute-walk-test distance; mobility + cardiopulmonary endurance signal, higher-better.
STAIR_ASCENT_SPEED // metres per second — HealthKit `stairAscentSpeed`. Mobility section gait metric measured while climbing stairs, higher-better.
STAIR_DESCENT_SPEED // metres per second — HealthKit `stairDescentSpeed`. Mobility section gait metric measured while descending stairs, higher-better.
BREATHING_DISTURBANCES // count — HealthKit `appleSleepingBreathingDisturbances` (iOS 18+). Per-night sleep-breathing-disturbance index Apple classifies as NotElevated / Elevated; lower-better.
// v1.10.0 — categorical events (WX-B)
// Device-flagged EVENT rows. These are NOT continuous measurements —
// each row records a discrete on-device notification the user's wearable
// produced (Apple Watch / Withings ScanWatch). `value` is always 1 (one
// fired event); the device's verdict / severity lands in the
// `rhythmClassification` column (see the `RhythmClassification` enum and
// the CHECK constraint in migration 0105). HealthLog never re-derives or
// re-classifies the underlying signal — it only stores and surfaces the
// result the device's own certified algorithm already emitted.
IRREGULAR_RHYTHM_NOTIFICATION // HealthKit `HKCategoryTypeIdentifierIrregularHeartRhythmEvent` + ScanWatch AFib screening. The certified on-device algorithm flagged a possible irregular rhythm. AWARENESS ONLY — never a HealthLog diagnosis.
HIGH_HEART_RATE_EVENT // HealthKit `HKCategoryTypeIdentifierHighHeartRateEvent`. The watch detected a sustained high heart rate above the user's configured threshold while apparently inactive.
LOW_HEART_RATE_EVENT // HealthKit `HKCategoryTypeIdentifierLowHeartRateEvent`. The watch detected a sustained low heart rate below the user's configured threshold.
WALKING_STEADINESS_EVENT // HealthKit `HKCategoryTypeIdentifierAppleWalkingSteadinessEvent`. The device flagged low / very-low walking steadiness (a falls-risk mobility signal); the severity rides in `rhythmClassification`.
BREATHING_DISTURBANCE_EVENT // HealthKit `HKCategoryTypeIdentifierSleepApneaEvent` + `AppleSleepingBreathingDisturbances`. The device flagged an elevated breathing-disturbance / possible sleep-apnea signal during sleep. SCREENING SIGNAL ONLY — never a HealthLog diagnosis.
// v1.10.0 — computed scores (WX-C)
// Server-derived wellness scores (0–100, unit `score`). Each is minted by a
// nightly engine from the user's already-stored signals and persisted as a
// `COMPUTED`-source Measurement row — never ingested from a client. Only
// RECOVERY_SCORE is computed in v1.10.0; STRESS_SCORE + STRAIN_SCORE are
// defined now so the later engines that compute them need no schema change.
// These are NOT clinical vitals — they never appear in the doctor-report PDF.
RECOVERY_SCORE // Daily recovery / readiness proxy. Higher = more recovered. Blends RHR + HRV (SDNN) + sleep + respiratory-rate deviation from the personal baseline (Plews 2013 / Buchheit 2014 lineage). Descriptive, not a clinical or training-recovery assessment. v1.11.0: WHOOP ships a device-native Recovery into this SAME type, distinguished by `source = WHOOP` vs `COMPUTED` — the two unique keys keep both rows; the cross-source picker decides display.
STRESS_SCORE // Reserved for the later strain/stress engine. Higher = more physiological stress. No engine computes it in v1.10.0.
STRAIN_SCORE // Reserved for the later strain/stress engine. Higher = more cardiovascular strain (workout-load lineage). No engine computes it in v1.10.0. WHOOP day-strain does NOT reuse this — it lands in DAY_STRAIN so the WHOOP-native value and HealthLog's TRIMP proxy never share a bucket.
// ── v1.11.0 — WHOOP integration (additive) ──
// Native WHOOP scores ingest as first-class `source = WHOOP` Measurement
// rows. Where the concept already exists as a COMPUTED type (RECOVERY_SCORE)
// the WHOOP row reuses that type and is distinguished by source; where it
// would collide with a HealthLog engine (STRAIN_SCORE) or a different
// physiological measure (SDNN HEART_RATE_VARIABILITY) it gets a NEW type so
// the native value and the derived proxy never share a bucket. See
// `.planning/v1.11-build/epic-A-whoop-buildspec.md` §3.2 / §5.
HRV_RMSSD // ms — WHOOP `recovery.score.hrv_rmssd_milli`. The RMSSD HRV metric WHOOP reports; kept structurally distinct from the SDNN HEART_RATE_VARIABILITY (different estimator, not interchangeable).
DAY_STRAIN // score (0–21) — WHOOP `cycle.score.strain`. The day's cardiovascular load on WHOOP's 0–21 scale. Distinct from the COMPUTED STRAIN_SCORE (0–100 TRIMP proxy) so the two never share a bucket.
WORKOUT_STRAIN // score (0–21) — WHOOP `workout.score.strain`. Per-workout strain; preferentially stored in `Workout.metadata` (tied to the workout row) rather than as a free-floating Measurement, but the type exists for the rare detached case.
SLEEP_PERFORMANCE // % (0–100) — WHOOP `sleep.score.sleep_performance_percentage`. Sleep achieved vs sleep needed.
SLEEP_EFFICIENCY // % (0–100) — WHOOP `sleep.score.sleep_efficiency_percentage`. Time asleep vs time in bed.
SLEEP_CONSISTENCY // % (0–100) — WHOOP `sleep.score.sleep_consistency_percentage`. Night-to-night bed/wake timing regularity.
SLEEP_NEED // minutes — WHOOP `sleep.score.sleep_needed.baseline_milli` (+ debt / strain / nap components; ms→min). The model's recommended sleep duration for the night.
ENERGY_EXPENDITURE_KJ // kJ — WHOOP `cycle.score.kilojoule`. Day energy expenditure in kilojoules (WHOOP reports kJ natively; kept in kJ rather than converted to kcal so the device-native value round-trips).
// ── v1.12.8 — WHOOP cycle + sleep coverage completion (additive) ──
// Three previously-uncaptured WHOOP fields. AVERAGE_HEART_RATE / MAX_HEART_RATE
// ride the cycle score (already fetched, previously dropped); kept distinct
// from spot PULSE / RESTING_HEART_RATE / WALKING_HEART_RATE_AVERAGE because
// they are the day's whole-cycle aggregates. SLEEP_DISTURBANCE_COUNT is the
// per-night `stage_summary.disturbance_count`.
AVERAGE_HEART_RATE // bpm — WHOOP `cycle.score.average_heart_rate`. The day's whole-cycle average heart rate.
MAX_HEART_RATE // bpm — WHOOP `cycle.score.max_heart_rate`. The day's whole-cycle peak heart rate.
SLEEP_DISTURBANCE_COUNT // count — WHOOP `sleep.score.stage_summary.disturbance_count`. Per-night tally of sleep disturbances.
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro coverage (additive) ──
// Two native Polar signals with no existing home. ANS_CHARGE is the HRV-based
// autonomic-nervous-system charge component of Nightly Recharge — Polar's most
// differentiated recovery signal, distinct from the 1–6 recovery band that
// already maps to RECOVERY_SCORE. CARDIO_LOAD is Polar's Training Load Pro
// cardiovascular-strain figure (its WHOOP-day-strain analogue); kept distinct
// from DAY_STRAIN (WHOOP's 0–21 scale) and the COMPUTED STRAIN_SCORE proxy so
// each device-native value keeps its own bucket and native scale.
ANS_CHARGE // score — Polar Nightly Recharge `ans_charge`. The HRV-based autonomic-nervous-system charge (typically ~-10..+10 around the personal baseline; higher = more recovered). Descriptive, not a clinical assessment.
CARDIO_LOAD // score — Polar Training Load Pro `cardio_load`. The session/day cardiovascular load (TRIMP-lineage strain). Distinct from WHOOP DAY_STRAIN (0–21) and the COMPUTED STRAIN_SCORE so the device-native value never shares a bucket.
// ── v1.17.1 — Oura coverage completion (additive) ──
// Oura's headline 0–100 Sleep Score from the `daily_sleep` collection
// (distinct from the per-stage `sleep` detail). Stored as `source = OURA`,
// unit `score`. Kept apart from SLEEP_PERFORMANCE / SLEEP_EFFICIENCY (WHOOP
// sub-scores) so two vendors' headline numbers never share a bucket.
SLEEP_SCORE // score (0–100) — Oura `daily_sleep.score`. Per-night sleep quality headline.
// Oura nightly body-temperature deviation from the user's personal baseline
// (`daily_readiness.temperature_deviation`), in °C. NOT an absolute
// temperature — a signed offset (illness / luteal-phase / stress signal),
// so it cannot share the BODY_TEMPERATURE / SKIN_TEMPERATURE / WRIST_TEMPERATURE
// absolute-reading buckets. Plausible band is small and centred on 0.
BODY_TEMPERATURE_DEVIATION // celsius (signed deviation) — Oura `daily_readiness.temperature_deviation`.
// ── v1.19.0 — Oura resilience (additive) ──
// Oura's daily resilience level from the `daily_resilience` collection — a
// categorical band describing how well the body is coping with cumulative
// load: limited / adequate / solid / strong / exceptional. Stored ORDINAL-
// ENCODED into the numeric `value` (limited=1, adequate=2, solid=3, strong=4,
// exceptional=5) so it fits the existing numeric Measurement model with no new
// categorical column or CHECK constraint. Unit is the level scale (`level`).
// The 5-level → ordinal mapping is the single source of truth in
// `src/lib/oura/client.ts` (RESILIENCE_LEVELS). Stored as `source = OURA`.
RESILIENCE // level (ordinal 1–5) — Oura `daily_resilience.level` (limited=1 … exceptional=5).
// ── v1.25 — clinical-signals wave (manual / instrument-derived) ──
// PHQ-9 / GAD-7 total scores. Server-derived from a completed
// `MentalHealthAssessment` (one measurement row per administration, value =
// total). Unit `score`. The raw item answers live encrypted on the
// assessment row, never here — these rows carry only the numeric trend so
// the existing chart / rollup infra reads them with no item exposure.
PHQ9_SCORE // score (0–27) — PHQ-9 depression screen total. Opt-in. See MentalHealthAssessment.
GAD7_SCORE // score (0–21) — GAD-7 anxiety screen total. Opt-in. See MentalHealthAssessment.
// Maximal isometric handgrip force (best of all trials, either hand). The
// EWGSOP2 sarcopenia floor is sex-specific (men < 27 kg, women < 16 kg); the
// band is applied at display time, the column stores the raw kg.
GRIP_STRENGTH // kg — hand-dynamometer maximal grip (EWGSOP2 sarcopenia limb).
// Patient-reported pain intensity on the 0–10 Numeric Rating Scale
// (0 = no pain, 10 = worst imaginable). LOINC 72514-3.
PAIN_NRS // score (0–10) — pain Numeric Rating Scale.
// Waist circumference in cm (WHO STEPS protocol) + the ancestry-robust
// waist-to-height ratio (WHtR; keep waist < half your height ⇒ < 0.5).
WAIST_CIRCUMFERENCE // cm — waist circumference (WHO bands; ethnicity-aware at display).
WAIST_TO_HEIGHT // ratio — waist-to-height ratio (WHtR ≥ 0.5 = increased risk).
// ── v1.27.9 — screening scores (WHO-5 well-being + Sleep Condition Indicator) ──
// Same contract as PHQ9_SCORE / GAD7_SCORE: server-derived from a completed
// `MentalHealthAssessment`, `source = COMPUTED`, one row per administration.
// Note the inverted direction versus PHQ-9 / GAD-7: HIGHER is better for both.
WHO5_SCORE // score (0–100) — WHO-5 well-being percentage (raw 0–25 × 4). Opt-in. See MentalHealthAssessment.
SCI_SCORE // score (0–32) — Sleep Condition Indicator total; higher = better sleep. Opt-in. See MentalHealthAssessment.
@@map("measurement_type")
}
/// v1.25 — validated mental-health self-assessment instruments: PHQ-9
/// (depression) + GAD-7 (anxiety). The discriminator on
/// `MentalHealthAssessment`. v1.27.9 adds the WHO-5 well-being index and the
/// Sleep Condition Indicator (SCI) — same administration contract, inverted
/// score direction (higher = better).
enum AssessmentInstrument {
PHQ9
GAD7
WHO5
SCI
@@map("assessment_instrument")
}
/// v1.25 — one completed administration of a validated mental-health screener
/// (PHQ-9 / GAD-7). Opt-in, beside the existing `MoodEntry` (never replaces it).
/// The per-item answers are the most sensitive payload, so they live in a
/// single AES-256-GCM `Bytes` blob (`responsesEncrypted`) and are never indexed
/// nor logged. The derived total / band / item-9 flag are denormalised
/// (server-authoritative) for cheap history reads without decrypting. A
/// matching `PHQ9_SCORE` / `GAD7_SCORE` Measurement row is written per
/// administration (linked by `externalId = assessment:<id>`, `source =
/// COMPUTED` — server-owned like RECOVERY_SCORE, never client-attributable) so
/// the score rides the existing chart / rollup infra — the trend, never the
/// item content.
model MentalHealthAssessment {
id String @id @default(cuid())
userId String @map("user_id")
instrument AssessmentInstrument
/// The validated translation actually presented (officially-validated wording
/// per locale, English fallback otherwise — recorded for the audit trail).
locale String
/// Instrument variant identifier (e.g. "standard"); future-proofs revisions.
version String @default("standard")
/// Encrypted JSON blob: { items: number[], functionalDifficulty?: 0..3, schema: 1 }.
/// AES-256-GCM via the shared Bytes codec; registered in ENCRYPTED_COLUMNS +
/// the rotation script. The item-9 raw value lives ONLY here.
responsesEncrypted Bytes @map("responses_encrypted")
/// Derived total (PHQ-9 0–27, GAD-7 0–21). Denormalised for fast reads.
totalScore Int @map("total_score")
/// Derived severity band label (server-authoritative; descriptive, never a diagnosis).
severityBand String @map("severity_band")
/// PHQ-9 only: item 9 (self-harm ideation) answered > 0. A coarse boolean so
/// the history view can re-surface crisis resources without decrypting.
item9Flagged Boolean @default(false) @map("item9_flagged")
/// Audit stamp: the supportive crisis-resource signpost was shown to the user.
/// Never triggers any third-party alert — private, on-device-only signposting.
crisisShownAt DateTime? @map("crisis_shown_at")
/// Recall-window anchor (when the screener was taken).
takenAt DateTime @map("taken_at")
tz String?
source String @default("WEB")
externalId String? @map("external_id")
syncVersion Int @default(0) @map("sync_version")
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, source, externalId])
@@index([userId, instrument, takenAt])
@@index([userId, updatedAt, id])
// `mental_health_assessments_user_external_id_unique` is a partial UNIQUE
// index over `(user_id, external_id) WHERE external_id IS NOT NULL` — see
// migration 0221. It deduplicates an outbox replay across sources (a repeat
// client externalId returns the existing administration). Prisma can't express
// a partial index in the schema language, so it lives in raw SQL and is
// intentionally omitted here.
@@map("mental_health_assessments")
}
/// v1.10.0 — categorical events (WX-B)
/// The device's own classification RESULT / severity for an EVENT row.
/// Non-NULL only for the categorical EVENT `MeasurementType`s
/// (IRREGULAR_RHYTHM_NOTIFICATION, HIGH/LOW_HEART_RATE_EVENT,
/// WALKING_STEADINESS_EVENT, BREATHING_DISTURBANCE_EVENT), NULL for every
/// continuous measurement — mirroring the `GlucoseContext` / `SleepStage`
/// nullable-context precedent.
///
/// HealthLog stores ONLY the verdict the device's FDA-cleared / CE-marked
/// on-device algorithm already produced. It never re-classifies a raw
/// signal and never emits a diagnosis of its own. `IRREGULAR` means the
/// device flagged a possible irregular rhythm; `NOT_DETECTED` /
/// `INCONCLUSIVE` carry the device's own non-positive verdicts so the
/// awareness timeline can be honest rather than only ever showing alarms.
/// `LOW` / `VERY_LOW` carry the walking-steadiness severity; `FIRED` is the
/// neutral "this event occurred" verdict for the high/low-HR + breathing
/// events that have no finer device-side grading.
enum RhythmClassification {
IRREGULAR // device flagged a possible irregular rhythm (AFib screening)
NOT_DETECTED // device's algorithm ran and did not flag an irregular rhythm
INCONCLUSIVE // device could not classify (poor signal / out-of-range HR)
LOW // walking-steadiness: low
VERY_LOW // walking-steadiness: very low
FIRED // neutral "event occurred" verdict (high/low-HR, breathing disturbance)
@@map("rhythm_classification")
}
/// Context for glucose measurements — affects which target range applies.
/// NULL for non-glucose measurements.
enum GlucoseContext {
FASTING // ≥8h without food, typically morning
POSTPRANDIAL // 2h after meal
RANDOM // any time, non-fasting, non-post-meal
BEDTIME // before sleep
@@map("glucose_context")
}
// Hour-cycle display preference for rendered times. AUTO defers to the
// active locale's convention; H12/H24 pin the cycle regardless of locale.
enum TimeFormatPreference {
AUTO
H12
H24
@@map("time_format_preference")
}
// Date display preference for rendered dates. AUTO defers to the active
// locale's convention; DMY/MDY/YMD pin the field order regardless of locale.
enum DateFormatPreference {
AUTO
DMY
MDY
YMD
@@map("date_format_preference")
}
enum MeasurementSource {
MANUAL
WITHINGS
IMPORT
APPLE_HEALTH // v1.4.23 — HealthKit batch ingest. iOS DTO mirrors this exact spelling (not "HEALTHKIT").
// v1.10.0 — computed scores (WX-C)
// Server-derived rows. A `COMPUTED` measurement is minted by a nightly
// engine from the user's already-stored signals (the Recovery score is the
// first); it is NEVER ingested from a client. Server-owned / read-only:
// the client-facing batch + single-POST write surfaces reject it exactly
// like WITHINGS / IMPORT, but it is part of the read enum so iOS can decode
// the rows it queries / charts.
COMPUTED
// v1.11.0 — WHOOP integration. Native WHOOP scores ingest server-side
// (no client write path) as `source = WHOOP` rows. iOS DTO mirrors this
// exact spelling so the `/api/sync/changes` delta feed decodes the rows.
WHOOP
// v1.12.0 — Fitbit/Pixel integration via the Google Health API. Server-owned
// (no client write path); rejected on batch + single POST like WITHINGS/WHOOP.
// iOS DTO mirrors this exact spelling so /api/sync/changes decodes the rows.
FITBIT
// v1.17.0 — Nightscout glucose integration (F1). Server-owned (no client
// write path); glucose rows ingest from the user's Nightscout instance.
// Rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS DTO
// mirrors this exact spelling so /api/sync/changes decodes the rows.
NIGHTSCOUT
// v1.17.0 — Polar OAuth integration (F4). Server-owned (no client write
// path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS
// DTO mirrors this exact spelling so /api/sync/changes decodes the rows.
POLAR
// v1.17.0 — Oura OAuth integration (F4). Server-owned (no client write
// path); rejected on batch + single POST like WITHINGS/WHOOP/FITBIT. iOS
// DTO mirrors this exact spelling so /api/sync/changes decodes the rows.
OURA
// v1.19.2 — measurements captured from a numeric reply to a Telegram
// measurement reminder. Unlike the server-owned integrations above, this
// is a user-driven write, but it enters through the webhook (chat-bound)
// rather than the cookie/Bearer client write path, so it carries its own
// source label. The client batch + single-POST surfaces still reject it
// (server-resolved only); iOS DTO mirrors this spelling so the
// /api/sync/changes delta feed decodes the rows.
TELEGRAM
// v1.22.0 — measurements logged through the remote MCP write surface. A
// distinct provenance from MANUAL/TELEGRAM: the row was confirmed and
// written by an external assistant over the `/mcp` wire under a
// `health:write`-scoped token, never the cookie/Bearer client write path.
// Its own value gives the MCP writes an isolated idempotency namespace
// `(userId, type, MCP, externalId)`. The client batch + single-POST
// surfaces still reject it (server-resolved only); iOS DTO mirrors this
// spelling so the /api/sync/changes delta feed decodes the rows.
MCP
// v1.27.0 — Google Health API integration (Fitbit + Pixel Watch + Fitbit
// Air) over `health.googleapis.com/v4`. A separate, coexisting provider
// alongside the classic Fitbit Web API integration (source `FITBIT`) with
// its own connection table and OAuth client. 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.
GOOGLE_HEALTH
// v1.28.x — Strava OAuth integration. Native Strava activities ingest
// server-side as `source = STRAVA` Workout rows (no client write path);
// rejected on the batch + single POST like WITHINGS/WHOOP/FITBIT. iOS DTO
// mirrors this exact spelling so the `/api/sync/changes` delta feed decodes
// the rows. Feeds Workout rows only — Strava exposes no daily metrics.
STRAVA
@@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.
// `measurements_note_backfill_idx` is a partial index over `(user_id)
// WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see migration 0243
// (v1.28.46 H4). It makes the note-encryption boot-discovery `SELECT DISTINCT
// user_id` an index-only scan that converges to empty. Same 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")
}
/// The Today rail's server-side "dismiss / mark seen" ledger — OBSERVATIONAL
/// `PriorityItem` kinds only (`milestone`, `ecg_new_recording`,
/// `tension_window`). The ACTIONABLE kinds (dose_window, sync_issue,
/// preventive_care, coach_checkin) never write a row here — they clear on
/// their own once the user acts, so a dismiss affordance on them would just
/// duplicate the existing resolution path.
///
/// `itemKey` is a deterministic identity for the candidate INSTANCE the
/// digest builder already computes (kind + metric + reach day for a
/// milestone, the recording's own timestamp for an ECG, the local day for a
/// tension window) — not the bare `kind` — so dismissing today's milestone
/// never suppresses tomorrow's; the underlying item's own freshness window
/// (reached-once / 24h / one-per-day) is what actually retires the row's
/// relevance, this table only fast-forwards that for the rest of today.
model DismissedPriorityItem {
id String @id @default(cuid())
userId String @map("user_id")
itemKey String @map("item_key")
dismissedAt DateTime @default(now()) @map("dismissed_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// The dismiss upsert's natural key + the digest read's
/// `userId + itemKey IN (...)` lookup.
@@unique([userId, itemKey])
@@map("dismissed_priority_items")
}
// ─── 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")
/// v1.28 — provenance of a MIRRORED medication. Non-NULL marks this row a
/// read-only mirror of an external medication list (today only
/// APPLE_HEALTH — the iOS 26+ HealthKit Medications sync). Source-
/// exclusive by policy: dose events from that source attach only to
/// medications mirrored from it, and the app's own dose-logging UI is
/// expected to stay off a mirrored medication client-side. NULL = a
/// native HealthLog medication.
externalSource IntakeSource? @map("external_source")
/// v1.28 — stable external identifier of the mirrored medication (the
/// HealthKit `medicationConceptIdentifier`). Paired with `externalSource`
/// (both set or both NULL; the create route enforces the pairing). Drives
/// the idempotent re-mirror on `POST /api/medications` via the
/// `(userId, externalSource, externalId)` unique below.
externalId String? @map("external_id")
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[]
/// v1.28 — user-pinned efficacy target(s) for the "Wirkung" view. Rows
/// exist ONLY when the user explicitly overrides the derived (ATC / name)
/// target resolution; absence means the resolver derives the target each
/// read. Nothing about a class→outcome association is denormalised here.
efficacyTargets MedicationEfficacyTarget[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
/// v1.28 — one mirror row per external medication concept per user
/// (migration 0239). Postgres treats NULLs as distinct in unique
/// indexes, so the vast majority of rows (both columns NULL) are
/// unaffected; only genuinely mirrored medications occupy a slot. A
/// re-mirror of the same concept id returns the existing row (the
/// create route pre-queries the triple and catches the P2002 race).
@@unique([userId, externalSource, externalId])
@@index([userId])
@@index([treatmentClass])
@@map("medications")
}
/// v1.28 — the user's explicit override of a medication's efficacy target,
/// the ONE thing the "Wirkung" view persists. Class→outcome resolution
/// (ATC-prefix → name inference) is computed each read from the closed
/// `med-target-map` table, so nothing drifts as that map improves; the only
/// durable state is the user's own pick when they retarget a medication or
/// choose a target for a med the resolver could not classify. Exactly one of
/// `measurementType` (a first-class metric series) / `biomarkerId` (a lab
/// analyte) is set per row. Cascade-deletes with the medication.
model MedicationEfficacyTarget {
id String @id @default(cuid())
medicationId String @map("medication_id")
/// Metric target — one of the closed `MeasurementType` series. NULL when
/// this row targets a lab analyte instead.
measurementType MeasurementType? @map("measurement_type")
/// Lab target — a user-scoped Biomarker. NULL when this row targets a
/// metric series instead. SetNull on biomarker delete so the row survives
/// as an orphan the resolver then treats as "no override".
biomarkerId String? @map("biomarker_id")
/// The storyline-leading target when several are pinned. Default true.
primary Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull)
@@index([medicationId])
@@index([biomarkerId])
@@map("medication_efficacy_targets")
}
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
/// v1.28 — Apple Health medication sync (iOS 26+ HealthKit Medications
/// API). Spelled exactly like `MeasurementSource.APPLE_HEALTH`; the iOS
/// DTO mirrors this wire spelling. Doubles as the provenance label on
/// `Medication.externalSource` for mirrored medications.
APPLE_HEALTH
@@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.28 — external dose-event identifier (the HealthKit
/// `HKMedicationDoseEvent` UUID). Drives idempotent re-import on the
/// bulk intake route: a re-synced Apple dose dedups against the live
/// `(userId, externalId)` unique below instead of inserting again
/// (first-write-wins per external dose event). NULL for every
/// non-mirrored row.
externalId String? @map("external_id")
// 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])
/// v1.28 — `medication_intake_events_user_external_id_live_key` is a
/// partial UNIQUE index over `(user_id, external_id) WHERE external_id
/// IS NOT NULL AND deleted_at IS NULL` — see migration 0239. The bulk
/// intake route pre-checks the batch's externalIds and reports replays
/// as duplicates; the index is the race backstop (a concurrent insert
/// surfaces P2002, which the route resolves to the winning row). Prisma
/// can't express a partial-predicate unique (same as the live-row slot
/// unique above, migration 0121), so the schema keeps the full
/// `@@unique` for the `userId_externalId` compound where-key.
@@unique([userId, externalId])
@@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])
// v1.30.12 perf — the dashboard "meds today" read fires sibling queries that
// filter `(userId, scheduledFor)` WITHOUT `medicationId` (the composite above
// cannot range-seek those) and a per-medication `_max(takenAt)` group-by that
// scanned the whole intake history. These two let both index-seek instead.
@@index([userId, scheduledFor])
@@index([userId, medicationId, takenAt])
@@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])
// `medication_dose_changes_note_backfill_idx` is a partial index over
// `(medication_id) WHERE note IS NOT NULL AND note_encrypted IS NULL` — see
// migration 0243 (v1.28.46 H4). The med-notes boot-discovery joins dose-
// changes to medications on medication_id to reach the owner; the index
// leads with that join key. Raw SQL only (partial predicate).
@@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])
// `medication_inventory_items_note_backfill_idx` is a partial index over
// `(user_id) WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see
// migration 0243 (v1.28.46 H4) for the med-notes boot-discovery scan. Raw
// SQL only (partial predicate not expressible in Prisma).
@@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])
// `medication_side_effects_note_backfill_idx` is a partial index over
// `(user_id) WHERE notes IS NOT NULL AND notes_encrypted IS NULL` — see
// migration 0243 (v1.28.46 H4) for the med-notes boot-discovery scan. Raw
// SQL only (partial predicate not expressible in Prisma).
@@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")
}
// v1.30.x — one-time native OIDC handoff code. Bridges the browser-context
// OIDC callback to the cookie-less native token exchange. The raw value
// `hlh_<…>` rides ONLY the custom-scheme redirect (healthlog://oidc-callback);
// only its HMAC-SHA256 hash (hashToken, keyed by API_TOKEN_HMAC_KEY) is stored,
// so a database dump reconstructs nothing. Single-use via the guarded consume
// (`WHERE consumed_at IS NULL`); a second presentation after consumption revokes
// the token pair the first exchange issued (refresh-token reuse-detection
// posture). 90-second TTL enforced at read; a daily sweep prunes stale rows.
model OidcNativeHandoff {
id String @id @default(cuid())
userId String @map("user_id")
codeHash String @unique @map("code_hash") // hashToken(hlh_<…>)
/// The app's S256 PKCE challenge, bound at mint; the exchange verifies
/// s256(code_verifier) against it (constant-time) before consuming.
codeChallenge String @map("code_challenge")
expiresAt DateTime @map("expires_at")
consumedAt DateTime? @map("consumed_at")
/// hashToken of the refresh token issued at exchange — replay containment
/// revokes exactly this family member (+ its paired access token).
issuedRefreshTokenHash String? @map("issued_refresh_token_hash")
createdAt DateTime @default(now()) @map("created_at")
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
@@map("oidc_native_handoffs")
}
// ─── 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")
}
/// v1.27.0 — Google Health connection (Fitbit + Pixel Watch + Fitbit Air) over
/// the Google Health API (`health.googleapis.com/v4`). A separate, coexisting
/// provider alongside the classic Fitbit Web API integration (`FitbitConnection`)
/// — its own token row, `GOOGLE_HEALTH` source enum value, and OAuth client.
/// Tokens are encrypted at the app level before persistence. Google refresh
/// tokens do NOT rotate per use, so `refreshToken` is overwritten only when a
/// token response carries a fresh one.
///
/// `needsReauth` flags the CASA-free "Testing" consent path, where refresh
/// tokens expire after 7 days: the sync loop sets it on an `invalid_grant`
/// refresh failure so the card can surface a soft "reconnect" CTA rather than a
/// hard error, and the callback clears it on a fresh consent.
model GoogleHealthConnection {
id String @id @default(cuid())
userId String @unique @map("user_id")
googleUserId String @map("google_user_id") // Google external account 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")
/// Set true when a refresh fails with `invalid_grant` (7-day Testing-mode
/// refresh expiry) so the connection reads as a soft disconnect awaiting
/// re-consent; cleared on a successful reconnect.
needsReauth Boolean @default(false) @map("needs_reauth")
/// Single high-water mark on interval `start_time` — Google exposes no sync
/// cursor, so incremental sync walks forward from here.
lastSyncedAt DateTime? @map("last_synced_at")
/// Set once the self-converging history backfill walks every data type to
/// completion. Null = backfill still in flight (or never started).
backfillCompletedAt DateTime? @map("backfill_completed_at")
/// Set once the one-shot sleep duplicate-repair re-read completed — the full
/// sleep-history re-sync that collapses duplicate rows left by the volatile
/// pre-fix externalId (a re-scored night minted parallel copies). Null =
/// repair pending (or the connection predates the fix).
sleepRepairedAt DateTime? @map("sleep_repaired_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("google_health_connections")
}
/// v1.27.0 — short-lived `(nonce → userId)` ledger backing the Google Health
/// OAuth state check, identical in shape + lifecycle to `FitbitOAuthState`.
/// Google's OAuth is Authorization Code + PKCE (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.
model GoogleHealthOAuthState {
nonce String @id
userId String @map("user_id")
/// PKCE code_verifier carried from connect to callback. 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("google_health_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])
// `mood_entries_note_backfill_idx` is a partial index over `(user_id)
// WHERE note IS NOT NULL AND note_encrypted IS NULL` — see migration 0243
// (v1.28.46 H4), serving the note-encryption boot-discovery scan. Raw SQL
// only (partial predicate not expressible in Prisma).
@@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")
// Operator-shared central Codex (ChatGPT subscription) OAuth credential. The
// operator connects ONE signed-in ChatGPT account here (device-code flow, same
// mechanism as the per-user `codex*Encrypted` columns) and any user who flips
// the per-user `useCentralCodex` opt-in routes their AI egress through it. The
// three token pieces (access, refresh, and the mandatory `ChatGPT-Account-ID`
// claim) are each AES-256-GCM encrypted at rest. OFF until the operator
// connects it; the shared account's rate limits + training-by-default caveat
// apply to every opted-in user (see the admin connect UI's honesty copy).
adminCodexAccessTokenEncrypted String? @map("admin_codex_access_token_encrypted")
adminCodexRefreshTokenEncrypted String? @map("admin_codex_refresh_token_encrypted")
adminCodexAccountIdEncrypted String? @map("admin_codex_account_id_encrypted")
adminCodexTokenExpiresAt DateTime? @map("admin_codex_token_expires_at")
adminCodexConnectedAt DateTime? @map("admin_codex_connected_at")
adminCodexConnectionStatus String @default("disconnected") @map("admin_codex_connection_status")
// 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")
// Document vault — the two admin-tunable limits (the only knobs beside the
// module key). `documentMaxFileBytes` is the per-file upload cap, default
// 25 MiB, clamped at runtime to the hard 100 MiB ceiling (single-shot GCM +
// bounded in-memory reads are load-bearing past that). `documentQuotaBytes`
// is the instance-wide per-user storage quota, default 1 GiB; a per-user
// override lives on `User.documentQuotaBytes`. Usage counts every
// non-purged row (tombstones still hold TOAST bytes).
documentMaxFileBytes Int @default(26214400) @map("document_max_file_bytes")
documentQuotaBytes BigInt @default(1073741824) @map("document_quota_bytes")
@@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")
/// Bumped on every write, including each mid-run progress tick. Acts
/// as a liveness heartbeat: `reconcileOrphanImportJobs` treats a
/// non-terminal row whose heartbeat has gone stale as orphaned, so a
/// job still actively parsing in another worker (fresh heartbeat) is
/// never flipped to `failed` (issue #486 follow-up).
updatedAt DateTime @updatedAt @map("updated_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])
// `lab_results_biomarker_backfill_idx` is a partial index over `(user_id)
// WHERE deleted_at IS NULL AND biomarker_id IS NULL` — see migration 0243
// (v1.28.46 H4), serving the lab-biomarker boot-discovery `SELECT DISTINCT
// user_id`. Raw SQL only (partial predicate not expressible in Prisma).
@@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[]
/// v1.28 — medications the user has pinned to this biomarker as their
/// efficacy target. SetNull on delete keeps the override rows as orphans.
efficacyTargets MedicationEfficacyTarget[]
@@unique([userId, name])
@@index([userId])
@@map("biomarkers")
}
// ─── Custom metrics ──────────────────────────────────────────
/// v1.25.5 — user-defined custom metrics. A self-hoster defines an arbitrary
/// measurement (name, unit, optional target window, decimals, description),
/// logs values against it, and charts them. Deliberately a SEPARATE generic
/// store from the closed `MeasurementType` enum + `Measurement` table: custom
/// metrics are NOT synced (Withings / Apple Health), NOT in FHIR, NOT in AI
/// insights / correlations / health-score / dashboard tiles — log + chart only.
/// `name` / `unit` / `description` are PLAINTEXT (they may hold personal text
/// but, like the Biomarker catalog, stay clear-text so the list can sort +
/// search). Unique per user + name so there is never a second definition.
/// Soft-deleted.
model CustomMetric {
id String @id @default(cuid())
userId String @map("user_id")
/// Free-text display name, e.g. "Morning grip strength".
name String
/// Free-text unit label, e.g. "kg", "reps", "score".
unit String
/// Optional target window. Both independently optional; when both present
/// the low bound must not exceed the high bound (charted as a reference band).
targetLow Float? @map("target_low")
targetHigh Float? @map("target_high")
/// Optional fixed decimal places for display (null = sensible default).
decimals Int?
/// Optional free-text description ("what this measures"). Plaintext.
description String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
entries CustomMetricEntry[]
@@unique([userId, name])
@@index([userId])
@@map("custom_metrics")
}
/// v1.25.5 — a single logged value against a `CustomMetric`. The `unit` is a
/// snapshot of the metric's unit at write time (historical truth, mirroring the
/// LabResult per-row unit). Charts read these LIVE (no rollup tier).
model CustomMetricEntry {
id String @id @default(cuid())
userId String @map("user_id")
customMetricId String @map("custom_metric_id")
value Float
/// Unit snapshot from the metric at write time.
unit String
measuredAt DateTime @map("measured_at")
note String?
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
metric CustomMetric @relation(fields: [customMetricId], references: [id], onDelete: Cascade)
@@index([userId, customMetricId, measuredAt])
@@map("custom_metric_entries")
}
// ─── User Achievements ───────────────────────────────────────
model UserAchievement {
id String @id @default(cuid())
userId String @map("user_id")
achievementId String @map("achievement_id")
unlockedAt DateTime @map("unlocked_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, achievementId])
@@index([userId])
@@map("user_achievements")
}
// ─── Host Metrics (admin system-status host-load chart) ──────
/// Per-minute snapshot of host-process load + memory captured by the
/// pg-boss `host-metric-sample` job. Read by `/api/admin/host-metrics`
/// to render the host-load trend above the system-status section.
/// 7-day retention is enforced inside the sampler — we keep the table
/// small enough that a single `findMany` over the last 2 hours stays
/// well under 200 rows.
model HostMetric {
id String @id @default(cuid())
capturedAt DateTime @default(now()) @map("captured_at")
loadAvg1 Float @map("load_avg_1")
loadAvg5 Float @map("load_avg_5")
loadAvg15 Float @map("load_avg_15")
memUsedBytes BigInt @map("mem_used_bytes")
memTotalBytes BigInt @map("mem_total_bytes")
// Optional rolling-window byte counters from /proc/diskstats. Null
// outside Linux (macOS dev machines, Windows). The API endpoint
// converts deltas into bytes-per-second client-side.
diskReadBytes BigInt? @map("disk_read_bytes")
diskWriteBytes BigInt? @map("disk_write_bytes")
@@index([capturedAt])
@@map("host_metrics")
}
// ─── Recommendation Feedback (v1.4.16 phase B5e) ───────────────
//
// Per-recommendation thumbs-up / thumbs-down rating. Captured client-
// side under each rec card; persisted with provider attribution
// (providerType + promptVersion) so the daily aggregator can slice
// helpful-rate per (severity x provider x prompt) for the admin AI
// quality dashboard.
//
// Default-on for the single-user case (only the user's ratings shape
// the user's future prompts); cross-user aggregate stays admin-only
// (no prompt-tuning flow yet — that ratchet is v1.4.17).
//
// Privacy: server-side fills providerType + promptVersion from the
// user's most recent cached payload (User.insightsCachedText) so the
// client can't tamper with the attribution.
//
// `(userId, recommendationId, recommendationText)` is the dedup key.
// recommendationText snapshots the rec at submission time so a re-
// generation that rewrites the same id with new text counts as a
// different row — that's intentional, the aggregator wants per-text
// signal not per-id signal.
model RecommendationFeedback {
id String @id @default(cuid())
userId String @map("user_id")
recommendationId String @map("recommendation_id")
// Nullable since v1.4.23 reconcile (Sr-H3): the recommendation
// surface still snapshots the prose here (recommendations are
// unencrypted on the wire, so plaintext is the source-of-truth);
// the Coach surface leaves it NULL and references the message via
// `coachMessageId` so the assistant prose stays encrypted-at-rest
// in `coach_messages.encryptedContent`.
recommendationText String? @map("recommendation_text")
recommendationSeverity String @map("recommendation_severity")
metricSourceType String @map("metric_source_type")
metricSourceTimeRange String @map("metric_source_time_range")
helpful Boolean
providerType String @map("provider_type")
promptVersion String @map("prompt_version")
createdAt DateTime @default(now()) @map("created_at")
// v1.4.23 H7 — polymorphic target so Coach replies share the table.
// "recommendation" (default) is the v1.4.16 B5e shape; "coach" is a
// Coach assistant message. The aggregator slices on targetType to
// bucket each surface independently. Default value lets every
// pre-v1.4.23 row read as "recommendation" without a backfill.
targetType String @default("recommendation") @map("target_type")
// Optional free-form reason — populated when the user clicks
// "unhelpful" and selects a category from the small chip strip.
// Capped at 200 chars upstream so the table can't bloat from a
// pasted essay.
reason String? @map("reason")
// v1.4.23 reconcile (Sr-H3) — Coach surface references the source
// message instead of snapshotting its prose into
// `recommendationText`. FK + cascade so a deleted message drops the
// rating row too. Always NULL for `targetType="recommendation"`.
coachMessageId String? @map("coach_message_id")
coachMessage CoachMessage? @relation(fields: [coachMessageId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, recommendationId, recommendationText], map: "rec_feedback_user_rec_text_key")
@@index([userId, createdAt])
@@index([recommendationSeverity, helpful, createdAt])
@@index([targetType, createdAt])
@@index([coachMessageId])
@@map("recommendation_feedback")
}
// ─── AI Coach (v1.4.20) ────────────────────────────────────
//
// Conversation thread between a user and the AI Coach. Messages are
// stored encrypted at rest via AES-256-GCM (`src/lib/crypto.ts`) under
// the active `ENCRYPTION_KEYS` entry — the Coach inherits the same
// key-rotation tooling as the rest of the app. GDPR cascade through
// User keeps the model deletion-clean.
//
// `title` is a short snippet of the first user message (≤80 chars,
// non-encrypted) so the history rail can render without paying the
// per-message decrypt cost. The snippet is plain text; do not put PII
// the user explicitly opted out of into it. The summarisation helper
// in `src/lib/ai/coach/persistence.ts` truncates with an ellipsis.
model CoachConversation {
id String @id @default(cuid())
userId String @map("user_id")
title String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// v1.11.0 — rolling encrypted summary of the elided older turns, so a long
// conversation keeps memory of what came before without re-sending every
// turn to the provider. AES-256-GCM (same codec as CoachMessage.encrypted).
summaryEncrypted Bytes? @map("summary_encrypted")
summaryUpdatedAt DateTime? @map("summary_updated_at")
summaryTurnCount Int @default(0) @map("summary_turn_count")
// v1.29.x (S7 — attach multiple documents to a coach conversation) — PERMANENT
// fence marker. Set true when the conversation is created via the fenced
// endpoint or when the FIRST document is attached. NO code path ever sets it
// back to false — detaching every document, or a document being deleted,
// leaves it true. A conversation whose history may contain document-derived
// text must never regain the tool loop. The LIVE attachment set lives in the
// `CoachConversationDocument` join table; this sticky flag is the fail-closed
// half of the fenced-only discriminator (see the dual predicate on the tool
// route). Replaces the pre-S7 scalar `documentId` discriminator, which could
// only express one document and would UNFENCE on that document's delete.
documentScoped Boolean @default(false) @map("document_scoped")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
messages CoachMessage[]
attachments CoachConversationDocument[]
@@index([userId, updatedAt])
// v1.29.x (S7) — the rail lists health threads + fenced threads newest-first
// and the tool-route send path filters on the flag. Replaces the retired
// [userId, documentId, updatedAt] composite.
@@index([userId, documentScoped, updatedAt])
@@map("coach_conversations")
}
/// v1.29.x (S7) — a document attached to a coach conversation. The EXISTENCE of
/// any row for a conversation is one half of the fenced-only discriminator (the
/// other is `CoachConversation.documentScoped`). Composite PK so the SQL
/// backfill needs no client-side id generation and duplicate attaches are
/// impossible by construction. Two cascades:
/// - from DOCUMENT: deleting a vault document removes its join rows (its text
/// leaves every conversation's context) but NEVER the conversation — a
/// fenced thread survives via the sticky flag.
/// - from CONVERSATION: deleting a chat removes its attachment rows, never the
/// documents.
model CoachConversationDocument {
conversationId String @map("conversation_id")
documentId String @map("document_id")
addedAt DateTime @default(now()) @map("added_at")
conversation CoachConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
@@id([conversationId, documentId])
@@index([documentId]) // document-sheet "chats about this doc" scan + path-id join filter
@@map("coach_conversation_documents")
}
// 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")
/// v1.28.16 — the authoritative "documents-only" flag, frozen write-once at
/// create. When true the public view serves ONLY the attached documents and
/// the aggregator is never called, INDEPENDENT of `sectionsJson`. Decoupling
/// the guarantee from "are all section flags off?" means a future report
/// section added to the prefs shape can never silently re-open an old
/// documents-only link (the previous derived check would have leaked it once
/// the new key defaulted on). Legacy links (pre-column) keep the derived
/// all-off fallback in the view loader.
documentOnly Boolean @default(false) @map("document_only")
/// 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)
/// v1.28 — the hand-picked document set this link exposes (0..N). A relation,
/// not a column: frozen write-once at create, owner-checked, never widened.
documents ClinicianShareLinkDocument[]
@@index([userId, createdAt(sort: Desc)])
@@index([expiresAt])
@@map("clinician_share_links")
}
/// v1.28 (document vault, Phase 3) — frozen, write-once membership binding a
/// stored document to a clinician share link. The document set a share exposes
/// is modelled as this relation, never a mutable column: the rows are created
/// in the same transaction as the link, each `documentId` is validated as the
/// caller's own live document at create time, and the set is never widened
/// afterwards. The public share serve route serves ONLY a document id present
/// in this set for the resolved token — a guessed / foreign id is a flat 404.
/// Both foreign keys cascade: deleting the link or the underlying document
/// removes the membership cleanly (a shared document the owner later deletes
/// simply disappears from the link).
model ClinicianShareLinkDocument {
id String @id @default(cuid())
shareLinkId String @map("share_link_id")
documentId String @map("document_id")
createdAt DateTime @default(now()) @map("created_at")
shareLink ClinicianShareLink @relation(fields: [shareLinkId], references: [id], onDelete: Cascade)
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
@@unique([shareLinkId, documentId])
@@index([shareLinkId])
@@index([documentId])
@@map("clinician_share_link_documents")
}
// 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 — any shipped UI locale, not just de/en:
/// the prose is generated per locale, so the row is keyed by it.
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")
}
// ─── Data-arrival spine (v1.31.0) ─────────────────────────────
//
// One row per (user, arrival kind, local day). The row is written by the
// `data-arrival` worker after a SALIENT ingest — never by a backfill, which
// the seam classifier drops before anything is enqueued.
//
// The unique index is load-bearing three times over: it is the "just in"
// marker the reaction surfaces read, it is the at-most-one-generated-line
// per kind per day claim (inserted with ON CONFLICT DO NOTHING, so a lost
// race means another worker already owns today's line), and it is the audit
// trail for what the spine actually reacted to.
model ArrivalReaction {
id String @id @default(cuid())
userId String @map("user_id")
/// One of `ARRIVAL_KINDS` (`@/lib/arrivals/types`).
kind String
/// The user's PROFILE-timezone YYYY-MM-DD this reaction is filed under.
localDate String @map("local_date")
/// Timestamp of the newest sample that triggered it.
occurredAt DateTime @map("occurred_at")
/// Kind-scoped referent: the workout id for `workout`, the panel day for labs.
refId String? @map("ref_id")
/// AES-256-GCM ciphertext of the generated reaction line. Null until — and
/// unless — a line is generated: the marker is the feature, the sentence is
/// garnish, and a provider-less install never fills this in. Follows the
/// `InsightNarrative.encryptedContent` precedent, NOT the plaintext pattern
/// the status cards use. Registered in `scripts/rotate-encryption-key.ts`.
lineEncrypted Bytes? @map("line_encrypted")
generatedAt DateTime? @map("generated_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// The once-per-kind-per-local-day claim. Do not relax this to include
// `refId` — the day scope is the throttle.
@@unique([userId, kind, localDate])
// The reaction surfaces read "today's rows for this user".
@@index([userId, localDate])
// The nightly retention sweep deletes by age.
@@index([createdAt])
@@map("arrival_reactions")
}
// ─── 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[]
documentLinks DocumentConditionLink[]
@@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")
}
// ─── Structured health records (v1.25 W-RECORDS) ──────────────────
//
// Allergies + family history kept as STRUCTURED records (not time-series
// signals — they are NOT in the signal registry). Reference data the user
// curates once and edits rarely, surfaced in the Settings "Anamnese"
// (medical-history) section and exported to FHIR (AllergyIntolerance /
// FamilyMemberHistory). Patient-reported throughout — never a clinical
// diagnosis the app asserts. Sensitive free-text fields are AES-256-GCM
// encrypted at rest (`*Encrypted` Bytes columns, the shared note codec);
// the structured enum fields stay queryable plaintext. Soft-deleted.
/// Broad allergen category for an Allergy (maps to FHIR
/// AllergyIntolerance.category — food / medication / environment / biologic).
enum AllergyCategory {
FOOD
MEDICATION
ENVIRONMENT
BIOLOGIC
OTHER
@@map("allergy_category")
}
/// Distinguishes an immune-mediated allergy from a non-immune intolerance
/// (FHIR AllergyIntolerance.type allergy | intolerance).
enum AllergyType {
ALLERGY
INTOLERANCE
@@map("allergy_type")
}
/// Worst-reaction severity (FHIR AllergyIntolerance.reaction.severity /
/// criticality). NULL = not assessed.
enum AllergySeverity {
MILD
MODERATE
SEVERE
@@map("allergy_severity")
}
/// Clinical status of the record (FHIR AllergyIntolerance.clinicalStatus).
enum AllergyStatus {
ACTIVE
INACTIVE
RESOLVED
@@map("allergy_status")
}
/// One self-recorded allergy/intolerance. `substance` is the queryable
/// human label (the AllergyIntolerance `code.text` anchor — never a
/// machine-guessed SNOMED/RxNorm code). The free-text reaction description
/// and notes are encrypted at rest. Patient-reported; the export carries a
/// "not a clinical diagnosis" guard rail.
model Allergy {
id String @id @default(cuid())
userId String @map("user_id")
/// User-facing substance/allergen name, e.g. "Penicillin", "Peanuts".
/// Queryable plaintext label (the medication.name / illness.label
/// precedent); rides FHIR `code.text`.
substance String
category AllergyCategory @default(OTHER)
type AllergyType @default(ALLERGY)
/// Worst observed reaction severity. NULL = not assessed.
severity AllergySeverity?
status AllergyStatus @default(ACTIVE)
/// When the allergy was first noticed (UTC instant). NULL = unknown.
onsetAt DateTime? @map("onset_at")
/// AES-256-GCM ciphertext of the free-text reaction/manifestation
/// description (e.g. "anaphylaxis", "hives"). Bytes via the shared codec.
reactionEncrypted Bytes? @map("reaction_encrypted")
/// AES-256-GCM ciphertext of an optional free-text note. Bytes column.
notesEncrypted Bytes? @map("notes_encrypted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, deletedAt])
@@map("allergies")
}
/// Relationship of a family member to the user (maps to FHIR
/// FamilyMemberHistory.relationship). Lateralised grandparents let the
/// maternal/paternal line be recorded where the user knows it.
enum FamilyRelationship {
MOTHER
FATHER
SISTER
BROTHER
DAUGHTER
SON
GRANDMOTHER_MATERNAL
GRANDFATHER_MATERNAL
GRANDMOTHER_PATERNAL
GRANDFATHER_PATERNAL
AUNT
UNCLE
COUSIN
HALF_SIBLING
OTHER
@@map("family_relationship")
}
/// One condition recorded for one relative (a FHIR FamilyMemberHistory with
/// a single condition). `condition` is the queryable human label (the
/// FamilyMemberHistory.condition `code.text` anchor — never a machine-guessed
/// code); `ageAtOnset` is the relative's age when the condition began. The
/// optional free-text note is encrypted at rest. Patient-reported.
model FamilyHistoryEntry {
id String @id @default(cuid())
userId String @map("user_id")
relationship FamilyRelationship
/// User-facing condition name, e.g. "Type 2 diabetes", "Breast cancer".
/// Queryable plaintext label; rides FHIR `condition.code.text`.
condition String
/// The relative's age (years) when the condition began. NULL = unknown.
ageAtOnset Int? @map("age_at_onset")
/// AES-256-GCM ciphertext of an optional free-text note. Bytes column.
notesEncrypted Bytes? @map("notes_encrypted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, deletedAt])
@@map("family_history_entries")
}
/// One cached forecast per user, regenerated debounced /
/// stale-while-revalidate (the InsightNarrative + v1.8.7 assessment
/// cache pattern), NOT per-read. Derived — not soft-deleted.
model CyclePrediction {
id String @id @default(cuid())
userId String @map("user_id")
/// Which method produced this forecast.
method PredictionMethod
/// YYYY-MM-DD — predicted next period start (point estimate) plus the
/// ranged Low / High band (rendered as a band, never a single date).
nextPeriodStart String @map("next_period_start")
nextPeriodStartLow String @map("next_period_start_low")
nextPeriodStartHigh String @map("next_period_start_high")
/// YYYY-MM-DD — fertile-window bounds (goal-gated at the read surface).
fertileWindowStart String? @map("fertile_window_start")
fertileWindowEnd String? @map("fertile_window_end")
/// YYYY-MM-DD — predicted ovulation day.
predictedOvulation String? @map("predicted_ovulation")
/// 0..1 confidence, shrinking with cycle-length variance + sparse
/// logging + < MIN_CYCLES observed.
confidence Float
/// How many observed cycles the forecast was computed from.
cyclesObserved Int @map("cycles_observed")
/// When the forecast was generated (drives the stale-while-revalidate
/// debounce).
generatedAt DateTime @map("generated_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId])
@@map("cycle_predictions")
}
// ─────────────────────────────────────────────────────────────────────────
// v1.25 (W-DOCS-IN) — inbound clinical documents.
//
// A self-hoster uploads a doctor report / discharge letter they RECEIVED.
// The raw document is stored encrypted at rest; the dedicated OCR/vision
// provider (the v1.22 document-scan rails) transcribes STRUCTURED FACTS into
// a review-then-confirm staging area. The app reproduces what the clinician
// wrote — it never interprets, never range-flags, never diagnoses, never
// links a condition to a medication. A fact only reaches the structured
// stores (labs / conditions / medications) once the user explicitly approves
// it; low-confidence facts fail closed (surfaced for manual review, never
// auto-committed). Opt-in module, off by default.
// ─────────────────────────────────────────────────────────────────────────
/// What kind of clinical document was uploaded (free choice by the user;
/// drives no interpretation — it is a label only). The richer set
/// (lab/imaging/prescription/insurance/vaccination) makes the library
/// browsable by category; the value still drives no interpretation.
enum InboundDocumentKind {
DOCTOR_REPORT
DISCHARGE_LETTER
LAB_RESULT
IMAGING
PRESCRIPTION
/// Überweisung / referral letter — a common German artefact with a clean
/// LOINC (57133-1) / ePA (ANF) mapping. Added with the document vault.
REFERRAL
INSURANCE
VACCINATION
OTHER
@@map("inbound_document_kind")
}
/// Lifecycle of an uploaded document. STORED is the library default — the
/// file is uploaded + encrypted at rest with no extraction run. EXTRACTING
/// while the provider runs, EXTRACTED once facts are staged for review,
/// FAILED on a provider/parse miss, CONFIRMED once the user has acted on
/// every fact, DISCARDED on a soft delete.
enum InboundDocumentStatus {
STORED
EXTRACTING
EXTRACTED
FAILED
CONFIRMED
DISCARDED
@@map("inbound_document_status")
}
/// Outcome of a document's plain-language summary. Recorded so the detail view
/// states what happened rather than claiming generation is in progress for a
/// job that was never enqueued.
///
/// WITHHELD deserves a note: the outbound safety screen tripped, so the model's
/// prose is discarded and NEVER shown. Only the fact of the refusal is kept, and
/// it is deliberately not terminal — the user can ask again, which is the whole
/// reason this is a state and not a stored refusal string.
enum DocumentSummaryState {
/// Never attempted — the opt-in was off at upload, or the document predates
/// the feature. The default, so every existing row is correct.
NONE
/// Enqueued or running.
PENDING
/// A summary was produced and is stored in `summaryEncrypted`.
READY
/// Produced, then blocked by the outbound safety screen. Text discarded.
WITHHELD
/// Attempted and could not produce one — no vision provider, spent budget,
/// withdrawn consent, an unreadable file, or a provider error.
UNAVAILABLE
@@map("document_summary_state")
}
/// The FHIR resource an extracted fact maps to (STATED status only): a
/// diagnosis → Condition, a measured value → Observation, a medication the
/// patient is/was taking → MedicationStatement.
enum ExtractedFactType {
CONDITION
OBSERVATION
MEDICATION_STATEMENT
@@map("extracted_fact_type")
}
/// Review state of a staged fact. PENDING until the user decides; APPROVED
/// once committed to a structured store; REJECTED when the user discards it.
enum ExtractedFactStatus {
PENDING
APPROVED
REJECTED
@@map("extracted_fact_status")
}
model InboundDocument {
id String @id @default(cuid())
userId String @map("user_id")
kind InboundDocumentKind @default(OTHER)
/// User-given label. Deliberately plaintext (like `filename`) so the library
/// list can ILIKE-search + ORDER BY in SQL. The document BODY stays
/// encrypted; only this short, user-authored title is clear-text. Tradeoff:
/// a title the user types MAY contain PHI (e.g. "HIV result") and is then
/// cleartext at rest / in backups — the same exposure the pre-existing
/// plaintext `filename` already carries. Accepted to keep server-side search
/// and sort.
title String?
/// Original filename as uploaded (sanitised, optional).
filename String?
/// Magic-byte-sniffed MIME type the document was stored as.
mimeType String @map("mime_type")
/// Stored byte length of the original (pre-encryption) document.
byteSize Int @map("byte_size")
/// AES-256-GCM ciphertext of the raw document bytes (base64 of the binary
/// → `encrypt()` string → UTF-8 BYTEA), the `*_encrypted` Bytes convention
/// shared with the note columns. The original is sensitive and never
/// stored in the clear or logged.
contentEncrypted Bytes @map("content_encrypted")
/// Lowercase-hex SHA-256 of the PLAINTEXT document bytes, computed at
/// upload for same-user duplicate detection. Nullable: rows stored before
/// the vault release are not backfilled (only new uploads dedupe). A
/// partial unique index (raw SQL in migration 0227, not expressible in
/// Prisma) enforces at most one LIVE row per (user_id, content_sha256):
/// CREATE UNIQUE INDEX inbound_documents_user_sha_live
/// ON inbound_documents (user_id, content_sha256)
/// WHERE deleted_at IS NULL AND content_sha256 IS NOT NULL;
contentSha256 String? @map("content_sha256") @db.VarChar(64)
/// Storage codec of `contentEncrypted`. "base64v1" = legacy string path
/// (base64-of-binary → `encrypt()` string → UTF-8 bytes); "binary2" = the
/// binary AES-256-GCM layout written by `encryptBytes()` (no base64
/// detour). Explicit column, never header-sniffed; the default keeps every
/// pre-existing row correct.
contentCodec String @default("base64v1") @map("content_codec")
status InboundDocumentStatus @default(STORED)
/// Provider type that ran the extraction (e.g. "anthropic", "openai",
/// "local"). Diagnostic only. Null on a store-only document.
providerType String? @map("provider_type")
/// The document's stated report/collection date (YYYY-MM-DD at UTC), as
/// transcribed by the model — never inferred, never user-set.
reportDate DateTime? @map("report_date")
/// Effective filing date the library sorts, groups, and range-filters on
/// (editable). Distinct from `reportDate`, which is the model-transcribed
/// value. On store it defaults to the upload day (now) when the user does
/// not supply one, so a stored row's filing date is never NULL and display
/// == sort == filter. It stays user-editable and MAY be cleared back to
/// NULL via PATCH (the list then sorts such rows last).
documentDate DateTime? @map("document_date")
/// Failure reason when status = FAILED (a short code, never the document).
errorReason String? @map("error_reason")
/// AES-256-GCM ciphertext of a short (3-4 sentence) plain-language summary
/// of WHAT the document is, generated ONCE in the background right after
/// upload when the user's `documentsAutoAiRead` opt-in is ON (that flag is
/// also the standing AI-egress consent). Absent when auto-read is OFF, no
/// provider is configured, or generation has not run yet. Descriptive only —
/// never a diagnosis. `encrypt()`-string-as-UTF-8 BYTEA, the shared Bytes
/// codec every other note column uses; PHI, never logged.
summaryEncrypted Bytes? @map("summary_encrypted")
/// When the background summary was generated (null until it runs). Surfaced
/// as meta beside the summary in the document detail view.
summaryGeneratedAt DateTime? @map("summary_generated_at")
/// What actually happened to this document's summary, so the detail view can
/// tell the four outcomes apart instead of reading a null column as "still
/// generating". `summaryEncrypted` alone cannot: a document nobody ever
/// enqueued, one whose provider is missing, one the safety screen withheld,
/// and one mid-flight all leave it null. NONE is the default, which keeps
/// every pre-existing row correct — none of them were ever attempted.
summaryState DocumentSummaryState @default(NONE) @map("summary_state")
facts ExtractedFact[]
conditionLinks DocumentConditionLink[]
/// 1:1 blind content-search index (encrypted extracted text + HMAC token
/// array). Absent until the document is indexed; cascades on delete.
contentIndex DocumentContentIndex?
/// 1:1 encrypted preview thumbnail (small JPEG, AES-256-GCM at rest).
/// Absent until the background thumbnail job runs; a side table (not a
/// column) so the list/detail `omit: { contentEncrypted }` queries never
/// drag the blob into the SELECT. Cascades on delete.
thumbnail DocumentThumbnail?
/// v1.29.x (S7) — coach-conversation attachments (m:n via
/// `CoachConversationDocument`). Deleting this document cascades its join rows
/// away (its text leaves every conversation's context) but never the
/// conversation itself — a fenced thread survives via its sticky flag.
chatAttachments CoachConversationDocument[]
/// share-link memberships (m:n via `ClinicianShareLinkDocument`).
/// Cascades: deleting this document removes it from every share it was on.
shareLinks ClinicianShareLinkDocument[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = discarded.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
// The list always filters `deletedAt: null` and sorts by `documentDate`, so
// deletedAt leads the sort column to keep the scan covered. This composite is
// a superset of the plain `(userId, deletedAt)` index, which is therefore
// redundant for read planning and dropped.
@@index([userId, deletedAt, documentDate])
// `inbound_documents_thumbnail_backfill_idx` is a partial index over
// `(user_id, mime_type) WHERE deleted_at IS NULL` — see migration 0243
// (v1.28.46 H4). It bounds the document-side scan of the thumbnail
// boot-discovery (which anti-joins document_thumbnails for the missing
// preview). Raw SQL only (partial predicate not expressible in Prisma).
@@map("inbound_documents")
}
model ExtractedFact {
id String @id @default(cuid())
documentId String @map("document_id")
userId String @map("user_id")
factType ExtractedFactType @map("fact_type")
status ExtractedFactStatus @default(PENDING)
/// Overall extraction confidence 0..1 (the lowest per-field score). The
/// review screen gates approval of a low-confidence fact behind a manual
/// edit (fail-closed).
confidence Float @default(0)
/// True when the fact scored below the confidence floor and must be
/// reviewed/edited before it can be approved. Cleared once the user edits
/// the fact (the values then become user-asserted).
needsReview Boolean @default(true) @map("needs_review")
/// The FHIR-staged fields, STATED status only, shaped per `factType`
/// (Condition / Observation / MedicationStatement). Codes (SNOMED / ICD-10
/// / LOINC / RxNorm / ATC) are present only when the document writes them.
/// Stored encrypted: the JSON payload (diagnosis text, lab values,
/// medication names, stated codes) is clinical PHI transcribed verbatim from
/// the source document, so it rides the AES-256-GCM `*_encrypted` BYTEA
/// convention shared with the note columns rather than as plaintext JSONB.
dataEncrypted Bytes @map("data_encrypted")
/// Per-field provenance: the verbatim source span the value came from, an
/// optional page index, and per-field confidence. Makes "extract what's
/// written" auditable against the original. The source span is a verbatim
/// excerpt of the clinical document (PHI), so it is stored encrypted with the
/// same codec as `dataEncrypted`.
provenanceEncrypted Bytes @map("provenance_encrypted")
/// The id of the structured-store row created when the fact was approved
/// (LabResult / IllnessEpisode / Medication), or NULL while PENDING /
/// REJECTED.
committedRecordId String? @map("committed_record_id")
/// The structured store the approved fact landed in ("labResult" /
/// "illnessEpisode" / "medication"), or NULL.
committedRecordType String? @map("committed_record_type")
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([documentId])
@@index([userId])
@@map("extracted_facts")
}
/// m:n join between a stored document and an illness/condition episode
/// (a discharge letter legitimately touches two conditions). Never
/// mandatory at upload; both sides cascade so a deleted document or
/// episode takes its links with it. `userId` is denormalised for the
/// owner-scoped queries the vault list runs.
model DocumentConditionLink {
id String @id @default(cuid())
documentId String @map("document_id")
episodeId String @map("episode_id")
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
episode IllnessEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([documentId, episodeId])
@@index([episodeId])
@@index([userId])
@@map("document_condition_links")
}
/// v1.27.22 (Document vault P2) — the blind content-search index for one stored
/// document (1:1). Content search matches INSIDE a document without ever
/// storing its body in the clear: the extracted text is kept AES-256-GCM at
/// rest (`textEncrypted`, the same `*Encrypted` Bytes convention as every other
/// PHI column) and, separately, tokenised into deterministic HMAC-SHA256 hashes
/// (`searchTokens`) under an HKDF-derived index subkey. A query is tokenised +
/// hashed the same way and matched with a GIN-accelerated array-overlap — so
/// the corpus is searchable at SQL speed while nothing readable sits at rest.
/// Honest limit: the hash index does whole-token equality only (no substring /
/// prefix / fuzzy); the vault list still ILIKEs the short title/filename for
/// substring and unions the two result sets. Both artefacts cascade with the
/// document. `tokenizerVersion` lets a future tokenizer change be re-indexed
/// unambiguously; the index subkey is HKDF-derived at runtime from the active
/// versioned key (no schema column), and key rotation re-tokenises from the
/// decrypted `textEncrypted`.
model DocumentContentIndex {
id String @id @default(cuid())
documentId String @unique @map("document_id")
userId String @map("user_id")
/// AES-256-GCM ciphertext of the normalised extracted text (capped, first
/// ~64 KiB), stored as the `encrypt()`-string-as-UTF-8 Bytes shape shared
/// with the Coach columns. The plaintext is recoverable server-side so
/// rotation can re-tokenise; the list query never selects it.
textEncrypted Bytes @map("text_encrypted")
/// v1.27.33 (Document vault P4 — chat about a document) — AES-256-GCM
/// ciphertext of the VERBATIM extracted text (byte-capped, NOT lowercased or
/// de-accented), stored additionally alongside `textEncrypted` so a document
/// chat can cite the document faithfully (exact casing, section names, units)
/// rather than from the normalised search text. Same `encrypt()`-string-as-
/// UTF-8 Bytes shape as every other PHI column. Nullable: rows indexed before
/// P4 carry NULL until re-indexed, and the chat route falls back to the
/// normalised `textEncrypted` when it is absent. The list query never selects it.
verbatimTextEncrypted Bytes? @map("verbatim_text_encrypted")
/// Deduped HMAC-SHA256 (truncated hex) of the normalised tokens — the blind
/// index. One-way: opaque without the server-held index subkey. GIN-indexed
/// (raw SQL in the migration; Prisma cannot express a GIN index).
searchTokens String[] @default([]) @map("search_tokens")
/// Provenance of the indexed text: "vision" (provider transcription of the
/// stored original), "text-ocr" (browser-OCR text posted by the client),
/// "local-pdf" (server-side text-layer extraction, no provider) or "local-ocr"
/// (server-side OCR of a scanned document — deferred follow-up). Free String;
/// adding a source value needs no migration.
source String
/// Provider type that produced the text (diagnostic only), or null on the
/// text-ocr path.
providerType String? @map("provider_type")
/// Tokeniser algorithm version — bump to force a re-index on a scheme change.
tokenizerVersion String @map("tokenizer_version")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("document_content_index")
}
/// 1:1 encrypted preview thumbnail for a stored document. A small JPEG
/// (~320px long edge, q70) rendered in the background from the original —
/// images are decoded + downscaled, a PDF's first page is rasterised — then
/// stored encrypted at rest. Held in a side table (not a column on
/// `InboundDocument`) so the list/detail queries that `omit` the original
/// blob never drag this blob either. The re-encode strips EXIF/GPS from the
/// source, so the preview is metadata-free by construction. Owner-scoped
/// serve route only; cascades on delete.
model DocumentThumbnail {
id String @id @default(cuid())
documentId String @unique @map("document_id")
userId String @map("user_id")
/// AES-256-GCM ciphertext of the JPEG preview, stored as the
/// `encrypt()`-string-as-UTF-8 Bytes shape (base64 of the JPEG), the same
/// codec `DocumentContentIndex.textEncrypted` uses so the standard
/// key-rotation walk covers it. A scanned medical preview is PHI — never
/// stored in the clear or logged. The list query never selects it.
thumbnailEncrypted Bytes @map("thumbnail_encrypted")
/// Decoded thumbnail dimensions (px) — diagnostic + lets a future consumer
/// size the tile without decoding the blob.
width Int
height Int
/// Plaintext byte length of the JPEG (pre-encryption), for `Content-Length`.
byteSize Int @map("byte_size")
/// Source MIME the preview was rendered from (diagnostic only).
sourceMime String @map("source_mime")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("document_thumbnails")
}
// ─── Nutrient intake (v1.28) ───────────────────────────────────────
//
// Micronutrient daily totals synced from Apple Health — vitamins,
// minerals, water, caffeine as supplement-style "substances taken",
// never a food diary (energy / macros are out of scope by directive).
// One row per (user, local day, nutrient); the composite PK IS the
// idempotency key, so an iOS re-post of a grown day total replaces the
// row in place (last-writer-wins day-total contract — the `stats:`
// overwrite semantics without externalId string parsing). `nutrient`
// is a bare string enforced in code against the closed catalog in
// `src/lib/nutrients/catalog.ts` (the IntegrationStatus precedent: no
// migration to add a code). No soft delete: rows are source-owned day
// totals; replace-on-repost is the correction mechanism and account
// deletion cascades. Behind the opt-in `nutrients` module
// (refuse-ingest-when-off).
model NutrientIntakeDay {
userId String @map("user_id")
/// YYYY-MM-DD anchored to the user's IANA timezone (iOS-computed —
/// the same day-key contract as the `stats:` externalIds).
day String
/// Closed catalog code ("vitamin_d", "magnesium", "water", ...).
nutrient String
/// Daily total in the catalog's canonical unit.
amount Float
/// Canonical unit echoed from the catalog ("mg" | "ug" | "ml") —
/// denormalised on purpose: rows stay self-describing in exports
/// even if the catalog ever drifts.
unit String
/// "APPLE_HEALTH" | "MANUAL" (closed set enforced in app code, not a
/// DB enum). v1.29 — joined the composite PK (migration 0249) so a
/// manual water entry and the Apple-synced day total coexist as two
/// rows instead of the manual write being silently clobbered by the
/// next Apple last-writer-wins sync. Apple owns its row (LWW on
/// re-post); manual owns its row (increment/set via
/// `POST /api/nutrients/water`); reads SUM across sources per day.
source String @default("APPLE_HEALTH")
externalSourceVersion String? @map("external_source_version")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([userId, day, nutrient, source])
@@index([userId, nutrient, day(sort: Desc)])
@@map("nutrient_intake_days")
}