mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
A batch that carried a type with duplicates only moved its lastReceivedAt, lastTrigger and lastBackgroundAt, so a type whose live delivery had stalled kept reading as freshly arrived for as long as a replay, a retry or an older client re-sent its old samples. The arrival ledger now moves those stamps only for a type the batch brought at least one new or changed row for. An unchanged stats: re-post and an hour posted again with its stored value count as duplicates here; the wire keeps answering updated for them. A duplicates-only type stamps the new last_seen_duplicate_at column on its existing row and nothing else. The intake bulk route's dose entry follows the same rule through the shared recorder. lastSyncedAt and healthKitLastSyncedAt are unchanged. GET /api/integrations/healthkit serves the stamp additively as metricFreshness[].lastSeenDuplicateAt, and the Apple Health card names a re-send that came after the last arrival. Refs #1259
10246 lines
549 KiB
Plaintext
10246 lines
549 KiB
Plaintext
// v1.4.34 web-freeze. Schema is locked additive-only until iOS launch
|
||
// + v1.5.0 marker. See .planning/v15-strategic-plan.md §2.
|
||
//
|
||
// ─── Timezone storage convention (UTC-only invariant) ───────────────
|
||
//
|
||
// The database is UTC-only. Almost every `DateTime` column is the
|
||
// Prisma default `timestamp(3)` WITHOUT time zone; a handful of rollup
|
||
// columns (`MeasurementRollup.bucketStart` / `computedAt`, and the mood
|
||
// rollup buckets) carry `@db.Timestamptz(3)`. This is NOT a live bug:
|
||
// Prisma always reads and writes UTC over the wire, so both types
|
||
// round-trip the same instant. The invariant every reader, writer, and
|
||
// raw-SQL query MUST hold is:
|
||
//
|
||
// * The application server and the Postgres session run in UTC (every
|
||
// connection pins TimeZone=UTC, see src/lib/db-session.ts).
|
||
// * 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")
|
||
|
||
/// v1.37.0 — since when this account is a record somebody else runs.
|
||
///
|
||
/// A managed profile is a person who cannot run their own record: it has a
|
||
/// display name and no credentials, never signs in, and is administered
|
||
/// end to end by whoever holds an active MANAGE grant on it. The marker is
|
||
/// what separates that case from an ordinary adult who granted management
|
||
/// of their own record — the identity and reach surfaces open for the
|
||
/// former and stay shut for the latter, and the gate is this column rather
|
||
/// than the grant, so no argument about a grant level can reach them.
|
||
///
|
||
/// A timestamp rather than a boolean: it says since when, and preserves a
|
||
/// future-compatible null state without adding a transition surface here.
|
||
/// Cleared by the handover flow (`ManagedProfileHandover`): when the
|
||
/// person the profile describes claims it with credentials of their own,
|
||
/// the marker goes back to null in the same transaction that settles the
|
||
/// guardians' grants, so the account becomes an ordinary adult account.
|
||
/// The grants and activity trail stay intact across that transition.
|
||
///
|
||
/// Consumers land in this release: the guardian resolver in
|
||
/// `src/lib/api-handler.ts` reads it on every guardian request, and the
|
||
/// creation flow is what writes it. A later notification plan may add
|
||
/// delivery fanout after it defines consent and preference semantics.
|
||
managedProfileAt DateTime? @map("managed_profile_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 (never a GPS track) the nightly weather fetch resolves
|
||
// against when no travel override covers the day. Stored rounded to 1
|
||
// decimal (`roundCoarse`): about 11 km north-south and 7 km east-west at
|
||
// 50° N, a town or a city district. That is the privacy floor, and the
|
||
// weather reanalysis is gridded at 9 to 25 km, so nothing finer is needed.
|
||
// Up to v1.39.3 the rounding was 2 decimals (about 1 km, a neighbourhood);
|
||
// migration 0361 rounded the existing rows. 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).
|
||
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")
|
||
/// v1.42 — sealed home location (lat, lon, label)
|
||
/// under AAD `healthlog/environment-location/v1`. The only copy since
|
||
/// v1.43: migration 0409 dropped the plaintext columns it replaced.
|
||
homeLocationEncrypted Bytes? @map("home_location_encrypted")
|
||
/// v1.42 — per-user switch for the air-quality and
|
||
/// pollen part of the environment module. On by default; off means the
|
||
/// nightly fetch skips the air-quality feed and the surfaces leave the
|
||
/// fields out. The operator switch `ENVIRONMENT_AIR_QUALITY_DISABLED` wins.
|
||
environmentAirQualityEnabled Boolean @default(true) @map("environment_air_quality_enabled")
|
||
/// v1.42 — progress of the air-quality history backfill
|
||
/// (`src/lib/environment/air-quality-history.ts`): how many past days with
|
||
/// entries the source can cover, how many carry air quality, when it was
|
||
/// last checked and whether it is through. Written by the job only; read by
|
||
/// `GET /api/environment` for the settings card. Null until the first run.
|
||
environmentAqHistoryJson Json? @map("environment_aq_history_json")
|
||
|
||
// Codex OAuth (ChatGPT subscription)
|
||
codexAccessTokenEncrypted String? @map("codex_access_token_encrypted")
|
||
codexRefreshTokenEncrypted String? @map("codex_refresh_token_encrypted")
|
||
/// @internal: dead mirror of the expiry inside the encrypted Codex creds —
|
||
/// the refresh path reads the decrypted blob's own `expiresAt`
|
||
/// (`src/lib/ai/provider.ts`), never this column. Kept (and kept written)
|
||
/// for wire/backup compatibility rather than dropped.
|
||
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")
|
||
/// v1.43.7 — the cached AI insights payload (briefing, per-domain readings,
|
||
/// recommendations; JSON), AES-256-GCM binary layout under
|
||
/// `INSIGHTS_CACHED_TEXT_AAD`. Read and written only through
|
||
/// `src/lib/insights/cached-text-cipher.ts`.
|
||
insightsCachedTextEncrypted Bytes? @map("insights_cached_text_encrypted")
|
||
// Language the cached text was generated in. The cache is one slot per
|
||
// user, so a reader in another language must not be served it: the
|
||
// snapshot briefing lift and the advisor read treat a tag that differs
|
||
// from the reader's locale as an empty cache and re-warm in the reader's.
|
||
// NULL on rows written before the tag existed (treated as matching; the
|
||
// next write tags it).
|
||
insightsCachedLocale String? @map("insights_cached_locale")
|
||
// 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.43 — Dawarich movement context (per-user, encrypted at app level, part
|
||
// of the `environment` module). Base URL and API key sealed like the
|
||
// Nightscout pair; `dawarichPrefsEncrypted` seals
|
||
// `{ homePlaceId, homeCountry, derivedHomeCountry, dismissedSuggestionIds }`.
|
||
// Same SSRF floor as Nightscout: a private host needs its exact origin in
|
||
// `DAWARICH_PRIVATE_ORIGINS`; the switch below never grants it.
|
||
/// Sealed Dawarich base URL.
|
||
dawarichUrlEncrypted String? @map("dawarich_url_encrypted")
|
||
/// Sealed Dawarich API key.
|
||
dawarichApiKeyEncrypted String? @map("dawarich_api_key_encrypted")
|
||
/// Sealed home place id, home country and dismissed travel suggestions.
|
||
dawarichPrefsEncrypted Bytes? @map("dawarich_prefs_encrypted")
|
||
/// @internal: display only; whether the operator list approved the instance at connect time. Egress is re-decided from `DAWARICH_PRIVATE_ORIGINS` on every request, never from this column.
|
||
dawarichAllowPrivateHost Boolean @default(false) @map("dawarich_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.42.3 — Oura collections the current grant does not cover, as a JSON
|
||
/// string array of collection names (`spo2`, `vo2max`, `cardio_age`,
|
||
/// `resilience`). Written by the sync when a collection answers 401/403 while
|
||
/// its siblings succeed with the same token, which is a scope the grant never
|
||
/// carried, not a dead token. The Oura card reads it to ask for one
|
||
/// reconnect. Cleared on connect, disconnect and credential removal.
|
||
ouraScopeDenied Json? @map("oura_scope_denied")
|
||
/// v1.44 — how far the Oura history backfill has got: per collection, the
|
||
/// day its next window ends on, and whether it is done or refused for a
|
||
/// scope; plus where the account's history starts once learned. Written
|
||
/// after every window so a slice resumes where the last one stopped (see
|
||
/// `src/lib/oura/backfill-progress.ts`); kept after completion so a
|
||
/// reconnect reads only what the old grant refused.
|
||
ouraBackfillProgress Json? @map("oura_backfill_progress")
|
||
/// v1.44 — the Oura history backfill finished (self-converging discovery
|
||
/// predicate `oura_backfill_completed_at IS NULL`). Cleared on connect.
|
||
ouraBackfillCompletedAt DateTime? @map("oura_backfill_completed_at")
|
||
/// v1.44 — the Oura account id (`personal_info.id`) of the current grant,
|
||
/// read at connect when the `personal` scope is granted. A connect whose
|
||
/// account another record already holds is refused
|
||
/// (`src/lib/integrations/oauth-kit/account-link.ts`). Cleared on disconnect.
|
||
ouraUserId String? @map("oura_user_id")
|
||
/// v1.44 — which provider apps on this record were copied from a
|
||
/// guardian at connect, and from whom: `{ "<provider>": "<guardianUserId>" }`.
|
||
/// A managed profile has no settings page for its own app, so a guardian's is
|
||
/// borrowed (`shareGuardianClientApp`). The copy is removed again when the
|
||
/// provider is disconnected or that guardian no longer holds a live MANAGE
|
||
/// grant on the record (`src/lib/integrations/oauth-kit/connect-target.ts`).
|
||
clientAppSharedBy Json? @map("client_app_shared_by")
|
||
|
||
// 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")
|
||
// Where an unfinished backfill picks up: the start of the oldest activity
|
||
// the last run read. A history longer than one run's page ceiling is read
|
||
// in runs that each walk further back from here, instead of every run
|
||
// starting again at the newest page. Cleared when the backfill completes.
|
||
stravaBackfillResumeBefore DateTime? @map("strava_backfill_resume_before")
|
||
|
||
// 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.42 — per-user document vault presentation: the card/list view and
|
||
// the stacked/flowing month arrangement. Display-only, preserve-when-absent
|
||
// PUT on `/api/documents/inbound/layout`, resolved field by field on read
|
||
// (`src/lib/documents/documents-layout.ts`). Null = defaults (cards, stacked).
|
||
// Shape: { "version": 1, "view": "list", "arrangement": "flow" }
|
||
documentsLayoutJson Json? @map("documents_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")
|
||
// Per-user list presentation for the labs, illness and checkups lists:
|
||
// card/list view, manual order, and (labs only) the sort. One blob keyed by
|
||
// list, resolved field by field on read (`src/lib/settings/module-list-layout.ts`),
|
||
// preserve-when-absent PUT on `/api/settings/module-list-layout`. Null = the
|
||
// defaults. Earlier releases kept the same choices in the browser; the client
|
||
// copies a stored browser value up once when the server has none for that list.
|
||
// Shape: { "version": 1, "labs": { "view": "list", "order": ["<id>"], "sortDir": "manual" }, ... }
|
||
moduleListLayoutJson Json? @map("module_list_layout_json")
|
||
/// Per-user opt-in to preview features, one boolean per flag, all off by
|
||
/// default (`src/lib/settings/preview-features.ts`). Read and written
|
||
/// through `/api/settings/preview-features` and carried by the backup.
|
||
/// @pending-consumer(topics preview) the `topics` flag is stored, but no surface reads it and the Settings > System switch is unlisted until the topic overview pages ship.
|
||
previewFeaturesJson Json? @map("preview_features_json")
|
||
/// Optional activity goals the person set for themselves: workouts, active
|
||
/// minutes and steps per week, steps per month. Null or an absent key = no
|
||
/// goal. Parsed by `src/lib/validations/activity-goals.ts`, written through
|
||
/// `/api/activity/goals`, read by the activity calendar
|
||
/// (`/api/activity/calendar`), which reports progress against each goal set.
|
||
activityGoalsJson Json? @map("activity_goals_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.44 — keep the GPS track of a workout. OFF by default: a route is
|
||
/// where a person lives, works and runs, so it is stored only after they
|
||
/// opt in. Read by the workout batch route and the export.zip import, which
|
||
/// drop the track while it is off (the workout itself is always kept).
|
||
/// Never read by any AI path; the AI egress guard freezes the route readers.
|
||
storeWorkoutRoutes Boolean @default(false) @map("store_workout_routes")
|
||
// 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.33.1 (#470) — the OpenAI-compatible gateway provider (LiteLLM,
|
||
// OpenRouter, vLLM, any endpoint speaking `/v1/chat/completions`).
|
||
// Three DEDICATED columns rather than a reuse of `aiBaseUrl` /
|
||
// `aiOpenaiKeyEncrypted`: the OpenAI arm pins `api.openai.com` and must
|
||
// never be able to read a user-supplied host, and the gateway credential
|
||
// must never be the OpenAI key. Separate columns make that structural
|
||
// instead of a comment. The base URL passes the same SSRF floor as the
|
||
// Local provider (`isPublicUrl` unless the operator allowlisted the host).
|
||
aiCompatBaseUrl String? @map("ai_compat_base_url")
|
||
aiCompatKeyEncrypted String? @map("ai_compat_key_encrypted")
|
||
aiCompatModel String? @map("ai_compat_model")
|
||
|
||
// 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. The first-run redirect's gate; the needs-based setup flow
|
||
// (`OnboardingRecord`) keeps its own ledger and stamps this on confirm.
|
||
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
|
||
// 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.
|
||
// 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")
|
||
// What triggered the most recent accepted HealthKit batch — "foreground",
|
||
// "background" or "push", exactly as the client declared it on the write.
|
||
// Null when the client sent no trigger (an older build, or a batch that
|
||
// predates the field).
|
||
//
|
||
// `healthKitLastBackgroundSyncAt` dates the most recent batch that arrived
|
||
// WITHOUT the app being opened. It is the one instrument that tells "the
|
||
// phone is delivering on its own" apart from "every arrival happened while
|
||
// the app was on screen", which is otherwise indistinguishable from the
|
||
// outside — `healthKitLastSyncedAt` alone looks identical either way.
|
||
healthKitLastSyncTrigger String? @map("healthkit_last_sync_trigger")
|
||
healthKitLastBackgroundSyncAt DateTime? @map("healthkit_last_background_sync_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.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".
|
||
//
|
||
// Written by the client-push ingest boundaries only — the batch /
|
||
// bulk endpoints for measurements, mood and intakes (see
|
||
// `markSyncCheckpoint`). The handshake that reports it is a GET and
|
||
// writes nothing, so the checkpoint can only trail a real sync
|
||
// instead of running ahead of one.
|
||
lastSyncedAt DateTime? @map("last_synced_at")
|
||
|
||
// When the account's record was last replaced wholesale: a backup restore
|
||
// committed, or the person deleted all their data. `/api/sync/changes`
|
||
// answers `cursorExpired` to any cursor issued before it, because such a
|
||
// cursor can no longer be caught up incrementally: a restore writes rows
|
||
// back with their original `updatedAt` and removes rows without leaving a
|
||
// tombstone, so the delta from an old cursor would miss both.
|
||
syncResetAt DateTime? @map("sync_reset_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")
|
||
|
||
// The owner's saved report selection: the leaf-id inclusion list plus the
|
||
// format / range / charts choices the export panel restores. Shape:
|
||
// { "v": 2,
|
||
// "leaves": ["WEIGHT", "BLOOD_PRESSURE_SYS", "LAB_RESULTS", …],
|
||
// "format": "pdf" | "fhir" | "package",
|
||
// "rangeDays": number,
|
||
// "includeCharts": boolean }
|
||
// Null = never saved, which resolves to the EMPTY selection everywhere.
|
||
// Read by the export panel, the share-link create form, the FHIR REST face
|
||
// and the MCP doctor-visit surfaces.
|
||
//
|
||
// Replaces `doctorReportPrefsJson`, whose flat opt-out shape could not
|
||
// express "not chosen": an absent key resolved to included, so a caller who
|
||
// said nothing received the whole record. Membership is the whole contract
|
||
// here — absence is exclusion, and there is no defaults constant to drift.
|
||
reportSelectionJson Json? @map("report_selection_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")
|
||
|
||
/// The account's own AI usage level (#1217).
|
||
/// NULL follows the instance default (`AppSettings.aiUsageDefaultLevel`);
|
||
/// a stored level above `AppSettings.aiUsageMaxLevel` is kept but capped
|
||
/// at read time. Written by `PUT /api/auth/me/ai-usage`, resolved in
|
||
/// `src/lib/ai/usage-level.ts` and published as `ai.usage` on
|
||
/// `GET /api/auth/me`.
|
||
aiUsageLevel AiUsageLevel? @map("ai_usage_level")
|
||
|
||
// v1.18.0 — module enable/disable foundation.
|
||
//
|
||
// A DISABLED allowlist for the optional ("secondary") domains. The
|
||
// contract is default-on: a NULL column, an empty object, or a key
|
||
// simply absent all mean "module enabled". A key present and set to
|
||
// `false` means that module is OFF; `true` is redundant-but-allowed.
|
||
// No backfill — every existing user reads as all-on.
|
||
//
|
||
// Shape (string keys ⇒ boolean):
|
||
// { "mood": false, "glucose": false, ... }
|
||
//
|
||
// The canonical key set + which keys are toggleable lives in
|
||
// `src/lib/modules/registry.ts`; the resolver + 403 guard live in
|
||
// `src/lib/modules/gate.ts`. CORE domains (weight, blood pressure,
|
||
// pulse, medications — the measurement engine + meds) are NEVER
|
||
// present here and the gate refuses to disable them, so a crafted
|
||
// `{ "weight": false }` blob cannot turn the core engine off.
|
||
//
|
||
// Two modules do NOT resolve from this column to avoid a double
|
||
// source of truth: `cycle` delegates to `CycleProfile`/`isCycleEnabled`
|
||
// and `coach` delegates to `User.disableCoach` + the operator-level
|
||
// assistant master flag. The column may still carry a `cycle`/`coach`
|
||
// key for forward-compat but the gate ignores it for those two.
|
||
//
|
||
// Persisted via `PATCH /api/auth/me/modules` with field-by-field
|
||
// merge semantics (no mass assignment); surfaced resolved on
|
||
// `GET /api/auth/me` as a `modules: { <key>: boolean }` map.
|
||
modulePreferencesJson Json? @map("module_preferences_json")
|
||
|
||
// v1.35.0 — which pillars count toward the composite Health Score.
|
||
//
|
||
// A DESELECTION list, the same posture as `modulePreferencesJson`:
|
||
// { "excludedPillars": ["SLEEP"], "version": 2, "changedAt": "<ISO>" }
|
||
//
|
||
// A pillar shipped after the person made their choice is absent from
|
||
// the list and therefore counts, so the score keeps its "everything
|
||
// eligible" starting point with no backfill. Storing the positive
|
||
// selection instead would make a newly shipped pillar look exactly
|
||
// like one the person deliberately removed.
|
||
//
|
||
// NULL means the person never chose and is deliberately distinct from
|
||
// a list naming every pillar ("count nothing"): only the first may
|
||
// inherit a later change of defaults.
|
||
//
|
||
// `version` increments on every accepted write and, with `changedAt`,
|
||
// is the per-user recipe identity a score record and the weekly delta
|
||
// read to tell an authored change apart from a health movement.
|
||
//
|
||
// Schema + resolver: `src/lib/analytics/score/config.ts`. Written by
|
||
// `PATCH /api/auth/me/health-score-config` with field-by-field merge
|
||
// (no mass assignment) under the write-time breadth rule.
|
||
healthScoreConfigJson Json? @map("health_score_config_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[] @relation("SessionOwner")
|
||
// v1.36.0 — browser sessions belonging to OTHER accounts that are currently
|
||
// acting on this one. Named so the delete cascade has somewhere to point.
|
||
sessionsActingOnMe Session[] @relation("SessionActingAs")
|
||
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[]
|
||
// v1.30.34 — short-lived, token-bound step-up elevations. The Bearer-transport
|
||
// equivalent of `Session.mfaVerifiedAt`: a token client re-proves a factor and
|
||
// receives one single-use elevation good for one MFA-management call.
|
||
stepUpElevations StepUpElevation[]
|
||
measurements Measurement[]
|
||
medications Medication[]
|
||
intakeEvents MedicationIntakeEvent[]
|
||
medicationPauseEras MedicationPauseEra[]
|
||
medicationCategoryLabels MedicationCategoryLabel[]
|
||
medicationCourses MedicationCourse[]
|
||
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[]
|
||
moodContexts MoodContext[]
|
||
moodPredictions MoodPrediction[]
|
||
mentalHealthAssessments MentalHealthAssessment[]
|
||
moodTagsCustom MoodTag[] @relation("UserMoodTags")
|
||
moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
|
||
moodTagsHidden MoodTagHidden[]
|
||
notificationChannels NotificationChannel[]
|
||
pushSubscriptions PushSubscription[]
|
||
dataBackups DataBackup[]
|
||
backupRestoreJobs BackupRestoreJob[]
|
||
offhostBackupState OffhostBackupState?
|
||
backupPassAttempts BackupPassAttempt[]
|
||
documentThumbnailFailures DocumentThumbnailFailure[]
|
||
achievements UserAchievement[]
|
||
recommendationFeedback RecommendationFeedback[]
|
||
idempotencyKeys IdempotencyKey[]
|
||
devices Device[]
|
||
integrationStatuses IntegrationStatus[]
|
||
integrationWatches IntegrationWatch[]
|
||
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[]
|
||
medicationIntakeImportJobs MedicationIntakeImportJob[] @relation("MedicationIntakeImportRecord")
|
||
// 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[]
|
||
// v1.34.0 — per-(user, type, local day) cumulative hourly profile. The
|
||
// shape the nightly cumulative drain folds out of the per-sample rows
|
||
// immediately before deleting them, so a "typical by 21:00" comparison
|
||
// has a substrate for days older than the 36-hour grace window.
|
||
intradayCumulativeProfiles IntradayCumulativeProfile[]
|
||
// 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.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`).
|
||
pushAttemptsForRecord PushAttempt[] @relation("PushAttemptRecordUser")
|
||
pushAttemptsReceived PushAttempt[] @relation("PushAttemptRecipientUser")
|
||
notificationEvents NotificationEvent[]
|
||
notificationEgressAuthorizationsForRecord NotificationEgressAuthorization[] @relation("NotificationEgressAuthorizationRecordUser")
|
||
notificationEgressAuthorizationsReceived NotificationEgressAuthorization[] @relation("NotificationEgressAuthorizationRecipientUser")
|
||
telegramReminderMessages TelegramReminderMessage[]
|
||
// 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.36.0 — account sharing. Two relations because a person is on both sides
|
||
// of the arrangement: `accountGrantsGiven` are the grants this account made
|
||
// over its OWN record, `accountGrantsReceived` are the records this account
|
||
// may open. Both cascade on delete of this row — a grant naming a user who
|
||
// no longer exists is a dangling authorization.
|
||
accountGrantsGiven AccountGrant[] @relation("AccountGrantsGiven")
|
||
accountGrantsReceived AccountGrant[] @relation("AccountGrantsReceived")
|
||
// 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.39.0 — the per-metric status notes, one encrypted row per
|
||
// (metric, locale). A regenerable cache; it used to live in `audit_logs`.
|
||
insightStatusCaches InsightStatusCache[]
|
||
// 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[]
|
||
workoutInsights WorkoutInsight[]
|
||
workoutInsightGenerationClaims WorkoutInsightGenerationClaim[]
|
||
// 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.37.20 — completion ledger behind the reminders (iOS #68): one
|
||
// append-only row per satisfy / skip. Cascade-deleted with the account.
|
||
measurementReminderEvents MeasurementReminderEvent[]
|
||
// 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), not synced and not in FHIR. Correlation discovery
|
||
// reads a metric only after the account explicitly opts it in.
|
||
customMetrics CustomMetric[]
|
||
customMetricEntries CustomMetricEntry[]
|
||
correlationPatterns CorrelationPattern[]
|
||
// v1.35.0 — one row per local day holding the health score as it was shown
|
||
// that day. Append-only: a later recomputation of the same day never
|
||
// rewrites the row it finds.
|
||
healthScoreRecords HealthScoreRecord[]
|
||
// 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[]
|
||
symptomDefinitions SymptomDefinition[]
|
||
symptomEvents SymptomEvent[]
|
||
// 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[]
|
||
// v1.39.2 — source keys of imported documents that the tombstone purge
|
||
// removed, so a re-run of an importer cannot bring a deleted document back.
|
||
documentImportKeys DocumentImportKey[]
|
||
// v1.39.2 — further source keys an import answered with an existing
|
||
// document (same bytes under another key). See DocumentSourceAlias.
|
||
documentSourceAliases DocumentSourceAlias[]
|
||
// v1.39.3 — the person's own connection to a Paperless-ngx or Papra
|
||
// instance for the document picker. See DocumentSourceConnection.
|
||
documentSourceConnections DocumentSourceConnection[]
|
||
documentAiRuns DocumentAiRun[]
|
||
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.38.0 — visits. `Encounter` is one contact with the healthcare
|
||
// system (happened or booked), `Practitioner` the account's own address
|
||
// book, and the three link tables are what the visit produced. No
|
||
// module gate: a visit is core, like the checkups page it lives on.
|
||
encounters Encounter[]
|
||
practitioners Practitioner[]
|
||
encounterDocumentLinks EncounterDocumentLink[]
|
||
encounterLabLinks EncounterLabLink[]
|
||
encounterConditionLinks EncounterConditionLink[]
|
||
// v1.38.0 — the immunization log. One row per administered dose, plus the
|
||
// link table that files the scanned Impfpass page beside it. Gated by the
|
||
// `vaccinations` module key, which governs the surfaces and not the store.
|
||
vaccinationRecords VaccinationRecord[]
|
||
vaccinationDocumentLinks VaccinationDocumentLink[]
|
||
// v1.42 — user-defined vaccines for products the shipped catalogue does
|
||
// not list (#1005).
|
||
customVaccines CustomVaccine[]
|
||
// v1.42 — per-HealthKit-type arrival ledger (#1173).
|
||
healthKitTypeSyncs HealthKitTypeSync[]
|
||
// v1.42 — managed-profile handover links (#959): the profile being handed
|
||
// over, and the guardian who minted the link.
|
||
handoversOfProfile ManagedProfileHandover[] @relation("HandoverProfile")
|
||
handoversCreated ManagedProfileHandover[] @relation("HandoverCreator")
|
||
// v1.42 — dated anchors in the person's life for the timeline (#613): a
|
||
// move, a birth, a new job. Shared under the `profile` domain; never sent to
|
||
// a model.
|
||
lifeEvents LifeEvent[]
|
||
// v1.43 — biomarker aliases and the merge ledger (D1223), supplement
|
||
// composition, Dawarich daily values, and the relation links that close
|
||
// the remaining pairs of the relation matrix.
|
||
biomarkerAliases BiomarkerAlias[]
|
||
biomarkerMerges BiomarkerMerge[]
|
||
medicationIngredients MedicationIngredient[]
|
||
dawarichDays DawarichDay[]
|
||
labResultDocumentLinks LabResultDocumentLink[]
|
||
encounterMedicationLinks EncounterMedicationLink[]
|
||
encounterVaccinationLinks EncounterVaccinationLink[]
|
||
medicationDocumentLinks MedicationDocumentLink[]
|
||
medicationConditionLinks MedicationConditionLink[]
|
||
measurementFoldRepair MeasurementFoldRepair?
|
||
|
||
// 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.39 — the needs-based onboarding's answers and step ledger for THIS
|
||
// record. 1:1, and keyed by record rather than by the person at the
|
||
// keyboard so a guardian running a managed profile's setup writes the
|
||
// child's row, not their own. See `OnboardingRecord`.
|
||
onboardingRecord OnboardingRecord?
|
||
|
||
// v1.15.20 — user-authored "about me" context for the AI surfaces
|
||
// (1:1, encrypted free text). See `UserHealthProfile`.
|
||
healthProfile UserHealthProfile?
|
||
healthProfileFactRevisions HealthProfileFactRevision[]
|
||
// 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
|
||
|
||
/// Migration 0217 created the physical type as `environment_location_source`,
|
||
/// like every other enum in this schema. This declaration carried no `@@map`,
|
||
/// so the client compiled every write against `public."EnvironmentLocationSource"`
|
||
/// — a type no database has ever had — and each insert into `environment_contexts`
|
||
/// died on `42704` before a row could commit. The map names the type that
|
||
/// exists; no migration follows, because the database was never the side that
|
||
/// drifted. `prisma-enum-mapping-guard.test.ts` now holds the whole schema to
|
||
/// this rule.
|
||
@@map("environment_location_source")
|
||
}
|
||
|
||
/// 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
|
||
|
||
/// v1.42 — sealed coarse resolved location for the day (lat/lon rounded to
|
||
/// 1 decimal, about 11 km, + label), AAD `healthlog/environment-location/v1`.
|
||
/// The only copy since v1.43: migration 0409 dropped the plaintext columns.
|
||
locationEncrypted Bytes? @map("location_encrypted")
|
||
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")
|
||
|
||
// v1.42 (#615) — air quality, pollen and UV from the Open-Meteo
|
||
// air-quality feed (CAMS). All nullable: a partial or absent feed stores
|
||
// what it has, and a day the feed never covered stays NULL, never zero.
|
||
/// Daily apparent ("feels-like") maximum (°C).
|
||
apparentMax Float? @map("apparent_max")
|
||
/// PM2.5 daily mean (µg/m³).
|
||
pm25Mean Float? @map("pm25_mean")
|
||
/// PM2.5 hourly maximum over the local day (µg/m³).
|
||
pm25Max Float? @map("pm25_max")
|
||
/// PM10 daily mean (µg/m³).
|
||
pm10Mean Float? @map("pm10_mean")
|
||
/// Nitrogen dioxide daily mean (µg/m³).
|
||
no2Mean Float? @map("no2_mean")
|
||
/// Sulphur dioxide daily mean (µg/m³).
|
||
so2Mean Float? @map("so2_mean")
|
||
/// Carbon monoxide daily mean (µg/m³).
|
||
coMean Float? @map("co_mean")
|
||
/// Ozone, maximum 8-hour running mean of the day (µg/m³).
|
||
o3Max8h Float? @map("o3_max_8h")
|
||
/// European AQI, daily maximum.
|
||
eaqiMax Float? @map("eaqi_max")
|
||
/// US AQI, daily maximum.
|
||
usaqiMax Float? @map("usaqi_max")
|
||
/// UV index, daily maximum.
|
||
uvIndexMax Float? @map("uv_index_max")
|
||
/// Saharan dust, daily maximum (µg/m³).
|
||
dustMax Float? @map("dust_max")
|
||
/// Aerosol optical depth, daily maximum.
|
||
aodMax Float? @map("aod_max")
|
||
/// Alder pollen, daily maximum (grains/m³, Europe only).
|
||
pollenAlderMax Float? @map("pollen_alder_max")
|
||
/// Birch pollen, daily maximum (grains/m³, Europe only).
|
||
pollenBirchMax Float? @map("pollen_birch_max")
|
||
/// Grass pollen, daily maximum (grains/m³, Europe only).
|
||
pollenGrassMax Float? @map("pollen_grass_max")
|
||
/// Mugwort pollen, daily maximum (grains/m³, Europe only).
|
||
pollenMugwortMax Float? @map("pollen_mugwort_max")
|
||
/// Olive pollen, daily maximum (grains/m³, Europe only).
|
||
pollenOliveMax Float? @map("pollen_olive_max")
|
||
/// Ragweed pollen, daily maximum (grains/m³, Europe only).
|
||
pollenRagweedMax Float? @map("pollen_ragweed_max")
|
||
/// Which air-quality model domain served the day (`cams_europe` / `cams_global`).
|
||
aqDomain String? @map("aq_domain")
|
||
/// Hourly air-quality samples the day was aggregated from (75 % rule).
|
||
aqHours Int? @map("aq_hours")
|
||
/// When the air-quality part was last fetched; NULL = not yet fetched (gap index).
|
||
aqFetchedAt DateTime? @map("aq_fetched_at")
|
||
|
||
/// 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])
|
||
// `environment_contexts_aq_gap_idx` (user_id, date) WHERE aq_fetched_at IS
|
||
// NULL is a partial index from migration 0381 (raw SQL only).
|
||
@@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")
|
||
|
||
/// v1.42 — sealed coarse location (lat, lon, label) for the period, AAD
|
||
/// `healthlog/environment-location/v1`. The only copy since v1.43:
|
||
/// migration 0409 dropped the plaintext columns.
|
||
locationEncrypted Bytes? @map("location_encrypted")
|
||
|
||
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)
|
||
|
||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||
@@index([userId])
|
||
@@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")
|
||
/// When this session last re-proved a credential through `POST
|
||
/// /api/auth/reproof` (any method). Read by `requireRecentProof`, which on an
|
||
/// account without a second factor accepts it as the fresh proof in front of
|
||
/// exports, share links, token minting and the admin data actions. A
|
||
/// second-factor proof also stamps `mfaVerifiedAt`; a password proof stamps
|
||
/// only this, so it never satisfies a gate that asks for a second factor.
|
||
reproofAt DateTime? @map("reproof_at")
|
||
// v1.23 — sliding "last seen" for the user-facing active-session list.
|
||
lastActiveAt DateTime? @map("last_active_at")
|
||
|
||
/// v1.36.0 — the cookie transport's acting-account carrier. NULL (the only
|
||
/// value any session has had until now) means the browser is acting as the
|
||
/// account it signed in as. A non-NULL value names the account whose record
|
||
/// this session is currently reading, and it is a SELECTOR, never an
|
||
/// authorization: the resolver joins it against a live grant on every single
|
||
/// request, so a value left here by a grant that has since ended confers
|
||
/// exactly nothing.
|
||
///
|
||
/// Server-side by construction — the column lives on a row the client cannot
|
||
/// address (the cookie carries a secret, not this id), and the only writer is
|
||
/// the switch endpoint, which validates the grant before it stamps.
|
||
///
|
||
/// The FK is ON DELETE SET NULL rather than CASCADE: deleting the OWNER must
|
||
/// not delete the delegate's browser session, it must put that browser back
|
||
/// into its own account.
|
||
actingAsUserId String? @map("acting_as_user_id")
|
||
|
||
/// v1.37.0 — how many times this session's record selector has moved.
|
||
///
|
||
/// Monotonic, starts at 0, and NEVER written by application code: migration
|
||
/// 0302 installs a `BEFORE UPDATE OF acting_as_user_id` trigger that sets it,
|
||
/// so the increment also happens on the paths where no application code runs
|
||
/// at all — the `ON DELETE SET NULL` referential action above, a raw fixture
|
||
/// `UPDATE`, an operator at `psql`. A convention that every writer must
|
||
/// remember is a convention those three break silently.
|
||
///
|
||
/// Consumer: the record-session fence
|
||
/// (`src/lib/sharing/record-session-fence.ts`), which refuses a cookie
|
||
/// request whose asserted epoch no longer matches the row. `0` additionally
|
||
/// means "this session has never been pointed at another record", which is
|
||
/// the fence's exemption for browser tabs that predate the feature; the
|
||
/// migration normalises existing rows so that invariant holds from the first
|
||
/// request after deploy.
|
||
recordEpoch Int @default(0) @map("record_epoch")
|
||
|
||
user User @relation("SessionOwner", fields: [userId], references: [id], onDelete: Cascade)
|
||
actingAs User? @relation("SessionActingAs", fields: [actingAsUserId], references: [id], onDelete: SetNull)
|
||
|
||
@@index([userId])
|
||
// Revocation clears every session pointing at the owner, and deleting an
|
||
// account nulls the same set. Both sweep on this column.
|
||
@@index([actingAsUserId])
|
||
// 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?
|
||
/// @internal: the row's own creation instant, supplied by this default —
|
||
/// `recordSignInDevice` (`src/lib/auth/login-alert.ts`) inserts the
|
||
/// fingerprint and the label and passes no timestamp. Nothing reads it. The
|
||
/// first sighting a USER sees is the `auth.login.new_device` audit row the
|
||
/// same function writes, rendered by the security-activity card, and that
|
||
/// row is removed on the audit-retention schedule. This column is the
|
||
/// ledger's own copy of the same instant, kept because the ledger outlives
|
||
/// the audit trail and an operator asking when a device first appeared on an
|
||
/// account has nowhere else to look. Deliberately reachable through the
|
||
/// database only: the ledger stores salted hashes and a coarse label
|
||
/// precisely so it has no user-facing surface. The backup does NOT select it
|
||
/// — the whole model is excluded (`src/lib/export/backup-plan.ts`), because
|
||
/// restoring a device-recognition ledger would pre-trust devices on a host
|
||
/// that has never seen them.
|
||
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])
|
||
@@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); clients send the raw HealthKit reading and the server scales it at ingest (see `apple-health-mapping.ts`). This line used to say the opposite, and the iOS client built against it, which cost every user their oxygen-saturation and body-fat readings until the importer learned to take both.
|
||
// ── 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.
|
||
// ── v1.44 — Apple Health activity rings, mindfulness, mood and AFib burden ──
|
||
// All five arrive over POST /api/measurements/batch and the export.zip import.
|
||
// The minute and hour types are cumulative (daily SUM); AFib burden and the
|
||
// State of Mind valence are spot readings.
|
||
EXERCISE_MINUTES // minutes — HealthKit `appleExerciseTime`. The activity ring's exercise minutes; cumulative, daily SUM.
|
||
STAND_MINUTES // minutes — HealthKit `appleStandTime`. Minutes spent standing; cumulative, daily SUM.
|
||
STAND_HOURS // count — HealthKit `HKCategoryTypeIdentifierAppleStandHour`. One row per hour in which the person stood (`stood` = 1; an `idle` hour is not stored); cumulative, daily SUM gives the stand-ring hours.
|
||
MINDFUL_MINUTES // minutes — HealthKit `HKCategoryTypeIdentifierMindfulSession`. Session length from the sample's own dates; cumulative, daily SUM.
|
||
STATE_OF_MIND_VALENCE // valence (−1..1) — HealthKit `HKStateOfMind.valence` (iOS 18+). How pleasant the logged emotion or mood felt, from very unpleasant (−1) to very pleasant (1). A self-report, not a measurement.
|
||
// ── v1.44 — room climate from a home automation system ──
|
||
// Pushed through the ingest token (`measurements:write`, source EXTERNAL),
|
||
// e.g. by a Home Assistant automation. Spot readings: the daily rollup
|
||
// carries mean, min and max. Indoor, so never mixed with the outdoor
|
||
// environment channels.
|
||
INDOOR_CO2 // ppm — indoor carbon dioxide concentration.
|
||
INDOOR_TEMPERATURE // celsius — indoor air temperature. Distinct from every body temperature type.
|
||
INDOOR_HUMIDITY // percent (0–100) — indoor relative humidity.
|
||
AFIB_BURDEN // percent (0–100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `atrialFibrillationBurden` (iOS 16+, AFib History). The share of time the watch estimated in AFib over a week. AWARENESS ONLY — never a HealthLog diagnosis.
|
||
|
||
@@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
|
||
/// v1.43 (#614) — AUDIT-C, the alcohol section of the life circumstances.
|
||
/// Stored here for the scoring, encryption and backup it shares with the
|
||
/// screeners, but never served by `/api/mental-health/assessments` (a
|
||
/// closed decoder in older app builds): read through
|
||
/// `GET /api/anamnesis/life-circumstances`, written through
|
||
/// `POST /api/anamnesis/audit-c`.
|
||
AUDIT_C
|
||
|
||
@@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
|
||
// v1.38.x — measurements pushed in through a `measurements:write` Bearer
|
||
// token: a home-automation bridge, a smart scale's own uploader, any
|
||
// third-party sender the user pointed at their own record. Like TELEGRAM
|
||
// and MCP this is a user-driven write rather than a server-owned
|
||
// integration, but it does not enter through the cookie/Bearer client
|
||
// write path, so it carries its own source label — a hand-typed reading
|
||
// and a bridged one were otherwise indistinguishable after the fact. Its
|
||
// own value also gives those writes an isolated idempotency namespace
|
||
// `(userId, type, EXTERNAL, externalId)`. No client may NAME it: the batch
|
||
// + single-POST surfaces refuse any body that asserts a source under this
|
||
// scope, and the value is resolved server-side. Unlike the server-owned
|
||
// sources above the rows stay value-editable, since the hardware is the
|
||
// user's own. iOS DTO must mirror this exact spelling so the
|
||
// /api/sync/changes delta feed decodes the rows.
|
||
EXTERNAL
|
||
// v1.42 — rows imported from an Android Health Connect export (#972).
|
||
// Server-owned: written only by the Health Connect import worker, never
|
||
// by a client, so the batch + single-POST surfaces reject it like IMPORT.
|
||
// iOS DTO must mirror this spelling so the /api/sync/changes delta feed
|
||
// decodes the rows.
|
||
HEALTH_CONNECT
|
||
|
||
@@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")
|
||
}
|
||
|
||
enum MeasurementAggregationProvenance {
|
||
HEALTHKIT_STATISTICS
|
||
EXPORT_XML_SOURCE_MAX
|
||
LEGACY_UNKNOWN
|
||
|
||
@@map("measurement_aggregation_provenance")
|
||
}
|
||
|
||
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")
|
||
// Authority of an aggregate stored under a `stats:*` identity. NULL for
|
||
// ordinary point/sample rows. Native HealthKit statistics outrank the
|
||
// source-day estimate derived from export.xml; migrated ambiguous rows are
|
||
// explicitly LEGACY_UNKNOWN until a native sync or archive reprocess repairs
|
||
// them.
|
||
aggregationProvenance MeasurementAggregationProvenance? @map("aggregation_provenance")
|
||
// Only set for BLOOD_GLUCOSE measurements. NULL for all others, and NULL
|
||
// for a BLOOD_GLUCOSE row whose writer has no classification to record —
|
||
// a sensor export, the Nightscout pull, the MCP tool, the Telegram capture.
|
||
// A CHECK constraint (migrations 0021 + 0274) enforces the type pairing in
|
||
// one direction only: a non-glucose row can never carry a context. Every
|
||
// per-context reader selects by equality against the four enum members, so
|
||
// a NULL context stays absent rather than reading as a classification.
|
||
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])
|
||
// 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.
|
||
// Because `updatedAt` is indexed here, an update that moves it can never
|
||
// be heap-only. The trigger `measurements_noop_update_keeps_stamps`
|
||
// (migration 0412) keeps `updatedAt` and `syncVersion` on an update that
|
||
// changes nothing else, so those stay HOT; the table also carries
|
||
// `fillfactor = 85` and a 5 % autovacuum threshold from the same migration.
|
||
@@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.
|
||
// `measurements_tombstone_purge_idx` is a partial index over `(deleted_at)
|
||
// WHERE deleted_at IS NOT NULL` — see migration 0276 (v1.33.0). Every
|
||
// other deleted_at index here is partial on IS NULL, which is the live-rows
|
||
// predicate and the opposite of what the nightly tombstone purge asks for;
|
||
// without this one the purge planned a sequential scan and could not finish
|
||
// inside statement_timeout. Raw SQL only (partial predicate not expressible
|
||
// in Prisma).
|
||
// `measurements_noted_live_idx` is a partial index over `(user_id,
|
||
// measured_at DESC, id DESC) WHERE deleted_at IS NULL AND (notes_encrypted
|
||
// IS NOT NULL OR notes IS NOT NULL)` — see migration 0397 (v1.42.2). The
|
||
// record search reads the notes on readings newest first; without it a
|
||
// record of millions of readings with a few notes walks every reading.
|
||
// Raw SQL only (partial predicate not expressible in Prisma).
|
||
@@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])
|
||
// The same physical recording can reach us through two doors with two
|
||
// different source ids: the `export.zip` importer identifies an Apple
|
||
// Watch strip by a hash of its content, the live `POST /api/insights/ecg`
|
||
// ingest by the device's own sample uuid. One device does not record two
|
||
// different strips at the same instant at the same sampling rate, so this
|
||
// is what the recording IS rather than what a source calls it, and it
|
||
// makes the convergence a guarantee instead of a convention.
|
||
@@unique([userId, source, recordedAt, samplingFrequency], map: "ecg_recordings_user_source_recorded_at_freq_key")
|
||
@@index([userId, recordedAt(sort: Desc)])
|
||
// The foreign key's own lookup: deleting a reading checks this column once
|
||
// per deleted row (migration 0362).
|
||
@@index([measurementId])
|
||
@@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")
|
||
/// The lowest and highest reading `value` of the bucket. `minValue` /
|
||
/// `maxValue` widen to a row's own range (`Measurement.valueMin` /
|
||
/// `valueMax`, the intra-hour range an hourly heart-rate statistic
|
||
/// carries); these do not, so a reader whose figure is over the readings
|
||
/// themselves (the full-history extremes in `feature-blocks.ts`) can fold
|
||
/// the DAY tier instead of the raw rows. Nullable to keep migration 0417
|
||
/// additive: the boot re-fold fills existing rows, new rows always carry
|
||
/// them, and a reader treats a type with a NULL in its range as uncovered.
|
||
readingMinValue Float? @map("reading_min_value")
|
||
readingMaxValue Float? @map("reading_max_value")
|
||
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")
|
||
}
|
||
|
||
// ─── Intraday cumulative profiles (v1.34.0) ────────────────
|
||
//
|
||
// The shape a cumulative day had, kept before the fold destroys it.
|
||
//
|
||
// A cumulative metric has no meaningful "latest reading" — a partial
|
||
// day's step count is a fragment, not a value — so the only honest
|
||
// comparison is against the same person's own typical total AT THE
|
||
// SAME LOCAL HOUR. That comparison needs an hourly substrate, and
|
||
// there was none: the nightly cumulative drain collapses per-sample
|
||
// Apple Health rows into one daily total after the 36-hour grace
|
||
// window and hard-deletes the originals in the same transaction.
|
||
// Today's curve was derivable; every older day's was already gone.
|
||
//
|
||
// So the drain now folds the rows it is about to delete into
|
||
// twenty-four running totals — one per local hour, index `h` carrying
|
||
// the total accumulated through the END of local hour `h` — and
|
||
// writes them here first. One compact row per user × type × local
|
||
// day, a few thousand rows a year across the supported types.
|
||
//
|
||
// This is a SIDECAR, never a reading. The canonical `stats:` daily
|
||
// total stays the measurement of record; nothing reads a value off
|
||
// this table that the measurement rows do not already carry. Days
|
||
// still inside the grace window have no row yet and are folded live
|
||
// from the raw rows on read, so the current day is always available.
|
||
model IntradayCumulativeProfile {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
type MeasurementType
|
||
/// Local calendar day the profile describes (`YYYY-MM-DD`), in the
|
||
/// user's timezone at fold time. Lexicographically ordered, so a
|
||
/// trailing-window read and the retention prune are both range scans.
|
||
dateKey String @map("date_key") @db.VarChar(10)
|
||
/// Twenty-four running totals in canonical units — element `h` is the
|
||
/// value accumulated from local midnight through the END of local hour
|
||
/// `h`, so the array is non-decreasing and `[23]` equals `dayTotal`.
|
||
/// A DST day still has exactly 24 slots: the repeated hour folds into
|
||
/// its slot and a skipped hour repeats its predecessor's running total,
|
||
/// which keeps every stored day index-comparable with every other.
|
||
hourlyCumulative Float[] @map("hourly_cumulative")
|
||
/// The day's total — `hourlyCumulative[23]`, denormalised so a coverage
|
||
/// check never has to read the array.
|
||
dayTotal Float @map("day_total")
|
||
/// How many raw per-sample rows backed this profile. A day folded from
|
||
/// two rows is not a shape; the baseline reader uses this to refuse such
|
||
/// a day rather than let it drag the median.
|
||
sampleCount Int @map("sample_count")
|
||
/// v1.43 (#77) — per-hour increments posted as
|
||
/// `stats:<HK>:<YYYY-MM-DD>T<HH>` rows (`intraday-hour-buckets.ts`), 24
|
||
/// slots in canonical units, -1 for an hour not posted. Empty for a day
|
||
/// the phone never posted hours for.
|
||
hourlyIncrements Float[] @map("hourly_increments")
|
||
/// What `hourlyCumulative` was built from: `PER_SAMPLE` (the fold of raw
|
||
/// rows) or `HOUR_BUCKETS` (the posted hours, once a day holds four).
|
||
/// CHECK in migration 0408. Read by the profile reader and the drain's
|
||
/// merge, which leaves a bucket-built day alone.
|
||
basis String @default("PER_SAMPLE")
|
||
/// How many hours the phone posted for the day; NULL when it posted none.
|
||
/// Below four the day keeps the per-sample fold (or no curve yet).
|
||
bucketCount Int? @map("bucket_count")
|
||
/// The IANA zone the day was bucketed in. A user who later moves zones
|
||
/// leaves behind profiles cut on the old clock; carrying the zone lets
|
||
/// the reader drop the mismatched days instead of silently comparing
|
||
/// hours that mean different things.
|
||
timezone String
|
||
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)
|
||
|
||
@@unique([userId, type, dateKey])
|
||
@@index([userId, type, dateKey(sort: Desc)])
|
||
@@index([dateKey])
|
||
@@map("intraday_cumulative_profiles")
|
||
}
|
||
|
||
// ─── 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?
|
||
insight WorkoutInsight?
|
||
insightGenerationClaim WorkoutInsightGenerationClaim?
|
||
|
||
/// 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.
|
||
/// @internal: legacy readable track, read only as a fallback for rows the
|
||
/// free-text encryption backfill has not reached yet (v1.39.4). Every write
|
||
/// since then goes to `geometryEncrypted` and leaves this NULL; the backfill
|
||
/// nulls it on the old rows. DROP this column in a later release, once the
|
||
/// backfill reports zero remaining rows on every instance.
|
||
geometry Json?
|
||
/// v1.39.4 — the same GeoJSON LineString, JSON text sealed as one binary
|
||
/// AES-256-GCM value (`encryptBytes`, labelled per purpose). Read through
|
||
/// `readRouteGeometry` in `src/lib/workouts/route-geometry-cipher.ts`.
|
||
geometryEncrypted Bytes? @map("geometry_encrypted")
|
||
/// 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)
|
||
|
||
// `workout_routes_geometry_backfill_idx` is a partial index over
|
||
// `(workout_id) WHERE geometry IS NOT NULL` — see migration 0361 for the
|
||
// encryption backfill's discovery scan. Raw SQL only (partial predicate not
|
||
// expressible in Prisma).
|
||
@@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")
|
||
}
|
||
|
||
// ─── Per-workout Activity Insight (v1.31.0) ─────────────────
|
||
//
|
||
// The grounded paragraph the workout-detail seam renders. Its OWN model
|
||
// rather than a column on `Workout` for one concrete reason: workout deletes
|
||
// are HARD deletes (see the note on the `Workout` unique above), so the row
|
||
// has to disappear with its workout. A column would go with it too, but a
|
||
// separate model also keeps the generation provenance (`inputHash`,
|
||
// `promptVersion`, `providerType`) off the hot workout row that every list
|
||
// read selects from, and gives the daily-cap count an index of its own.
|
||
//
|
||
// Written ONLY by the `workout-insight-generate` worker, which the arrival
|
||
// spine dispatches when a workout genuinely LANDS. Nothing on a read path
|
||
// ever creates one: a workout with no row renders no card, and that includes
|
||
// every workout that predates this model.
|
||
model WorkoutInsight {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
workoutId String @unique @map("workout_id")
|
||
/// AES-256-GCM ciphertext of the generated paragraph. Follows the
|
||
/// `InsightNarrative.encryptedContent` precedent, NOT the plaintext pattern
|
||
/// the status cards use. Registered in `src/lib/crypto/encrypted-columns.ts`
|
||
/// and rotated by `scripts/rotate-encryption-key.ts`.
|
||
paragraphEncrypted Bytes @map("paragraph_encrypted")
|
||
/// Fingerprint of the deterministic evidence the paragraph was generated
|
||
/// from, plus the prompt version. A re-sync of the same session hashes
|
||
/// identically and costs nothing.
|
||
inputHash String @map("input_hash")
|
||
promptVersion String @map("prompt_version")
|
||
providerType String? @map("provider_type")
|
||
/// The UI locale the paragraph was WRITTEN in. Provenance only: a later
|
||
/// language switch does not regenerate, because a read path may never
|
||
/// trigger a provider call. Stored so that is visible rather than guessed.
|
||
locale String
|
||
generatedAt DateTime @map("generated_at")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade)
|
||
|
||
/// The per-day cap count reads this: rows for one user in a generatedAt
|
||
/// window.
|
||
@@index([userId, generatedAt])
|
||
@@map("workout_insights")
|
||
}
|
||
|
||
// Durable ownership and local-day capacity reservation for workout insight
|
||
// generation. The row is created before expensive evidence work. A live
|
||
// `claimId` owns the pre-provider lease; `providerInvokedAt` is the terminal
|
||
// boundary after which retries must never spend again.
|
||
model WorkoutInsightGenerationClaim {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
/// Null once the workout is deleted. The row stays as the day's ledger so
|
||
/// the delete cannot free an insight slot; the retention job drops it after
|
||
/// a week.
|
||
workoutId String? @unique @map("workout_id")
|
||
localDate String @map("local_date")
|
||
claimId String? @unique @map("claim_id")
|
||
claimedAt DateTime? @map("claimed_at")
|
||
providerInvokedAt DateTime? @map("provider_invoked_at")
|
||
completedAt DateTime? @map("completed_at")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
workout Workout? @relation(fields: [workoutId], references: [id], onDelete: SetNull)
|
||
|
||
@@index([userId, localDate])
|
||
@@map("workout_insight_generation_claims")
|
||
}
|
||
|
||
// ─── 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.
|
||
/// @internal: audit-trail provenance written by the PR detection worker; no
|
||
/// SURFACE selects it: the record row itself carries the value, unit and
|
||
/// achievedAt a reader sees. The backup does select it
|
||
/// (`src/lib/export/awards-backup.ts`), because the column is a real foreign
|
||
/// key in the database even though no relation is declared here, so a
|
||
/// restore has to resolve it against the measurements it wrote instead of
|
||
/// writing it blind and failing the constraint.
|
||
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])
|
||
// The foreign key's own lookup: deleting a reading checks this column once
|
||
// per deleted row (migration 0362).
|
||
@@index([sourceMeasurementId])
|
||
@@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.39.1 (#1033) — per-medication intake tracking. `true` (the default,
|
||
/// and the backfill for every existing row) is the behaviour every
|
||
/// medication has always had. `false` keeps the medication as a record:
|
||
/// dose, dates and the schedule rows stay stored and shown as information,
|
||
/// but nothing derives a due dose from them — no reminder on any channel,
|
||
/// no projected or auto-missed slot, no place on the doses card or the
|
||
/// take-all flow, and no adherence figure anywhere (compliance, heatmap,
|
||
/// doctor report, Coach, BP-status gate, achievements, MCP). Unlike
|
||
/// `asNeeded` the schedule rows survive, so every schedule reader that
|
||
/// derives a due dose must honour this flag explicitly
|
||
/// (`src/lib/medications/intake-tracking.ts`); the structural guard
|
||
/// `src/__tests__/medication-intake-tracking-guard.test.ts` holds the list.
|
||
/// The medication wire serves such a medication with `schedules: []` and
|
||
/// the stored rows under `recordedSchedules`, so a client that arms local
|
||
/// reminders from `schedules` stays silent without an update. Switching
|
||
/// tracking off or back on archives a schedule revision at that instant
|
||
/// (the stretch in between archives with an empty payload), so the rate
|
||
/// paths never count the untracked stretch as missed.
|
||
trackIntake Boolean @default(true) @map("track_intake")
|
||
/// v1.42.2 — record every scheduled dose as taken without a tap. For a
|
||
/// person who takes a fixed dose reliably (evening supplements) and does not
|
||
/// want to confirm it each day. The `intake-auto-take` job writes a taken
|
||
/// intake for each slot of a tracked, active, unpaused medication once the
|
||
/// slot's time has passed, marked `autoRecorded`; a slot the person already
|
||
/// took, skipped or removed is never touched, and no slot before
|
||
/// `autoMarkTakenSince` is ever filled. While on, no medication reminder is
|
||
/// sent for it on any channel, and the hourly auto-miss pass leaves the
|
||
/// slots this job owns alone.
|
||
autoMarkTaken Boolean @default(false) @map("auto_mark_taken")
|
||
/// v1.42.2 — when `autoMarkTaken` was last switched on. The auto-take job
|
||
/// never fills a slot anchored before it, so switching the option on does
|
||
/// not rewrite the history before that moment. NULL while the option is off.
|
||
autoMarkTakenSince DateTime? @map("auto_mark_taken_since") @db.Timestamptz(6)
|
||
/// 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[]
|
||
/// v1.43 — composition per unit (supplements), and the relation links to
|
||
/// visits, documents and conditions (indication).
|
||
ingredients MedicationIngredient[]
|
||
encounterLinks EncounterMedicationLink[]
|
||
documentLinks MedicationDocumentLink[]
|
||
conditionLinks MedicationConditionLink[]
|
||
intakeImportJobs MedicationIntakeImportJob[]
|
||
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.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[]
|
||
/// v1.40 — the clinical category the person filed this medication under
|
||
/// (one row at most; absence reads as OTHER).
|
||
categoryAssignment MedicationCategoryAssignment?
|
||
/// v1.40 (#1024) — the medication's courses. Empty = chronic since
|
||
/// creation. `startsOn`/`endsOn` above project the latest one, written only
|
||
/// by `src/lib/medications/courses.ts`.
|
||
courses MedicationCourse[]
|
||
|
||
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([treatmentClass])
|
||
@@map("medications")
|
||
}
|
||
|
||
/// v1.40 — the clinical category of a medication (BLOOD_PRESSURE, VITAMIN,
|
||
/// MENTAL_HEALTH, ... or a `custom:<id>` key of one of the owner's
|
||
/// `MedicationCategoryLabel` rows). The table predates this model: it was a
|
||
/// raw-SQL side table the app created at runtime, which migration 0004 had
|
||
/// dropped as orphaned. Migration 0368 creates it `IF NOT EXISTS` with the
|
||
/// same DDL, so a database that already holds the runtime table converges
|
||
/// without drift. The foreign key keeps the runtime table's `NO ACTION` on
|
||
/// update. Absence of a row reads as OTHER.
|
||
model MedicationCategoryAssignment {
|
||
medicationId String @id @map("medication_id")
|
||
/// A built-in value from `MEDICATION_CATEGORY_VALUES` or `custom:<id>`.
|
||
/// Only `src/lib/medication-category.ts` reads or writes it.
|
||
category String @default("OTHER")
|
||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||
|
||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||
|
||
@@index([category])
|
||
@@map("medication_categories")
|
||
}
|
||
|
||
/// v1.40 (#1041) — a medication category the person named themselves, shown
|
||
/// next to the built-in ones. A medication filed under it carries the row's
|
||
/// `key` (`custom:<uuid>`) in `MedicationCategoryAssignment.category`; the
|
||
/// list and detail DTOs publish the decrypted label as `categoryLabel`.
|
||
/// Deleting a label moves its medications to OTHER in the same transaction,
|
||
/// so the app never writes a dangling key; one a restore leaves behind reads
|
||
/// as OTHER. Capped at 20 per account.
|
||
model MedicationCategoryLabel {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
/// `custom:<uuid>`, the handle the category column, the backup and the
|
||
/// iPhone app use. Never reused.
|
||
key String @unique
|
||
/// The person's own label, AES-256-GCM at rest. Never logged or audited.
|
||
labelEncrypted Bytes @map("label_encrypted")
|
||
/// Position in the picker and the filter row, ascending.
|
||
sortOrder Int @default(0) @map("sort_order")
|
||
/// false = hidden from the picker; medications filed under it keep it.
|
||
isActive Boolean @default(true) @map("is_active")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([userId, sortOrder])
|
||
@@map("medication_category_labels")
|
||
}
|
||
|
||
/// v1.40 (#1024) — one course of a medication: a calendar span the person
|
||
/// took it for (Tamiflu for a week now and then). Courses never overlap, and
|
||
/// every course but the latest-starting one has ended, so the latest course is
|
||
/// always the one `Medication.startsOn/endsOn` project (see
|
||
/// `src/lib/medications/course-window.ts`). A course carries no schedule or
|
||
/// dose of its own: a dose that changes with a course is a schedule revision
|
||
/// dated to its start. A one-time medication has at most one course.
|
||
model MedicationCourse {
|
||
id String @id @default(cuid())
|
||
medicationId String @map("medication_id")
|
||
/// Denormalised owner, so the wipe and the sharing scope address the row
|
||
/// directly. Always the medication's owner.
|
||
userId String @map("user_id")
|
||
/// First day, inclusive, on the person's clock.
|
||
startsOn DateTime @map("starts_on") @db.Date
|
||
/// Last day, inclusive. NULL = open (only the latest course may be open).
|
||
endsOn DateTime? @map("ends_on") @db.Date
|
||
/// Why this course, in the person's words. AES-256-GCM at rest.
|
||
noteEncrypted Bytes? @map("note_encrypted")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([medicationId, startsOn])
|
||
@@index([userId])
|
||
@@map("medication_courses")
|
||
}
|
||
|
||
/// 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
|
||
/// v1.37.10 (#219) — per-schedule inventory units consumed per dose. The
|
||
/// intake consumption hook counts UNITS (tablets, ampoules, puffs); a
|
||
/// split-dose plan takes a different unit count at different times of day
|
||
/// (a whole tablet in the morning, a half at noon). NULL inherits the
|
||
/// medication-level `Medication.unitsPerDose`, so every existing row keeps
|
||
/// its current behaviour. Same Decimal(10,4) grain + curated fraction /
|
||
/// whole-number posture as the medication-level column; the consume hook
|
||
/// matches a taken intake's slot to its schedule by wall-clock time and
|
||
/// reads this first, falling back to the medication level on NULL.
|
||
unitsPerDose Decimal? @map("units_per_dose") @db.Decimal(10, 4)
|
||
/// 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[]
|
||
|
||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||
@@index([medicationId])
|
||
@@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.42.2 — true when the `intake-auto-take` job recorded this dose
|
||
/// because the medication has `autoMarkTaken` on, not the person. It counts
|
||
/// as taken everywhere (compliance, history, Coach); history lists label it
|
||
/// "recorded automatically". Any explicit take or skip of the slot by the
|
||
/// person clears it.
|
||
autoRecorded Boolean @default(false) @map("auto_recorded")
|
||
/// 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)
|
||
/// v1.32.37 — client-supplied replay anchor, unique PER USER (migration
|
||
/// 0273). It carried a GLOBAL unique from 0001_init, which let one
|
||
/// user's key refuse another user's write outright and let an unscoped
|
||
/// lookup resolve to a stranger's row. Same grain as `(user_id,
|
||
/// external_id)` below: every read must key on `(userId, idempotencyKey)`.
|
||
idempotencyKey String? @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])
|
||
@@unique([userId, idempotencyKey])
|
||
@@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])
|
||
// `medication_intake_events_tombstone_purge_idx` is a partial index over `(deleted_at)
|
||
// WHERE deleted_at IS NOT NULL` — see migration 0276 (v1.33.0). Every
|
||
// other deleted_at index here is partial on IS NULL, which is the live-rows
|
||
// predicate and the opposite of what the nightly tombstone purge asks for;
|
||
// without this one the purge planned a sequential scan and could not finish
|
||
// inside statement_timeout. Raw SQL only (partial predicate not expressible
|
||
// in Prisma).
|
||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||
@@index([medicationId])
|
||
@@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.
|
||
/// The register dialog defaults it from the medication's delivery form
|
||
/// (INJECTION → PEN, ORAL → BLISTER, else OTHER).
|
||
///
|
||
/// The kind decides whether opening the container starts a
|
||
/// post-opening degradation clock: PEN and AMPOULE are one sealed
|
||
/// reservoir that the first dose breaches, so they carry the 30-day
|
||
/// in-use window; BLISTER / INHALER / BOTTLE / OTHER hold individually
|
||
/// sealed units (or an unidentified arrangement) and expire only on
|
||
/// their printed date. See `IN_USE_CLOCK_CONTAINER_TYPES` in
|
||
/// `src/lib/medications/inventory/state-machine.ts` for the per-member
|
||
/// reasoning.
|
||
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.
|
||
/// Read by the state machine: PEN / AMPOULE carry the post-opening
|
||
/// clock, the rest expire only on `printedExpiry`.
|
||
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 container is first opened. On a PEN / AMPOULE it
|
||
/// also sets the 30-day in-use clock per EMA EPAR §6.3; on the other
|
||
/// kinds it records that the pack is open and nothing more.
|
||
firstUseAt DateTime? @map("first_use_at")
|
||
/// Persisted = MIN(firstUseAt + 30 days, printedExpiry) for a
|
||
/// clock-bearing container, else printedExpiry alone. 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")
|
||
/// Marketing-authorisation holder / maker printed on the carton. Free
|
||
/// text: the field is a label the user reads back, not a lookup key, and
|
||
/// there is no authority list a self-hoster could be held to. Nullable
|
||
/// because every pre-existing row predates it and plain supply rows
|
||
/// (blisters, bottles) have no use for it.
|
||
manufacturer String?
|
||
/// Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text
|
||
/// and deliberately NOT parsed into a number + unit: pens state strength
|
||
/// per dose, per ml, or per cartridge depending on the product, and a
|
||
/// forced numeric split would lose which of the three it was. Nullable
|
||
/// for the same reason as `manufacturer`.
|
||
doseStrength String? @map("dose_strength")
|
||
/// 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).
|
||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||
@@index([medicationId])
|
||
@@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).
|
||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||
@@index([medicationId])
|
||
@@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())
|
||
recipientUserId String @map("recipient_user_id")
|
||
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")
|
||
|
||
recipientUser User @relation(fields: [recipientUserId], references: [id], onDelete: Cascade)
|
||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||
schedule MedicationSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([recipientUserId, medicationId, scheduleId, date, phase, timeOfDay], map: "telegram_reminder_messages_recipient_slot_key")
|
||
@@index([medicationId, date])
|
||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||
@@index([scheduleId])
|
||
@@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)
|
||
// v1.30.34 — step-up elevations bound to this token. Cascade-deleted with it.
|
||
stepUpElevations StepUpElevation[]
|
||
|
||
@@index([userId])
|
||
@@index([mcpConnectionId])
|
||
@@map("api_tokens")
|
||
}
|
||
|
||
// v1.30.34 — a step-up elevation: the Bearer-transport answer to
|
||
// `Session.mfaVerifiedAt`.
|
||
//
|
||
// The web gates second-factor management on a freshness stamp written onto a
|
||
// session row. A token client has no session row, so it had no way to express
|
||
// "the human holding this device just re-proved a factor" and the whole
|
||
// MFA-management surface was unreachable from a native client.
|
||
//
|
||
// A row here is that proof, and it is deliberately the weakest credential in
|
||
// the tree:
|
||
// - it is minted ONLY after a password or primary-passkey re-proof, so
|
||
// presenting a stolen token gains nothing;
|
||
// - `apiTokenId` binds it to the exact token that re-proved, so a second
|
||
// token — the same user's included — cannot redeem it;
|
||
// - `consumedAt` makes it single-use, claimed by a conditional UPDATE so two
|
||
// concurrent redemptions cannot both win;
|
||
// - `expiresAt` caps it at minutes, matching the cookie path's step-up window.
|
||
//
|
||
// Only the hash of the opaque `hle_` secret is stored, so a table dump yields
|
||
// nothing redeemable — the same posture as ApiToken, RefreshToken, and Session.
|
||
// Revoking the token strands every elevation bound to it by construction:
|
||
// redemption runs behind Bearer resolution, which refuses a revoked token.
|
||
model StepUpElevation {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
// The `ApiToken` row this elevation is bound to. Cascade so a deleted token
|
||
// cannot leave a redeemable elevation behind.
|
||
apiTokenId String @map("api_token_id")
|
||
// HMAC-SHA256 (`hashToken`) of the raw `hle_<64 hex>` secret. Unique so a
|
||
// redemption is a single indexed lookup and a collision cannot alias two rows.
|
||
tokenHash String @unique @map("token_hash")
|
||
// Which factor was re-proved: "password", "totp", "webauthn", or "passkey".
|
||
// This IS an authorisation input, not telemetry — see `FRESH_FACTOR_METHODS`
|
||
// in `@/lib/auth/step-up`. Only totp / webauthn / passkey satisfy a
|
||
// fresh-factor gate; password reaches what a plain cookie session reaches
|
||
// and stops there.
|
||
method String
|
||
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)
|
||
apiToken ApiToken @relation(fields: [apiTokenId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([userId])
|
||
@@index([apiTokenId])
|
||
@@index([expiresAt])
|
||
// NOTE: migration 0267 additionally creates a PARTIAL unique index —
|
||
// `(api_token_id) WHERE consumed_at IS NULL` — so at most one elevation per
|
||
// token is ever redeemable. Prisma cannot express a partial index, and a
|
||
// plain `@@unique([apiTokenId])` would be wrong: it would also forbid minting
|
||
// a second elevation after the first was legitimately consumed. The
|
||
// constraint therefore lives only in the migration, and the mint below
|
||
// handles its unique violation explicitly.
|
||
@@map("step_up_elevations")
|
||
}
|
||
|
||
// 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")
|
||
/// Consumed through the `creator` relation: the admin invites list
|
||
/// (`src/app/api/admin/invites/route.ts`) selects the creating admin's
|
||
/// id + username via it.
|
||
createdBy String @map("created_by")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
expiresAt DateTime @map("expires_at")
|
||
usedAt DateTime? @map("used_at")
|
||
/// LAST consumer only (overwritten on multi-use invites; the full history
|
||
/// lives in `InviteRedemption`). Consumed through the `consumer` relation
|
||
/// by the admin invites list (`src/app/api/admin/invites/route.ts`).
|
||
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])
|
||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||
@@index([createdBy])
|
||
@@index([usedBy])
|
||
@@map("invite_tokens")
|
||
}
|
||
|
||
/// v1.42 (#959) — a one-time link that hands a managed profile over to the
|
||
/// person it describes. Minted by a guardian with MANAGE on the profile; the
|
||
/// token (`hlp_` + 64 hex) is shown once and only its HMAC-SHA256 hash is
|
||
/// stored, the `InviteToken` scheme. Claiming sets credentials on the
|
||
/// profile's own `User` row (no data moves), clears `managedProfileAt` and
|
||
/// settles each guardian's grant by the proposal recorded here. At most one
|
||
/// open link per profile (partial unique index in migration 0383).
|
||
model ManagedProfileHandover {
|
||
id String @id @default(cuid())
|
||
/// The managed profile (`User` row with `managedProfileAt` set).
|
||
profileId String @map("profile_id")
|
||
/// The guardian who minted the link. Cleared when that account is deleted
|
||
/// (0390): a claimed handover keeps the new owner's pending decision, and
|
||
/// an unclaimed link without a creator is refused like any link whose
|
||
/// creator lost access.
|
||
createdById String? @map("created_by_id")
|
||
/// HMAC-SHA256 of the token, unique; the claim and its preview look the
|
||
/// link up by it (`src/lib/managed-profiles/handover.ts`).
|
||
tokenHash String @unique @map("token_hash")
|
||
/// The handover's state, versioned and read fail-closed by
|
||
/// `parseHandoverState`: the issuing guardian's proposal per guardian
|
||
/// (end / read / manage), and after the claim what each guardian was given
|
||
/// and the new owner's final decision. The pending-decision read on the new
|
||
/// owner's account is answered from here.
|
||
proposalsJson Json @map("proposals_json")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
/// Link expiry (1, 7 or 14 days after minting).
|
||
expiresAt DateTime @map("expires_at")
|
||
/// Set when the profile was claimed through the link; the token is spent.
|
||
usedAt DateTime? @map("used_at")
|
||
/// Set when a guardian withdrew the link, a newer link replaced it, or its
|
||
/// creator lost access.
|
||
revokedAt DateTime? @map("revoked_at")
|
||
|
||
profile User @relation("HandoverProfile", fields: [profileId], references: [id], onDelete: Cascade)
|
||
createdBy User? @relation("HandoverCreator", fields: [createdById], references: [id], onDelete: SetNull)
|
||
|
||
@@index([profileId])
|
||
// `managed_profile_handovers_open_idx` UNIQUE (profile_id) WHERE used_at IS
|
||
// NULL AND revoked_at IS NULL is a partial index from migration 0383.
|
||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||
@@index([createdById])
|
||
@@map("managed_profile_handovers")
|
||
}
|
||
|
||
/// v1.42 (#1173) — when each HealthKit type last arrived through the batch
|
||
/// ingest, and by which trigger. One row per (user, type), upserted by the
|
||
/// batch route; read by the Apple Health card's per-type freshness list.
|
||
/// Diagnostic state: rebuilt by the next sync, so it is not backed up.
|
||
model HealthKitTypeSync {
|
||
userId String @map("user_id")
|
||
/// A `MeasurementType` name for rows the measurement batch writes, or
|
||
/// `MEDICATION_DOSE` for Apple Health doses the intake bulk route
|
||
/// receives. Text, not the enum, since v1.42.4 (migration 0401) because a
|
||
/// dose is not a measurement type. Since v1.43 (ios #77) a
|
||
/// `backfill:<type>` row records when a `syncTrigger: "backfill"` batch
|
||
/// last carried the type (its `lastReceivedAt`, every other column NULL),
|
||
/// so a catch-up leaves the live row of the type untouched; live readers
|
||
/// skip the prefix (`healthkit-type-sync.ts`).
|
||
type String
|
||
/// Last batch that brought a new or changed row of this type. Since #1259
|
||
/// a batch of duplicates only does not move it (nor the trigger or the
|
||
/// background stamp); it moves `lastSeenDuplicateAt` instead.
|
||
lastReceivedAt DateTime @map("last_received_at")
|
||
/// `syncTrigger` of that batch
|
||
/// (`foreground` / `background` / `push` / `manual`), NULL when absent.
|
||
lastTrigger String? @map("last_trigger")
|
||
/// Last batch that inserted or updated a sample of
|
||
/// this type (as opposed to sending only duplicates).
|
||
lastNewSampleAt DateTime? @map("last_new_sample_at")
|
||
/// Last batch with `syncTrigger = background` that carried this type. A
|
||
/// later foreground, push or manual arrival leaves it standing; NULL when
|
||
/// none has (or only before v1.42.4, except a row whose latest arrival was
|
||
/// a background one, which migration 0401 seeded).
|
||
lastBackgroundAt DateTime? @map("last_background_at")
|
||
/// #1259 — last live batch that carried this type with duplicates only:
|
||
/// nothing new or changed, so not an arrival. NULL when none has since
|
||
/// migration 0471. Read by the Apple Health card, served on
|
||
/// `GET /api/integrations/healthkit` as `lastSeenDuplicateAt`.
|
||
lastSeenDuplicateAt DateTime? @map("last_seen_duplicate_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@id([userId, type])
|
||
@@map("healthkit_type_syncs")
|
||
}
|
||
|
||
// 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])
|
||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||
@@index([userId])
|
||
@@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 ("this token
|
||
/// was already exchanged into X").
|
||
/// @internal: audit-trail bookkeeping written by the rotation path
|
||
/// (`src/lib/auth/refresh-token.ts`); reuse-detection keys on
|
||
/// `usedAt`/`tokenHash`, so no code path reads this pointer.
|
||
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)
|
||
|
||
/// 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.
|
||
// v1.32.11 — this table now backs BOTH native handoff flows: the original
|
||
// OIDC SSO leg (`flow = "oidc"`) and the first-party web-handoff login the iOS
|
||
// app uses on a self-hosted domain (`flow = "web_login"`, iOS #65). The mint +
|
||
// consume core is shared (`src/lib/auth/native-handoff.ts`); the `flow`
|
||
// discriminator is written at mint and CHECKED at exchange, so an oidc code can
|
||
// never be redeemed at the web-login token route and vice versa.
|
||
model OidcNativeHandoff {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
codeHash String @unique @map("code_hash") // hashToken(hlh_<…>)
|
||
/// Which handoff flow minted this code — `oidc` (the SSO native leg) or
|
||
/// `web_login` (the first-party web-handoff, iOS #65). Defaults to `oidc` so
|
||
/// every pre-existing row keeps its meaning. The consume path filters on it,
|
||
/// making a cross-flow redemption structurally impossible (not merely
|
||
/// unobserved).
|
||
flow String @default("oidc") @map("flow")
|
||
/// 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")
|
||
/// v1.32 — durable per-category webhook subscription outcomes. The JSON
|
||
/// payload is versioned and validated before use; the timestamp mirrors the
|
||
/// earliest transient/pending category retry for indexed hourly discovery.
|
||
webhookSubscriptionState Json? @map("webhook_subscription_state")
|
||
webhookSubscriptionRetryAt DateTime? @map("webhook_subscription_retry_at")
|
||
/// v1.42.3 — what this connection's Withings API plan refuses (status 277),
|
||
/// per request family: `{ measures, sleepSummary, ecg }`, each
|
||
/// `{ unavailable, checkedAt }`. Lets the sync skip gated meastypes without
|
||
/// paying a refused round trip each time, re-probe them weekly, and lets the
|
||
/// Withings card name what the plan withholds. Versioned and validated on
|
||
/// read (`src/lib/withings/api-plan.ts`); reset on reconnect.
|
||
apiPlanState Json? @map("api_plan_state")
|
||
/// v1.43.11 — what the sleep sync learned reading each night's stage
|
||
/// timeline: `{ v: 1, nights: { "<start>:<end>": { empty, failed } } }`,
|
||
/// keyed by the night's unix span. `empty` counts reads that answered with
|
||
/// no stage, so a night a tracker never stages stops being read after a
|
||
/// few tries; `failed` marks a night whose read errored, so it is read
|
||
/// first on the next run. Pruned to the nights of the summary window.
|
||
/// Validated on read (`src/lib/withings/sleep-stage-reads.ts`); reset on
|
||
/// reconnect.
|
||
sleepStageReads Json? @map("sleep_stage_reads")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([webhookSubscriptionRetryAt])
|
||
@@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])
|
||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||
@@index([userId])
|
||
@@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? @unique @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])
|
||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||
@@index([userId])
|
||
@@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])
|
||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||
@@index([userId])
|
||
@@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")
|
||
/// Set once the one-shot sleep repair re-reads the bounded sleep history and
|
||
/// collapses the duplicates the earlier volatile segment key left behind.
|
||
/// Null = repair pending.
|
||
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("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])
|
||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||
@@index([userId])
|
||
@@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")
|
||
/// How far the in-flight history backfill has got
|
||
/// (`{ done: string[], cursors: { [collection]: { at, order, pages } },
|
||
/// ended?: { [collection]: { type, at, reason } } }`): the collections it
|
||
/// walked to the end, for each collection read page by page the edge it has
|
||
/// written up to, so the next slice resumes there instead of starting over,
|
||
/// and the collections it ended short of their history's start (the
|
||
/// horizon, or a walk that stopped getting anywhere). Once the backfill
|
||
/// completes only `ended` is kept, or null when nothing ended short; the
|
||
/// status routes publish it as `backfillEnded`. Reset on every reconnect.
|
||
backfillProgress Json? @map("backfill_progress")
|
||
/// One bounded, owner-scoped current sync run. This is replaced at the start
|
||
/// of every run and never retains provider payloads or historical runs.
|
||
syncProgress Json? @map("sync_progress")
|
||
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])
|
||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||
@@index([userId])
|
||
@@map("google_health_oauth_states")
|
||
}
|
||
|
||
/// One row per OAuth callback the connect flows have begun to act on, keyed on
|
||
/// a hash of the provider and the `state` it returned with. Inserting it is
|
||
/// the claim: a second request carrying the same `state` (a browser that sends
|
||
/// the callback twice, a reload, a retry) finds the row taken and never
|
||
/// exchanges the code again. An authorization code works once, and Oura
|
||
/// revokes the token the first exchange issued when it sees the code reused.
|
||
///
|
||
/// The claiming request records where it sent the browser, so the duplicate
|
||
/// sends it to the same place instead of to an error page. Holds no user id,
|
||
/// token or code: `location` is the app's own result URL (`?oura=connected`,
|
||
/// `?whoop=error&reason=…`). Rows outlive the state they guard by a few
|
||
/// minutes and are swept by expiry when a callback completes and by the daily
|
||
/// OAuth-state cleanup (`src/lib/jobs/oauth-callback-claim-cleanup.ts`).
|
||
model OAuthCallbackClaim {
|
||
/// sha256(provider + NUL + state), hex.
|
||
key String @id
|
||
provider String
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
expiresAt DateTime @map("expires_at")
|
||
/// When the claiming request finished, with or without a result URL.
|
||
settledAt DateTime? @map("settled_at")
|
||
/// The redirect the claiming request answered with. Null when it threw.
|
||
location String?
|
||
|
||
@@index([expiresAt])
|
||
@@map("oauth_callback_claims")
|
||
}
|
||
|
||
// ─── Mood Entries ─────────────────────────────────────────────
|
||
|
||
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
|
||
/// v1.37 — the five level-A self-state values, each 0-10 with its own
|
||
/// anchors. `MOOD_DIMENSIONS` (`src/lib/mood/dimensions.ts`) is the one
|
||
/// table binding a column to its label, its anchors and its orientation.
|
||
///
|
||
/// `score` above stays the 1-5 legacy axis and is not re-scaled: eleven
|
||
/// surfaces derive numbers from it on that assumption, three of them into
|
||
/// prose an assistant then states as fact. The two axes coexist by design.
|
||
/// `moodA1` is derived from the five-point label on every write, so a quick
|
||
/// check-in is a full entry rather than a degraded one; A2 to A5 are only
|
||
/// ever set by hand, and stay NULL when nobody answered them. Absence reads
|
||
/// as absence — a fabricated middle value would poison both the trend and
|
||
/// any later model trained on these columns.
|
||
///
|
||
/// Queryable plaintext, deliberately — the same trade `CycleDayLog`
|
||
/// documents at its `basalBodyTempC`. Encrypting them would remove the
|
||
/// trend read, the factor crosstab and every aggregate at once, and the
|
||
/// values are self-ratings on a fixed scale rather than prose. The free-text
|
||
/// note beside them stays encrypted, because that is where the prose is.
|
||
///
|
||
/// `stressA2` is stored literally, exactly as the user set it against the
|
||
/// anchors they read, so a higher value means more stress and a worse day.
|
||
/// A surface that needs "up is better" flips the sign through the `inverse`
|
||
/// flag on the dimension table, never inline. The same rule already governs
|
||
/// `MoodTag.inverse`, and it exists because a stored value that disagrees
|
||
/// with the label the user saw cannot be recovered afterwards.
|
||
///
|
||
/// A delegate holding a READ grant sees these columns, because a grant is
|
||
/// whole-record by design and carries no per-domain scope. That is
|
||
/// deliberate, not an oversight: withholding part of a record from a
|
||
/// delegate would be a new capability, not a field select.
|
||
///
|
||
/// None of these reaches the health score, and that too is a decision. The
|
||
/// WELLBEING pillar reads PHQ-9, GAD-7 and WHO-5 and cites the papers its
|
||
/// thresholds come from; a daily self-rated slider is a different kind of
|
||
/// evidence, and averaging the two would weaken the line that makes the
|
||
/// score worth showing to a doctor. The reasoning is written out at
|
||
/// `src/lib/analytics/score/wellbeing.ts`.
|
||
moodA1 Int? @map("mood_a1") // 0-10, pleasantness
|
||
stressA2 Int? @map("stress_a2") // 0-10, inverse-oriented
|
||
energyA3 Int? @map("energy_a3") // 0-10
|
||
connectionA4 Int? @map("connection_a4") // 0-10
|
||
stabilityA5 Int? @map("stability_a5") // 0-10
|
||
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")
|
||
// Provenance. MANUAL / WEB / TELEGRAM / DAYLIO / MCP, plus the legacy
|
||
// MOODLOG value carried by rows the retired moodLog bridge imported —
|
||
// those rows are user history and keep their label. The column default
|
||
// is legacy too; every writer sets `source` explicitly.
|
||
source String @default("MOODLOG")
|
||
// 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 (an iOS row
|
||
// UUID, 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[]
|
||
|
||
/// v1.38 — the day's context, when somebody chose to add one. A separate
|
||
/// optional row rather than columns here: it is genuinely sparse, it is
|
||
/// twenty fields wide, and no reader outside the context surfaces wants it
|
||
/// inline.
|
||
context MoodContext?
|
||
|
||
@@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])
|
||
// 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).
|
||
// `mood_entries_tombstone_purge_idx` is a partial index over `(deleted_at)
|
||
// WHERE deleted_at IS NOT NULL` — see migration 0276 (v1.33.0). Every
|
||
// other deleted_at index here is partial on IS NULL, which is the live-rows
|
||
// predicate and the opposite of what the nightly tombstone purge asks for;
|
||
// without this one the purge planned a sequential scan and could not finish
|
||
// inside statement_timeout. Raw SQL only (partial predicate not expressible
|
||
// in Prisma).
|
||
@@map("mood_entries")
|
||
}
|
||
|
||
/// v1.38 — the shape of the day around a mood entry: work, contacts, leisure,
|
||
/// and a notable event if there was one.
|
||
///
|
||
/// A 1:1 optional sub-row keyed by the entry it belongs to. Every field is
|
||
/// nullable and an entry with nothing filled in has no row at all — an empty
|
||
/// sub-object is not a row, because a table full of all-NULL rows would say
|
||
/// "asked and answered nothing" where the truth is "never asked".
|
||
///
|
||
/// **Nothing here is captured twice.** Sleep, activity and vitals belong to
|
||
/// the modules that own them and are resolved at read time
|
||
/// (`src/lib/mood/linked-context.ts`), never copied onto this row: a copy
|
||
/// would go stale the moment somebody corrected a sleep session, and then two
|
||
/// surfaces would disagree about the same night. Body symptoms and their
|
||
/// severity belong to the illness module (`IllnessDayLog`,
|
||
/// `IllnessSymptomLink`), which this row links to and does not duplicate.
|
||
///
|
||
/// The multi-select lists ride as JSON arrays in string columns, matching
|
||
/// `MoodEntry.tags`. That column shape is not queryable and is read whole —
|
||
/// acceptable here because every reader wants the entry's whole context
|
||
/// anyway, and the alternative is two join tables for what is a checkbox row.
|
||
/// A future filter over "days that included the partner" would need a real
|
||
/// column or a join, and would be a schema change, not a query change.
|
||
///
|
||
/// The numeric ranges are stated in the trailing comments and enforced in Zod,
|
||
/// not as CHECK constraints — the same trade `MoodEntryTagLink.rating`
|
||
/// documents: a later change to a scale must not strand rows written under the
|
||
/// old one.
|
||
///
|
||
/// A delegate holding a READ grant sees this row, including the decrypted
|
||
/// note, because a grant is whole-record by design and carries no per-domain
|
||
/// scope. That is deliberate and is stated here so nobody discovers it later:
|
||
/// withholding part of a record from a delegate would be a new capability, not
|
||
/// a field select.
|
||
model MoodContext {
|
||
/// The entry this context belongs to, and the primary key. One context per
|
||
/// entry, enforced by the key rather than by application code.
|
||
moodEntryId String @id @map("mood_entry_id")
|
||
/// Carried so the context can be queried and deleted per user without a
|
||
/// join through the entry. Every read still pins it.
|
||
userId String @map("user_id")
|
||
|
||
// ── Work ────────────────────────────────────────────────────
|
||
/// `off` | `partial` | `regular` | `overtime` — `WORK_STATUS_KEYS`.
|
||
workStatus String? @map("work_status")
|
||
workMinutes Int? @map("work_minutes") // 0-1440
|
||
overtimeMinutes Int? @map("overtime_minutes") // 0-1440
|
||
workLoad Int? @map("work_load") // 0-10, inverse-oriented (higher is heavier)
|
||
workSatisfaction Int? @map("work_satisfaction") // 0-10
|
||
|
||
// ── Contacts ────────────────────────────────────────────────
|
||
/// JSON array of `CONTACT_CIRCLE_KEYS`. Never scored: how many people
|
||
/// somebody saw is not a good or a bad number, which is why the experienced
|
||
/// quality below is its own field.
|
||
contactCircles String? @map("contact_circles")
|
||
/// `inPerson` | `phone` | `video` | `written` — `CONTACT_FORM_KEYS`.
|
||
contactForm String? @map("contact_form")
|
||
/// `brief` | `medium` | `extended` — `CONTACT_EXTENT_KEYS`.
|
||
contactExtent String? @map("contact_extent")
|
||
contactQuality Int? @map("contact_quality") // 0-10
|
||
contactSupport Int? @map("contact_support") // 0-10
|
||
|
||
// ── Leisure ─────────────────────────────────────────────────
|
||
/// JSON array of `LEISURE_CATEGORY_KEYS`.
|
||
leisureCategories String? @map("leisure_categories")
|
||
leisureMinutes Int? @map("leisure_minutes") // 0-1440
|
||
leisureJoy Int? @map("leisure_joy") // 0-10
|
||
leisureRecovery Int? @map("leisure_recovery") // 0-10
|
||
|
||
// ── A notable event ─────────────────────────────────────────
|
||
/// One of `EVENT_TYPE_KEYS`. One per entry: a day with two notable events
|
||
/// has two things worth writing in the note, and a multi-select would invite
|
||
/// ticking every box instead of naming the one that mattered.
|
||
eventType String? @map("event_type")
|
||
eventValence Int? @map("event_valence") // -5 to +5
|
||
/// When it happened, if the person knew. Absent is common and means absent.
|
||
eventAt DateTime? @map("event_at")
|
||
|
||
/// One free-text note for the whole context, AES-256-GCM at rest (Bytes, the
|
||
/// shared codec every other note column uses). One column rather than one
|
||
/// per section: four ciphertext columns would be four rotation entries and
|
||
/// four ways to forget one. Registered in `ENCRYPTED_COLUMNS` and in
|
||
/// `scripts/rotate-encryption-key.ts`.
|
||
notesEncrypted Bytes? @map("notes_encrypted")
|
||
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
moodEntry MoodEntry @relation(fields: [moodEntryId], references: [id], onDelete: Cascade)
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([userId])
|
||
@@map("mood_contexts")
|
||
}
|
||
|
||
/// What the account's own past days say a day like this one usually looks
|
||
/// like — one row per local day, written by a job and never by a person.
|
||
///
|
||
/// Four things about this table that a later reader would otherwise have to
|
||
/// infer, and would infer wrongly:
|
||
///
|
||
/// 1. **Written by the `mood-prognosis-refresh` job only. Never by a route,
|
||
/// and the user cannot overwrite it.** The self-assessment on `MoodEntry`
|
||
/// and the value here are two separate readings of the same day and they
|
||
/// stay separate: the deviation between them is the entire finding, and a
|
||
/// surface that let somebody correct the prognosis into agreement would
|
||
/// destroy it. Two tables make that structural rather than a convention,
|
||
/// and `src/__tests__/mood-prediction-write-surface-guard.test.ts` holds
|
||
/// the writer set to one file.
|
||
/// 2. **`features` holds the evidence, not a hint.** It is the JSON list of
|
||
/// the standardised contributions the fit actually used for that day, so
|
||
/// the "what the model weighted" line is generated from what happened
|
||
/// rather than recomputed at render time against a model that may since
|
||
/// have been re-fitted.
|
||
/// 3. **Deliberately not in the disaster-recovery backup.** It is a derived
|
||
/// artefact, recomputable in full from `MoodEntry` + `MoodContext` and the
|
||
/// linked modules, and the nightly job rebuilds it after a restore. The
|
||
/// verdict is written down in `DERIVED_MODELS`
|
||
/// (`src/lib/export/backup-plan.ts`), where the completeness guard reads
|
||
/// it, so this is a decision rather than an omission. It IS in the wipe
|
||
/// plan: a derived row is still a statement about somebody's days.
|
||
/// 4. **`n` and the band are part of the value.** A consumer that renders
|
||
/// `predicted` without `ciLow` / `ciHigh` / `n` is a defect: a forecast
|
||
/// built from thirty days and one built from three hundred look identical
|
||
/// on screen and mean very different things.
|
||
model MoodPrediction {
|
||
userId String @map("user_id")
|
||
/// YYYY-MM-DD in the account's own zone — the same day key and the same
|
||
/// anchoring `MoodEntry.date` carries, so the two readings of a day join
|
||
/// without a second timezone derivation.
|
||
date String
|
||
|
||
/// The expected pleasantness (A1) for that day, 0-10, on the account's own
|
||
/// past pattern. A counterfactual, never a measurement.
|
||
predicted Float
|
||
/// The band around it, from the rolling-origin validation residuals. Null
|
||
/// only when the validation produced no residuals at all, which the write
|
||
/// path treats as "no forecast" rather than as a bare point estimate.
|
||
ciLow Float? @map("ci_low")
|
||
ciHigh Float? @map("ci_high")
|
||
/// Complete days the fit was built from. Shown wherever `predicted` is.
|
||
n Int
|
||
|
||
/// Bumped whenever the feature set or the fit changes, so a re-fit is
|
||
/// legible in the stored rows instead of silently changing their meaning.
|
||
modelVersion String @map("model_version")
|
||
/// JSON array of `{ feature, contribution }` — the standardised coefficient
|
||
/// times that day's standardised value, per feature.
|
||
features String
|
||
computedAt DateTime @map("computed_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@id([userId, date])
|
||
@@map("mood_predictions")
|
||
}
|
||
|
||
// ─── 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 capture, 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.
|
||
///
|
||
/// v1.37 — `RATED` is frozen, not removed. The capture sheet stopped
|
||
/// offering factor ratings when the level-A columns arrived on `MoodEntry`:
|
||
/// a 1-5 score for "work stress" and a 0-10 stress self-state are different
|
||
/// questions, and asking both on one entry invites two answers to one
|
||
/// thing. Nothing was deleted — no tag row, no link, no rating — so every
|
||
/// existing score keeps rendering, keeps editing and keeps feeding the
|
||
/// factor crosstab. Re-enabling the offer on the create sheet is a decision
|
||
/// about what to ask, not a bug fix; do not restore it because it looks
|
||
/// like an omission.
|
||
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")
|
||
}
|
||
|
||
// ─── 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", "whoop") 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" | "whoop" | "fitbit" | …
|
||
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)
|
||
// Start of the current unbroken failure streak, whatever the kind. The
|
||
// per-kind buckets count attempts; this dates them, which is what tells a
|
||
// blip apart from a dead pipe. Cleared by a success that is entitled to
|
||
// clear it, by a reconnect, a disconnect, and a resume.
|
||
failingSinceAt DateTime? @map("failing_since_at")
|
||
// Every sync leg currently known to be failing, oldest failure first, as a
|
||
// JSON string array. A single slot could only ever remember one leg, so two
|
||
// legs of the same provider failing in overlapping windows erased each other
|
||
// and the row forgot one of them. A failure adds its leg; a success removes
|
||
// its own; the error state, the buckets and `failingSinceAt` clear only when
|
||
// the array is empty — see `recordSyncSuccess`.
|
||
//
|
||
// The LAST element is the leg that failed most recently and therefore owns
|
||
// whatever message `lastError` holds. When that leg recovers while others are
|
||
// still failing, `lastError` is cleared rather than left describing a leg that
|
||
// is now healthy.
|
||
//
|
||
// An empty array means "failing, but unattributed" (a single-leg provider, or
|
||
// a failure recorded without a leg), under which any success clears the row.
|
||
failingLegs Json? @map("failing_legs")
|
||
alertedAt DateTime? @map("alerted_at") // last time admin Telegram alert fired (idempotency guard)
|
||
// The last ten sync attempts, newest first, as a JSON array of
|
||
// `{ endpoint, status, at, errorCode }`. `endpoint` is the sync leg name the
|
||
// code passes (never a URL), `status` is `ok` or the failure kind, and
|
||
// `errorCode` is a short closed-shape code ("401", "invalid_grant") or null.
|
||
// No message, no token, no value: this is what the redacted diagnostics on
|
||
// the connection health read publish, so it is shaped to be safe to copy
|
||
// into a bug report. Written by `appendRecentCall` in
|
||
// `src/lib/integrations/status.ts`, read by `resolveConnectionHealth`.
|
||
recentCalls Json? @map("recent_calls")
|
||
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([state])
|
||
@@map("integration_statuses")
|
||
}
|
||
|
||
// What the integration watchdog (`src/lib/integrations/watchdog.ts`) knows
|
||
// about one connection: whether a source that normally delivers has gone
|
||
// silent, and whether an operator incident is open for it. One row per
|
||
// (user, connection) the watchdog has looked at. `integration` carries the
|
||
// connection key, which is the IntegrationStatus key set plus "apple-health",
|
||
// so it is a separate table rather than more columns on IntegrationStatus,
|
||
// whose readers assume every row is a polled provider.
|
||
//
|
||
// Operational, like IntegrationStatus: it records what this host observed, so
|
||
// it is excluded from backups and removed with the account's data.
|
||
model IntegrationWatch {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
integration String
|
||
/// Set while the source is unexpectedly silent: the newest data instant at
|
||
/// the moment the watchdog flagged it. Null when the source is not flagged.
|
||
/// The connection health read surfaces it as `degraded` /
|
||
/// `unexpected_silence` until data arrives again.
|
||
silentSince DateTime? @map("silent_since")
|
||
/// Reason code of the operator incident currently open for this connection,
|
||
/// null when none is. One incident at a time per connection.
|
||
incidentReason String? @map("incident_reason")
|
||
incidentOpenedAt DateTime? @map("incident_opened_at")
|
||
/// When the operator was told about the open incident, or when the watchdog
|
||
/// adopted an alert the sync ledger had already sent for the same streak.
|
||
/// Null while an incident is open but not yet announced.
|
||
alertedAt DateTime? @map("alerted_at")
|
||
checkedAt DateTime @default(now()) @map("checked_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, integration])
|
||
@@map("integration_watches")
|
||
}
|
||
|
||
// ─── 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
|
||
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")
|
||
|
||
// Delivery outcome of the last error report this host tried to send.
|
||
// Without it the admin card can only say a DSN is set and parses, which is
|
||
// not the same fact as reports arriving: a wrong public key, a wrong project
|
||
// id or an unreachable host all parse perfectly and deliver nothing.
|
||
/// Instant the last report left successfully. Consumer: the GlitchTip card's
|
||
/// badge, which is only green while this is inside the recent window.
|
||
glitchtipLastOkAt DateTime? @map("glitchtip_last_ok_at")
|
||
/// Instant the last attempt failed. Consumer: the GlitchTip card's status
|
||
/// line, which names the failure rather than showing a reassuring badge.
|
||
glitchtipLastFailureAt DateTime? @map("glitchtip_last_failure_at")
|
||
/// Short classification of that failure (`invalid_dsn`, `http_403`,
|
||
/// `network`) — never a response body, which is a remote host's text.
|
||
/// Consumer: the same status line.
|
||
glitchtipLastFailureReason String? @map("glitchtip_last_failure_reason")
|
||
|
||
// 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)
|
||
|
||
// The operator's assistant switches. The master stops every AI capability
|
||
// (model calls and model-written text alike); the four sub-switches each
|
||
// cover a cost or egress profile: the Coach, the daily briefing and period
|
||
// narratives, per-reading status notes (status text, workout notes, reaction
|
||
// lines) and reading documents (the document vault, lab scans, medication
|
||
// extraction). They never gate data: a switch that is off hides and stops AI
|
||
// work, and every measurement, score and statistic keeps loading. Resolved
|
||
// with every other AI layer in `src/lib/ai/capabilities/`, and published per
|
||
// record on `GET /api/auth/me` as `ai`. Defaults keep every capability on so
|
||
// an upgrade needs no admin action.
|
||
//
|
||
// The Coach switch is also the operator's only Coach switch: the Coach key in
|
||
// `moduleAvailabilityJson` was folded into it (migration 0343) and is ignored.
|
||
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")
|
||
assistantDocumentAiEnabled Boolean @default(true) @map("assistant_document_ai_enabled")
|
||
|
||
// v1.43 — the operator's AI usage bounds (#1217). `aiUsageMaxLevel` is the
|
||
// highest level an account may choose, `aiUsageDefaultLevel` the level an
|
||
// account without its own choice runs at (never above the ceiling),
|
||
// `aiSyncRegenGapHours` the minimum gap between status-card regenerations
|
||
// after a sync at `standard` (NULL = env `AI_SYNC_REGEN_GAP_HOURS`, else 6).
|
||
// Edited through `PUT /api/admin/settings/assistant-flags` (`aiUsage`).
|
||
/// Instance ceiling for the per-account AI usage level (resolved in
|
||
/// `src/lib/ai/capabilities/usage.ts`).
|
||
aiUsageMaxLevel AiUsageLevel @default(STANDARD) @map("ai_usage_max_level")
|
||
/// Level for accounts without their own choice.
|
||
aiUsageDefaultLevel AiUsageLevel @default(STANDARD) @map("ai_usage_default_level")
|
||
/// Sync regeneration gap in hours (1..48); NULL = env or 6. Read by the
|
||
/// status invalidator at the `standard` level.
|
||
aiSyncRegenGapHours Int? @map("ai_sync_regen_gap_hours")
|
||
|
||
// v1.41 — the operator's reasoning controls. `aiReasoningEnabled` switches
|
||
// provider-side reasoning off for every surface (the Coach and the three
|
||
// background jobs that use it); `aiReasoningMaxEffort` caps the level a
|
||
// person may pick (`low` | `medium` | `high`, an app-side closed enum in
|
||
// `src/lib/ai/reasoning/levels.ts`). Resolved once per turn or job by
|
||
// `src/lib/ai/reasoning/resolve.ts` and published per record on
|
||
// `GET /api/auth/me` as `coachReasoning`. Defaults keep reasoning on with no
|
||
// cap, so an upgrade needs no admin action.
|
||
aiReasoningEnabled Boolean @default(true) @map("ai_reasoning_enabled")
|
||
aiReasoningMaxEffort String @default("high") @map("ai_reasoning_max_effort")
|
||
|
||
// 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")
|
||
|
||
// The operator's confirmation that the encryption key is backed up, bound to
|
||
// the key it was given for: the active key id and its fingerprint (the first
|
||
// twelve hex characters of SHA-256 over the raw key bytes). A rotation or a
|
||
// re-keyed install changes either one, and the step is due again. Never the
|
||
// key itself. Written by `POST /api/admin/encryption/key-backup/confirm`;
|
||
// consumer: `readKeyBackupStatus` (the admin step, the overview tile and the
|
||
// dashboard banner for admins).
|
||
encryptionKeyBackupConfirmedAt DateTime? @map("encryption_key_backup_confirmed_at")
|
||
encryptionKeyBackupConfirmedKeyId String? @map("encryption_key_backup_confirmed_key_id")
|
||
encryptionKeyBackupConfirmedFingerprint String? @map("encryption_key_backup_confirmed_fingerprint")
|
||
/// Plain id, not a relation: the instance row must not become user-scoped,
|
||
/// and a deleted admin leaves the confirmation standing. Consumer: the admin
|
||
/// step names who confirmed when the account still exists.
|
||
encryptionKeyBackupConfirmedByUserId String? @map("encryption_key_backup_confirmed_by_user_id")
|
||
|
||
/// When this instance first stored a discovery finding under the seasonally
|
||
/// adjusted correlation engine (v1.42). Stamped once, by the first
|
||
/// `syncAcceptedPatterns` run of a discovery family, before it writes; null
|
||
/// until then. Consumer: `hadFindingsBeforeSeasonalAdjustment`, which counts
|
||
/// a discovery pattern created before it (or any, while it is null) as a
|
||
/// finding of the earlier engine and so earns the one-time note.
|
||
correlationSeasonalEngineSince DateTime? @map("correlation_seasonal_engine_since")
|
||
|
||
// v1.43 — pulse day-mean method (#1107 follow-up). `hour_mean_v1` weighs
|
||
// every hour alike; `weighted_hour_mean_v2` weighs an hour by
|
||
// `min(1, n / 3)`. Switched only by the admin apply step after a dry run;
|
||
// the dry-run result is the only thing a dry run writes.
|
||
/// Active pulse day-mean method (CHECK in migration 0407). Readers: every
|
||
/// process's mirror (`pulse-day-mean-method.ts`) and the rollup tier
|
||
/// (`storedHourWeightSql`); the admin status route.
|
||
pulseDayMeanMethod String @default("hour_mean_v1") @map("pulse_day_mean_method")
|
||
/// When the admin applied the current method; shown on the admin card.
|
||
pulseDayMeanAppliedAt DateTime? @map("pulse_day_mean_applied_at")
|
||
/// Last dry-run status and result (account ordinals only, no ids); the apply
|
||
/// step's precondition and the admin card's result.
|
||
pulseDayMeanDryRunJson Json? @map("pulse_day_mean_dry_run_json")
|
||
/// @internal: progress marker of the one-time workout climb backfill
|
||
/// (`src/lib/workouts/climb-backfill.ts`): the last workout id scanned,
|
||
/// `done` when finished, NULL before it starts. Operational, never shown.
|
||
workoutClimbBackfillCursor String? @map("workout_climb_backfill_cursor")
|
||
|
||
@@map("app_settings")
|
||
}
|
||
|
||
/// One known plaintext per configured encryption key id, sealed under that
|
||
/// key. At boot every configured id is checked against its row: a row that no
|
||
/// longer opens means the key in the environment is not the key this database
|
||
/// was written with, and the process refuses to serve instead of answering
|
||
/// with random errors. A missing row is written (`ON CONFLICT DO NOTHING`).
|
||
/// Deliberately outside key rotation: a canary belongs to its key id, and
|
||
/// re-sealing it under the active key would make every id's check test the
|
||
/// active key. Written and read only by `src/lib/crypto/canary.ts`.
|
||
model EncryptionKeyCanary {
|
||
keyId String @id @map("key_id")
|
||
ciphertext String
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
@@map("encryption_key_canaries")
|
||
}
|
||
|
||
// ─── Audit Log ─────────────────────────────────────────────
|
||
|
||
model AuditLog {
|
||
id String @id @default(cuid())
|
||
userId String? @map("user_id")
|
||
|
||
/// v1.36.0 — who acted, when that is not the person the row is filed under.
|
||
///
|
||
/// NULL means the account acted for itself, which is what every row written
|
||
/// before this column existed means and what almost every row written after
|
||
/// it will mean. The threading in `src/lib/auth/audit.ts` fills it only when
|
||
/// the request resolved an acting account, so a self-action can never grow
|
||
/// an actor id by accident — if it could, the reading of every historical
|
||
/// row would change under us.
|
||
///
|
||
/// A delegated action files under the OWNER (`userId`) with the delegate
|
||
/// here: it is the owner's record that was touched, and the owner's view of
|
||
/// "what happened to my data" has to contain it.
|
||
///
|
||
/// Deliberately NOT a foreign key. `userId` above is ON DELETE SET NULL, and
|
||
/// the same rule here would rewrite history rather than erase it — nulling
|
||
/// the actor turns "your daughter opened your record" into "you opened your
|
||
/// record" the moment her account is deleted. An id that no longer resolves
|
||
/// to a person renders as a deleted account; an id that silently became NULL
|
||
/// renders as a lie. The 365-day retention purge bounds how long either
|
||
/// lives.
|
||
actorUserId String? @map("actor_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
|
||
/// When the geo backfill (`src/lib/jobs/geo-backfill.ts`) last tried this
|
||
/// row. A row it could not complete waits `GEO_BACKFILL_RETRY_DAYS` before
|
||
/// the next try, so an address no database will ever place (a private
|
||
/// range behind a proxy) is not resolved again on every hourly pass. NULL:
|
||
/// never tried. The backfill reads its candidates through the partial index
|
||
/// `audit_logs_geo_backfill_idx` (migration 0418, raw SQL only: Prisma
|
||
/// cannot express its predicate).
|
||
geoAttemptedAt DateTime? @map("geo_attempted_at")
|
||
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)])
|
||
// v1.33.0 — the retention purge filters on `createdAt` alone. Every index
|
||
// above leads with `userId` or `action`, so the purge planned a sequential
|
||
// scan and could not finish inside statement_timeout on an instance with
|
||
// enough history. See migration 0276.
|
||
@@index([createdAt])
|
||
// v1.36.0 — the owner's "who touched my record" view reads
|
||
// `{ userId, actorUserId: { not: null } }` newest first. Every index above
|
||
// leads with `userId` and then `action` or `createdAt`, so that read would
|
||
// have to filter the whole of an account's history to find the handful of
|
||
// delegated rows in it. See migration 0294.
|
||
@@index([userId, actorUserId, 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])
|
||
@@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])
|
||
@@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_UPLOAD_<ms>
|
||
/// The whole encrypted copy as one value: how every backup was stored up
|
||
/// to v1.39.1. NULL for a copy stored in pieces (`chunks`), which is how
|
||
/// every backup is written from v1.39.2 on. A row carries one or the other.
|
||
data String?
|
||
/// How many pieces a chunked copy has, and the random id sealed into every
|
||
/// one of them (`src/lib/export/backup-chunks.ts`). The reader checks both
|
||
/// against the pieces before it releases a byte; NULL on a single-value copy.
|
||
chunkCount Int? @map("chunk_count")
|
||
chunkStreamId String? @map("chunk_stream_id")
|
||
/// The application key ids the ciphertext INSIDE this copy was written
|
||
/// under, recorded as the copy is written (`createBackupKeyIdTextScanner`).
|
||
/// Key rotation re-seals the copy's envelope but not what is inside it, so
|
||
/// a key stays needed while any copy lists it. NULL for a copy written
|
||
/// before v1.39.3, which the admin encryption view counts as unrecorded.
|
||
/// Consumer: `GET /api/admin/encryption/status` (`backups.stored`).
|
||
innerKeyIds String[] @default([]) @map("inner_key_ids")
|
||
/// Whether `innerKeyIds` was recorded; false on every copy written before
|
||
/// v1.39.3. Consumer: the same status read (`backups.unrecorded`).
|
||
innerKeyIdsRecorded Boolean @default(false) @map("inner_key_ids_recorded")
|
||
/// What the restore preview shows for this copy, worked out while the copy
|
||
/// was written (`src/lib/export/backup-preview.ts`): the section counts, the
|
||
/// schema version, and the inner key ids with the sections they sit in and
|
||
/// the shortest value of each section, so the key verdict is taken again against this
|
||
/// server's keys every time it is read. It names the copy it describes and
|
||
/// is ignored once the copy changes. Plain JSON on purpose: counts and dates
|
||
/// only, and the samples are ciphertext already, which a key rotation must
|
||
/// not re-seal because they stand for what is inside the copy. NULL for a
|
||
/// copy written before this column existed, until its first preview has
|
||
/// read it whole.
|
||
/// Consumer: `GET /api/admin/backups/{id}/summary`.
|
||
preview Json?
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
chunks DataBackupChunk[]
|
||
|
||
@@unique([userId, type], map: "data_backups_user_id_type_key")
|
||
@@map("data_backups")
|
||
}
|
||
|
||
/// One piece of a stored backup, in order. Each is sealed on its own with
|
||
/// AES-256-GCM (the binary codec in `src/lib/crypto.ts`), and the sealed
|
||
/// plaintext names the copy it belongs to, its position and whether it is
|
||
/// the last, so a piece that is missing, moved, altered or borrowed from
|
||
/// another copy is refused. Deleted with its backup.
|
||
model DataBackupChunk {
|
||
id String @id @default(cuid())
|
||
backupId String @map("backup_id")
|
||
seq Int
|
||
data Bytes
|
||
|
||
backup DataBackup @relation(fields: [backupId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([backupId, seq])
|
||
@@map("data_backup_chunks")
|
||
}
|
||
|
||
// ─── Backup restore jobs (v1.39.1) ──────────────────────────
|
||
//
|
||
// A restore of a stored backup runs on the `backup-restore` pg-boss queue, not
|
||
// inside the request that asked for it: rebuilding an account of 1.25 million
|
||
// readings takes about a minute and a half, and a reverse proxy that cuts a
|
||
// request at 60 s showed the operator an error for a restore that went on to
|
||
// finish. The request checks the copy opens, writes one of these rows and
|
||
// answers 202; the worker runs the restore and writes its progress and its
|
||
// outcome here, and `GET /api/admin/backups/restores/{id}` reads them back.
|
||
//
|
||
// The staged file is the `DataBackup` row the restore reads. It is already
|
||
// encrypted at rest and stays where it is whatever the outcome, so a failed
|
||
// restore is retried from the same row without uploading anything again.
|
||
//
|
||
// At most one queued or running job per account: the partial unique index
|
||
// `backup_restore_jobs_one_active_per_user` (migration 0347, raw SQL because
|
||
// Prisma cannot express a partial index) is what makes a second request a 409
|
||
// rather than a race.
|
||
//
|
||
// Status state machine:
|
||
// queued → running → succeeded
|
||
// ↘ failed
|
||
// running (worker gone) → queued again at the next worker boot, at most
|
||
// twice, then failed.
|
||
model BackupRestoreJob {
|
||
id String @id @default(cuid())
|
||
/// The account the restore replaces: the backup's owner, never the admin.
|
||
/// Consumer: the admission check, the worker, and the status routes.
|
||
userId String @map("user_id")
|
||
/// The admin who asked for the restore. A plain column rather than a
|
||
/// relation, so deleting that admin later does not erase the record of who
|
||
/// replaced this account's data. Consumer: the worker's audit rows.
|
||
actorUserId String @map("actor_user_id")
|
||
/// The `DataBackup` row being restored. A plain column: the backup may be
|
||
/// deleted while the job waits, which the worker reports as a failure rather
|
||
/// than a cascade deleting the job. Consumer: the worker and the status
|
||
/// routes (the console marks the row being restored).
|
||
backupId String @map("backup_id")
|
||
/// SHA-256 of the stored copy's identity when the restore was requested (the
|
||
/// single value, or the stream id of a copy in pieces). The weekly job
|
||
/// overwrites its row in place, so the worker checks the copy it reads is the
|
||
/// one the operator previewed and confirmed. Consumer: the worker.
|
||
backupDigest String @map("backup_digest")
|
||
/// The operator's answer to "also restore the instance settings". Consumer:
|
||
/// the worker, and the status routes.
|
||
restoreInstanceSettings Boolean @default(false) @map("restore_instance_settings")
|
||
/// `pg-boss` job id of the latest delivery. Consumer: the boot sweep, which
|
||
/// completes a delivery whose worker is gone so it does not later surface as
|
||
/// a timed-out job.
|
||
pgBossJobId String? @map("pg_boss_job_id")
|
||
/// `queued | running | succeeded | failed`. Consumer: every reader.
|
||
status String @default("queued")
|
||
/// Where a running restore is: `validating | clearing | measurements |
|
||
/// sections | rebuilding`. NULL while queued and once finished. Consumer:
|
||
/// the status routes.
|
||
phase String?
|
||
/// Counts the worker writes as it goes (`RestoreProgress` in
|
||
/// `src/lib/export/restore-backup.ts`). Consumer: the status routes.
|
||
progress Json @default("{}")
|
||
/// On success: what the restore wrote, cleared and skipped. Counts and
|
||
/// catalogue keys only, the same facts the audit row carries. Consumer: the
|
||
/// status routes.
|
||
result Json?
|
||
/// On failure: a stable `code`, a sentence an operator can act on, and the
|
||
/// missing sections when that was the reason. Consumer: the status routes.
|
||
failure Json?
|
||
/// When the restore transaction committed: from here on the account holds
|
||
/// the restored data, whatever happens to the steps after it. Written and
|
||
/// awaited before the rollup rebuild starts. Consumer: the boot sweep and
|
||
/// the admission check, which never queue a committed job again (a second
|
||
/// run would delete what the account gained since) and close it as
|
||
/// `failed_after_commit` instead; and the worker's own failure message.
|
||
committedAt DateTime? @map("committed_at")
|
||
/// How many times a worker has started this job. Consumer: the boot sweep,
|
||
/// which stops re-queueing after the second attempt.
|
||
attempts Int @default(0)
|
||
/// Bumped every few seconds while a worker runs the job. A running row whose
|
||
/// heartbeat has gone stale belongs to a worker that is gone. Consumer: the
|
||
/// admission check and the boot sweep.
|
||
heartbeatAt DateTime? @map("heartbeat_at")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
startedAt DateTime? @map("started_at")
|
||
completedAt DateTime? @map("completed_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
// `backup_restore_jobs_one_active_per_user` is a partial UNIQUE index over
|
||
// `(user_id) WHERE status IN ('queued', 'running')` — see migration 0347.
|
||
// Prisma can't express a partial index in the schema language, so it lives
|
||
// in raw SQL and is intentionally omitted here.
|
||
@@index([userId, createdAt])
|
||
@@index([status, heartbeatAt])
|
||
@@map("backup_restore_jobs")
|
||
}
|
||
|
||
// ─── Off-host backup ledger ──────────────────────────────────
|
||
//
|
||
// One row per account, written by the nightly off-host job the moment an
|
||
// object lands in the bucket. It exists because "the job ran" and "this
|
||
// account has a recent copy off-host" are different facts: the run reports
|
||
// counts, and a cohort of a hundred accounts where one has silently failed
|
||
// every night for a month reads exactly like a healthy one.
|
||
//
|
||
// The admin console's off-host card is the reader, and it is the only reader
|
||
// there will be: the page computes staleness from this timestamp rather than
|
||
// listing the bucket, so an operator who has revoked the worker's read grant
|
||
// still sees the truth.
|
||
model OffhostBackupState {
|
||
userId String @id @map("user_id")
|
||
|
||
/// When the last nightly run walked this account, whether or not an object
|
||
/// came of it. It is what separates "no run has reached this account yet"
|
||
/// from "a run reached it and put nothing there" — two facts that look
|
||
/// identical in a ledger keyed only on success, and the first of which is
|
||
/// what every account reads on the day this table ships. Consumer:
|
||
/// `classifyOffhostBackup()` behind `GET /api/admin/backups`, and the
|
||
/// card's "last checked" line.
|
||
lastAttemptAt DateTime @map("last_attempt_at")
|
||
|
||
/// When the newest object for this account finished uploading. NULL means
|
||
/// a run has looked at this account and nothing has ever landed for it.
|
||
/// Consumer: `classifyOffhostBackup()` behind `GET /api/admin/backups`.
|
||
lastSuccessAt DateTime? @map("last_success_at")
|
||
|
||
/// Size of that object in bytes — the same number the run reports as its
|
||
/// largest, per account. `BigInt` because the uploader permits an object of
|
||
/// up to 8 MiB × 10 000 parts = 80 GB and a 32-bit column stops at 2.1 GB:
|
||
/// the biggest account on the host, the one an operator cares most about,
|
||
/// is exactly the one whose row would refuse to be written.
|
||
/// NULL alongside a NULL `lastSuccessAt`.
|
||
/// Consumer: the off-host card's size column.
|
||
sizeBytes BigInt? @map("size_bytes")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@map("offhost_backup_state")
|
||
}
|
||
|
||
/// Where one account stands in a backup pass that walks every account (the
|
||
/// weekly copy, `data-backup`, and the nightly off-host upload,
|
||
/// `data-backup-offhost`): when the pass last started the account and when it
|
||
/// last got past it, stored or failed in a way the pass caught. A start newer
|
||
/// than the finish, with no run of that pass active, is an attempt the
|
||
/// process died under: a restart, or a container killed for memory by a
|
||
/// record too large for it. The pass takes such an account last, so one
|
||
/// account cannot keep every account after it from a copy, and the admin
|
||
/// backups page names it.
|
||
/// Consumers: `orderInterruptedLast()` in both passes, and
|
||
/// `readBackupPassActivity()` behind `GET /api/admin/backups`.
|
||
model BackupPassAttempt {
|
||
userId String @map("user_id")
|
||
/// The pass's queue name: `data-backup` or `data-backup-offhost`.
|
||
pass String
|
||
/// When the pass last started on this account.
|
||
startedAt DateTime @map("started_at")
|
||
/// When the pass last got past it. NULL until it has once.
|
||
finishedAt DateTime? @map("finished_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@id([userId, pass])
|
||
@@map("backup_pass_attempts")
|
||
}
|
||
|
||
/// Per application key id, when the nightly off-host run last put an object
|
||
/// in the bucket whose inner ciphertext needed that key. The bucket's
|
||
/// lifecycle rule keeps each object for its retention, so a retired key is
|
||
/// needed until that long after this date. Not per account: the question it
|
||
/// answers is about keys. Written by `runOffhostBackup`; consumer:
|
||
/// `GET /api/admin/encryption/status` (`backups.offhost`).
|
||
model OffhostBackupKeyUse {
|
||
keyId String @id @map("key_id")
|
||
firstWrittenAt DateTime @map("first_written_at")
|
||
lastWrittenAt DateTime @map("last_written_at")
|
||
|
||
@@map("offhost_backup_key_use")
|
||
}
|
||
|
||
/// An account whose off-host copies have to leave the bucket: it was deleted,
|
||
/// or its data was wiped. Written in the same transaction as the deletion, so
|
||
/// the request cannot be lost between the two, and deleted once every object
|
||
/// for the account is gone. `subjectId` is deliberately not a relation: for a
|
||
/// deleted account the user row is already gone. Written by
|
||
/// `requestOffhostPurge`; consumer: `processOffhostPurges` (the
|
||
/// `offhost-backup-purge` job) and the off-host card's pending count behind
|
||
/// `GET /api/admin/backups`.
|
||
model OffhostPurgeRequest {
|
||
id String @id @default(cuid())
|
||
subjectId String @map("subject_id")
|
||
/// `account_deleted`, `data_wiped` or `managed_profile_deleted`.
|
||
reason String
|
||
requestedAt DateTime @default(now()) @map("requested_at")
|
||
attempts Int @default(0)
|
||
lastAttemptAt DateTime? @map("last_attempt_at")
|
||
/// The bucket's last refusal, trimmed; shown to the operator.
|
||
lastFailure String? @map("last_failure")
|
||
|
||
@@index([requestedAt])
|
||
@@map("offhost_purge_requests")
|
||
}
|
||
|
||
// ─── 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")
|
||
/// v1.42 (#972) — which importer owns the row: `apple_health` (every row
|
||
/// before v1.42) or `health_connect`. The reconcile cron and the
|
||
/// staged-file sweep look each row up on its own kind's queue
|
||
/// (`src/lib/jobs/apple-health-import-worker.ts`), so one importer never
|
||
/// fails or deletes the other's running job; the upload dedupe and the
|
||
/// Health Connect status route scope by it.
|
||
kind String @default("apple_health")
|
||
/// 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`. Consumer: both kick-off
|
||
/// endpoints (`src/app/api/import/apple-health-export/route.ts` and the
|
||
/// admin variant) dedupe on (userId, uploadSha256, parserRevision) and
|
||
/// short-circuit a re-upload of the exact same file back to the existing
|
||
/// job id without re-queueing. A prior `failed` job is excluded from the
|
||
/// match (issue #486), so the same bytes can always be retried.
|
||
uploadSha256 String? @map("upload_sha256")
|
||
/// Parser semantics revision. Existing archives migrated at revision 1 can
|
||
/// be deliberately reprocessed when the current parser revision advances:
|
||
/// the kick-off dedupe lookup (see `uploadSha256`) pins the CURRENT
|
||
/// `APPLE_HEALTH_IMPORT_PARSER_REVISION`, so a job parsed under an older
|
||
/// revision never short-circuits a fresh upload of the same bytes.
|
||
parserRevision Int @default(1) @map("parser_revision")
|
||
/// The instant the Health app stamped on the archive itself — the
|
||
/// `value` attribute of the `<ExportDate>` element that opens
|
||
/// `export.xml`. Written mid-run by the parse phase the moment the
|
||
/// element goes past (`onExportDate` in
|
||
/// `src/lib/measurements/import-apple-health-export.ts`, persisted by
|
||
/// `src/lib/jobs/apple-health-import-worker.ts`), so a job that later
|
||
/// fails still records which export it was working from. Consumer: the
|
||
/// status endpoint (`src/app/api/import/apple-health-export/[jobId]/
|
||
/// status/route.ts`). NULL until the parser reaches the element, and
|
||
/// for an archive that omits it or writes an unreadable value.
|
||
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([userId, kind, startedAt])
|
||
@@index([status])
|
||
@@map("import_jobs")
|
||
}
|
||
|
||
// Durable medication-intake CSV imports. The validated, normalized payload
|
||
// stays in Postgres so retries never depend on request-process memory.
|
||
model MedicationIntakeImportJob {
|
||
id String @id @default(cuid())
|
||
recordUserId String @map("record_user_id")
|
||
actorUserId String @map("actor_user_id")
|
||
/// v1.33.0 (migration 0278) — nullable. The per-medication import route
|
||
/// scopes its job to one medication and names it here. A dose history
|
||
/// exported from another tracker covers a whole regimen in one file — the
|
||
/// reported export named sixteen medications across 3,395 rows — so that job
|
||
/// belongs to the account and each payload entry names its own medication
|
||
/// instead. NULL therefore means "account-wide", not "unknown": the worker
|
||
/// reads `entry.medicationId` and refuses a payload that names neither.
|
||
medicationId String? @map("medication_id")
|
||
pgBossJobId String? @unique @map("pg_boss_job_id")
|
||
status String @default("queued")
|
||
payload Json
|
||
progress Json @default("{}")
|
||
result Json?
|
||
failureReason String? @map("failure_reason")
|
||
heartbeatAt DateTime? @map("heartbeat_at")
|
||
startedAt DateTime? @map("started_at")
|
||
completedAt DateTime? @map("completed_at")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
recordUser User @relation("MedicationIntakeImportRecord", fields: [recordUserId], references: [id], onDelete: Cascade)
|
||
medication Medication? @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([recordUserId, createdAt])
|
||
@@index([actorUserId, createdAt])
|
||
@@index([medicationId, createdAt])
|
||
@@index([status, heartbeatAt])
|
||
@@map("medication_intake_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"
|
||
/// HTTP status the last failure came back with; null on a success and on
|
||
/// a network-class failure that never had a response to read one from.
|
||
/// Consumer: the operator readout (`src/app/api/admin/provider-health/
|
||
/// route.ts`), which carries it beside the failure instant it belongs to —
|
||
/// it is what separates a dead credential the operator has to replace from
|
||
/// a provider having a bad afternoon.
|
||
lastStatus Int? @map("last_status")
|
||
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
|
||
/// SHA-256 of the canonical request body, scoped by method and path. A
|
||
/// lookup replays only when it matches; a different body under the same key
|
||
/// runs normally. Null on rows written before it existed, which never match.
|
||
requestFingerprint String? @map("request_fingerprint")
|
||
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. A checkup's own
|
||
/// words, readable like its label.
|
||
///
|
||
/// Always NULL on an ENCOUNTER row: an appointment's address is the
|
||
/// practitioner's, encrypted there (`Practitioner.locationEncrypted`), and
|
||
/// the reminder tick resolves it from the owning visit when it builds the
|
||
/// push (`appointmentReminderAddress`). Earlier releases copied it here in
|
||
/// the clear; `free-text-encryption-backfill` clears those copies, a
|
||
/// re-anchor clears the row's own, and the backup neither writes nor
|
||
/// restores one.
|
||
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)
|
||
/// Booster match key: the antigen slug a logged vaccination clears this
|
||
/// reminder for. Written only by the vaccination booster mint (rung 2 —
|
||
/// prefilled, user-confirmed) and read only by the create path's satisfy
|
||
/// matcher (`src/lib/vaccinations/satisfy-matching.ts`), which resolves a
|
||
/// dose's component antigens and satisfies every reminder they match.
|
||
///
|
||
/// Deliberately absent from the reminder DTO
|
||
/// (`src/lib/measurement-reminders/dto.ts`): this is a match key, not a
|
||
/// behaviour, so the published contract does not move — the `origin` enum
|
||
/// stays two-armed, the cron still fires by `nextDueAt` regardless of why a
|
||
/// row exists, and no Vorsorge surface excludes these rows. A booster
|
||
/// belongs on `/checkups`. That is also why a fourth nullable column here
|
||
/// is not the signal the appointment arm's warning described: an
|
||
/// appointment had to be bent into this engine (one-shot, no cadence,
|
||
/// excluded everywhere), whereas a booster is the engine's native case —
|
||
/// recurring, satisfiable, cadence-bearing.
|
||
vaccinationAntigen String? @map("vaccination_antigen")
|
||
/// v1.37.20 — snooze cursor. Non-NULL and in the future = "pushed back until
|
||
/// this instant". The snooze write sets `nextDueAt` to the SAME instant, so
|
||
/// every due-state consumer (digest, dashboard, cron, AI block) moves with
|
||
/// it without knowing snooze exists. Self-expiring: once the instant passes,
|
||
/// the clock comparison stops being true and no cleanup is needed. Any
|
||
/// recompute of `nextDueAt` (satisfy, skip, cadence PATCH) nulls it.
|
||
snoozedUntil DateTime? @map("snoozed_until")
|
||
/// v1.37.20 — last honest skip. A skip re-anchors `nextDueAt` at the skip
|
||
/// instant but NEVER moves `lastSatisfiedAt` — done and skipped stay
|
||
/// distinguishable forever. "Skipped cycle" derives as
|
||
/// `lastSkippedAt > lastSatisfiedAt` (or non-NULL with no satisfy yet).
|
||
lastSkippedAt DateTime? @map("last_skipped_at")
|
||
/// v1.37.20 — lifetime skip counter ("skipped N times").
|
||
skipCount Int @default(0) @map("skip_count")
|
||
/// v1.39.2 — when a reminder for this row last reached at least one channel.
|
||
/// Written by the reminder tick after a delivered dispatch, read by the same
|
||
/// tick. A reminder on a cycle longer than a week, or a one-shot (never an
|
||
/// appointment), no longer rolls `nextDueAt` forward when it is reminded:
|
||
/// it stays due until it is satisfied (a reading, a lab result or "done"),
|
||
/// skipped or snoozed, and this cursor keeps the repeat to at most once a
|
||
/// week. A nudge counts as "already sent for this slot" only
|
||
/// while `lastNotifiedAt >= nextDueAt`, so a snooze, a skip or a satisfy
|
||
/// (each of which moves `nextDueAt`) opens a fresh slot that fires on its
|
||
/// own day. Operational state, deliberately not in the backup: a restored
|
||
/// row without it re-reminds at most once, on its next notify hour.
|
||
lastNotifiedAt DateTime? @map("last_notified_at")
|
||
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)
|
||
/// Back-relation for the appointment arm: a PLANNED `Encounter` mints
|
||
/// exactly one ENCOUNTER-origin row and points at it. SetNull on the far
|
||
/// side, so deleting the reminder leaves the visit intact.
|
||
encounters Encounter[]
|
||
/// Back-relation for the booster arm: the doses that recorded satisfying
|
||
/// this reminder. SetNull on the far side — deleting the reminder leaves
|
||
/// the vaccination record intact.
|
||
vaccinations VaccinationRecord[]
|
||
/// v1.37.20 — the completion ledger (iOS #68): every satisfy and every
|
||
/// skip appends one row. Cascade: the ledger has no meaning without its
|
||
/// reminder.
|
||
events MeasurementReminderEvent[]
|
||
|
||
@@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; `ENCOUNTER` = minted by, and owned by, a planned `Encounter`.
|
||
/// All three feed the same engine / endpoint / cron — an appointment is a
|
||
/// one-shot row on this engine, not a second engine.
|
||
///
|
||
/// `ENCOUNTER` rows are excluded from every Vorsorge READ surface, or the
|
||
/// checkup list would fill with appointments: the reminder list route, the
|
||
/// daily digest, the insights preventive-care block and the MCP
|
||
/// `get_preventive_care` tool each carry the exclusion, and
|
||
/// `src/__tests__/encounter-reminder-exclusion.test.ts` proves all four plus
|
||
/// the DTO mapper's refusal. The published `origin` enum therefore stays at
|
||
/// two members. Firing and satisfying deliberately do NOT exclude — that is
|
||
/// the whole point of the row.
|
||
enum ReminderOrigin {
|
||
VORSORGE
|
||
COACH
|
||
ENCOUNTER
|
||
|
||
@@map("reminder_origin")
|
||
}
|
||
|
||
/// v1.37.20 — what a completion-ledger row records: an honest fulfilment
|
||
/// (`SATISFIED` — a matching reading landed or the user marked it done) or an
|
||
/// honest skip (`SKIPPED` — the user deliberately let this cycle go, which is
|
||
/// NOT a completion and never touches `lastSatisfiedAt`).
|
||
enum MeasurementReminderEventKind {
|
||
SATISFIED
|
||
SKIPPED
|
||
|
||
@@map("measurement_reminder_event_kind")
|
||
}
|
||
|
||
/// v1.37.20 — the Vorsorge completion ledger (iOS #68). The reminder engine is
|
||
/// single-cursor (`nextDueAt` + `lastSatisfiedAt`), so before this table each
|
||
/// satisfy overwrote the previous one and history was unrecoverable. Every
|
||
/// path through the ONE satisfy primitive (`satisfyReminder`) and the skip
|
||
/// route appends one row here; nothing ever updates or deletes a row short of
|
||
/// deleting the reminder itself. History starts at the release that ships
|
||
/// this — the single cursor holds nothing to backfill from, which the iOS #68
|
||
/// thread records as agreed.
|
||
model MeasurementReminderEvent {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
reminderId String @map("reminder_id")
|
||
kind MeasurementReminderEventKind
|
||
/// The event instant: the satisfy instant (which may be the backdated
|
||
/// reading's own timestamp) or the skip instant (always the server clock).
|
||
occurredAt DateTime @map("occurred_at")
|
||
/// Derived AT WRITE TIME against the reminder's `nextDueAt` as it stood
|
||
/// before the event re-anchored it: `true` when the event landed at or
|
||
/// before the due instant (or the cadence had no computable due). Clients
|
||
/// (web + iOS) consume this resolved value and never re-derive it — the
|
||
/// pre-event `nextDueAt` is gone after the write, so write time is the only
|
||
/// honest moment to answer the question.
|
||
onTime Boolean @map("on_time")
|
||
/// Which path produced the event — a closed set of engine call sites
|
||
/// (`manual`, `auto_measurement`, `auto_lab`, `telegram`, `vaccination`,
|
||
/// `encounter`, `skip`), asserted in code, not free text.
|
||
source String
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
reminder MeasurementReminder @relation(fields: [reminderId], references: [id], onDelete: Cascade)
|
||
|
||
/// The history endpoint reads newest-first per reminder.
|
||
@@index([reminderId, occurredAt])
|
||
@@index([userId])
|
||
@@map("measurement_reminder_events")
|
||
}
|
||
|
||
// ─── 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
|
||
/// Reference-range bounds stamped onto the row at write time. Despite the
|
||
/// name these are NOT the lab's printed range: since v1.18.1 every write
|
||
/// path copies the linked `Biomarker`'s `lowerBound` / `upperBound` here as
|
||
/// historical truth, so a later catalog edit does not silently rewrite a
|
||
/// past reading. The range the LAB printed lives in `sourceReference*`.
|
||
referenceLow Float? @map("reference_low")
|
||
referenceHigh Float? @map("reference_high")
|
||
|
||
/// The reference range as PRINTED ON THE SOURCE REPORT for this one reading.
|
||
///
|
||
/// Labs run different methods on different devices, so the window a
|
||
/// physician evaluates against is the one printed beside the value on that
|
||
/// report — not a catalog band that averages every lab. These three columns
|
||
/// carry it: the parsed numeric bounds where a bound could be derived, and
|
||
/// `sourceReferenceText` as the report's own verbatim string ("< 5",
|
||
/// "bis 5,0", "negativ", "siehe Befund"). The text is kept whenever the
|
||
/// source printed one, parseable or not — absence reads as absence, and a
|
||
/// window that could not be derived is never invented.
|
||
///
|
||
/// Precedence: for a single reading the source bounds win over the catalog
|
||
/// band; the catalog stays the net for readings that carry no source range.
|
||
/// `resolveEffectiveReferenceRange` in `src/lib/labs/reference-range.ts` is
|
||
/// the one place that rule is expressed, and every surface that judges a
|
||
/// reading routes through it.
|
||
///
|
||
/// Plaintext, not `*Encrypted`: that suffix marks user prose (`noteEncrypted`
|
||
/// here, `Biomarker.contextEncrypted` next door). This is a structured
|
||
/// lab-report field of exactly the class of the plaintext `analyte` / `unit`
|
||
/// / `value` / `valueText` columns beside it — `valueText` already carries
|
||
/// the actual clinical RESULT ("positiv") in the clear, so encrypting the
|
||
/// window that result is judged against would protect strictly less while
|
||
/// making the column unreadable to the SQL the read paths run.
|
||
sourceReferenceLow Float? @map("source_reference_low")
|
||
sourceReferenceHigh Float? @map("source_reference_high")
|
||
sourceReferenceText String? @map("source_reference_text")
|
||
/// v1.42 — the value as printed on the source
|
||
/// report, kept when a write converted it into the biomarker's canonical
|
||
/// unit. NULL when no conversion happened. An edit that changes `value` clears it.
|
||
sourceValue Float? @map("source_value")
|
||
/// v1.42 — the unit the source report printed,
|
||
/// paired with `sourceValue`.
|
||
sourceUnit String? @map("source_unit")
|
||
/// 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)
|
||
encounterLinks EncounterLabLink[]
|
||
documentLinks LabResultDocumentLink[]
|
||
|
||
@@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")
|
||
/// v1.42 — the catalogue analyte this marker
|
||
/// resolves to (alias list in `src/lib/labs/biomarker-catalog.ts`), which
|
||
/// selects the unit-conversion table. NULL = not resolved; no conversion.
|
||
analyteKey String? @map("analyte_key")
|
||
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[]
|
||
/// v1.43 — other spellings that resolve to this marker, and the merges that
|
||
/// folded other markers into it.
|
||
aliases BiomarkerAlias[]
|
||
mergesInto BiomarkerMerge[]
|
||
|
||
@@unique([userId, name])
|
||
@@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?
|
||
/// Explicit opt-in to expose this metric as a bounded behaviour channel in
|
||
/// correlation discovery. False keeps the metric isolated.
|
||
correlationEnabled Boolean @default(false) @map("correlation_enabled")
|
||
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])
|
||
@@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")
|
||
/// @internal: legacy plaintext note, read only as a fallback for rows the
|
||
/// free-text encryption backfill has not reached yet (v1.39.3). Every write
|
||
/// since then goes to `noteEncrypted` and leaves this NULL; the backfill
|
||
/// nulls it on the old rows. DROP this column in a later release, once the
|
||
/// backfill reports zero remaining rows on every instance.
|
||
note String?
|
||
/// v1.39.3 — AES-256-GCM ciphertext of the note. Same Bytes codec as the
|
||
/// measurement and mood notes.
|
||
noteEncrypted Bytes? @map("note_encrypted")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
/// v1.37.20 — soft-delete tombstone, peer parity with Measurement /
|
||
/// MoodEntry (every other entry surface deletes recoverably with an undo
|
||
/// toast; custom-metric entries were the one hard delete). NULL = live.
|
||
deletedAt DateTime? @map("deleted_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
metric CustomMetric @relation(fields: [customMetricId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([userId, customMetricId, measuredAt])
|
||
// `custom_metric_entries_note_backfill_idx` is a partial index over
|
||
// `(user_id) WHERE note IS NOT NULL` — see
|
||
// migration 0357 for the free-text encryption boot-discovery scan. Raw SQL
|
||
// only (partial predicate not expressible in Prisma).
|
||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||
@@index([customMetricId])
|
||
@@map("custom_metric_entries")
|
||
}
|
||
|
||
/// Persisted identity and latest accepted evidence for a user-scoped
|
||
/// correlation finding. `canonicalKey` is derived from factor + outcome + lag;
|
||
/// the named fields remain queryable and make the identity portable.
|
||
model CorrelationPattern {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
canonicalKey String @map("canonical_key")
|
||
family String
|
||
factorKey String @map("factor_key")
|
||
outcomeKey String @map("outcome_key")
|
||
lagDays Int @map("lag_days")
|
||
sampleSize Int @map("sample_size")
|
||
effectSize Float @map("effect_size")
|
||
pValue Float @map("p_value")
|
||
qValue Float? @map("q_value")
|
||
evidenceHash String @map("evidence_hash")
|
||
isCurrent Boolean @default(true) @map("is_current")
|
||
lastComputedAt DateTime @map("last_computed_at")
|
||
dismissedAt DateTime? @map("dismissed_at")
|
||
dismissedEvidenceHash String? @map("dismissed_evidence_hash")
|
||
dismissedEffectSize Float? @map("dismissed_effect_size")
|
||
dismissedSampleSize Int? @map("dismissed_sample_size")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([userId, canonicalKey])
|
||
@@index([userId, family, isCurrent])
|
||
@@map("correlation_patterns")
|
||
}
|
||
|
||
// ─── 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])
|
||
@@map("user_achievements")
|
||
}
|
||
|
||
// ─── Health Score record ──────
|
||
|
||
/// The three bands a composite can land in, lowercase because that is the
|
||
/// value every surface already speaks. Closed on purpose: the column refuses
|
||
/// a band no renderer knows.
|
||
enum HealthScoreBand {
|
||
green
|
||
yellow
|
||
red
|
||
|
||
@@map("health_score_band")
|
||
}
|
||
|
||
/// The health score as it was SHOWN to the account on one local day.
|
||
///
|
||
/// Not a cache and not a derived tier: nothing can rebuild it. The live
|
||
/// computation always answers for today from today's rows, so once the rows
|
||
/// behind an old day have been edited, re-synced, corrected or simply grown,
|
||
/// the number the person actually saw that day is gone unless it was written
|
||
/// down. That is what this table is, and it is why the write is append-only:
|
||
/// the FIRST score computed on a local day is the one that is kept, and a
|
||
/// later computation of the same day leaves the row exactly as it found it.
|
||
///
|
||
/// It never overrides the live computation either. Every surface keeps
|
||
/// reading the live score; this table is read only where a past day is the
|
||
/// question.
|
||
model HealthScoreRecord {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
/// The LOCAL calendar day (`YYYY-MM-DD`) the score was computed for, cut in
|
||
/// `timezone`. Lexicographically ordered, so a trailing-window read is a
|
||
/// range scan.
|
||
dayKey String @map("day_key") @db.VarChar(10)
|
||
/// The IANA zone the day was cut in. An account that moves zones leaves
|
||
/// behind days cut on the old clock; carrying the zone is what lets a
|
||
/// reader see that rather than silently compare days of different lengths.
|
||
timezone String
|
||
|
||
/// The composite 0-100 as shown. Integer, matching the composite's own
|
||
/// rounding.
|
||
composite Int
|
||
/// The band as shown. Stored rather than re-derived from `composite`,
|
||
/// because the worst pillar can pull the band below the one the mean alone
|
||
/// would give, so the number does not determine the band.
|
||
band HealthScoreBand
|
||
/// Algorithm identity the row was computed under.
|
||
scoreVersion Int @map("score_version")
|
||
/// Registry-ordered pillar ids that produced the composite. Part of the
|
||
/// number's identity: the same value under a different composition is a
|
||
/// different measurement.
|
||
composition String[]
|
||
/// Each counted pillar's own 0-100 score, keyed by pillar id. The composite
|
||
/// breaks across a composition change; these stay comparable through one,
|
||
/// which is the whole reason they are stored beside it rather than folded
|
||
/// away.
|
||
pillarScores Json @map("pillar_scores")
|
||
/// SHA-256 over exactly what the stored composite and band depend on: the
|
||
/// score version, the composition, and every counted pillar's score with
|
||
/// its scoring-mode identity. Two rows with the same fingerprint were
|
||
/// computed from the same standing; two rows that differ, were not.
|
||
inputFingerprint String @map("input_fingerprint") @db.VarChar(64)
|
||
|
||
/// The account's health-score configuration version in force when the row
|
||
/// was written, and when that configuration last changed. Supplied on every
|
||
/// write by `computeAndRecordUserHealthScore`, which resolves them from the
|
||
/// same stored recipe the composition came from; an account that never chose
|
||
/// records version 0 with no date, which is a real answer and not an absence.
|
||
///
|
||
/// Read by `scoreSeriesSeams` (`src/lib/analytics/score/series.ts`), which
|
||
/// marks the first row after a version move as a series break, refuses to
|
||
/// join the composite line across it, and keeps the per-pillar rows running
|
||
/// through it. Nullable only because the columns predate the writer by one
|
||
/// release; nothing written since supplies null.
|
||
configVersion Int? @map("config_version")
|
||
configChangedAt DateTime? @map("config_changed_at") @db.Timestamptz(3)
|
||
|
||
/// When the score was computed. Distinct from `dayKey`: the day says which
|
||
/// day was scored, this says when the scoring ran.
|
||
computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3)
|
||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([userId, dayKey])
|
||
@@index([userId, dayKey(sort: Desc)])
|
||
@@map("health_score_records")
|
||
}
|
||
|
||
// ─── 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.insightsCachedTextEncrypted) 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")
|
||
/// @internal: legacy plaintext title, read only as a fallback for rows the
|
||
/// free-text encryption backfill has not reached yet (v1.39.3). Every write
|
||
/// since then goes to `titleEncrypted` and leaves this NULL; the backfill
|
||
/// nulls it on the old rows. DROP this column in a later release, once the
|
||
/// backfill reports zero remaining rows on every instance.
|
||
title String?
|
||
/// v1.39.3 — AES-256-GCM ciphertext of the title (the first 80 characters of
|
||
/// the opening Coach message, or the rename). Same Bytes codec as the notes.
|
||
titleEncrypted Bytes? @map("title_encrypted")
|
||
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")
|
||
/// @internal: diagnostic timestamp of the last summary rewrite — the elision
|
||
/// logic keys on `summaryTurnCount`, not on this instant; nothing reads it.
|
||
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])
|
||
// `coach_conversations_title_backfill_idx` is a partial index over
|
||
// `(user_id) WHERE title IS NOT NULL` — see
|
||
// migration 0357 for the free-text encryption boot-discovery scan. Raw SQL
|
||
// only (partial predicate not expressible in Prisma).
|
||
@@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?
|
||
// v1.39.4 — the tables of values this turn read (JSON `CoachResultTable[]`,
|
||
// at most 128 KB), AES-256-GCM encrypted like `encryptedContent`. Health
|
||
// values, so they never ride the plaintext `metricSourceJson`, which holds
|
||
// only the tables' metadata. Read through `readMessageResults` by the
|
||
// owner-narrowed results endpoint; NULL on every turn without a table.
|
||
resultsEncrypted Bytes? @map("results_encrypted")
|
||
// v1.41 — the model-written text of the turn's live trail (JSON
|
||
// `CoachTrail`, at most 16 KB): reasoning titles and texts per round, the
|
||
// facts recalled into the turn, the text of a fact proposal. AES-256-GCM
|
||
// like `encryptedContent`. The trail's plaintext metadata (phase, status,
|
||
// round, catalog label, duration, count, stop reason) rides
|
||
// `metricSourceJson.activity`. Owner-only; NULL on every turn without one.
|
||
trailEncrypted Bytes? @map("trail_encrypted")
|
||
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")
|
||
// v1.41 — who put the fact here, an app-side closed enum
|
||
// (`COACH_FACT_SOURCES` in `src/lib/ai/coach/memory/contract.ts`): `user`
|
||
// (the remember button, or a proposal the person confirmed), `coach` (saved
|
||
// during a turn), `extracted` (the background extraction, and every row
|
||
// written before the column existed), `pattern` (the deterministic matcher).
|
||
source String @default("extracted")
|
||
// v1.41 — when the fact last went into a Coach turn; NULL until it has.
|
||
lastUsedAt DateTime? @map("last_used_at")
|
||
// v1.41 — the message the fact came out of, for "undo" and "from this
|
||
// conversation". A bare id like `sourceConversationId`, not a relation, so
|
||
// deleting a conversation does not take the fact with it.
|
||
sourceMessageId String? @map("source_message_id")
|
||
|
||
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")
|
||
// Closed inclusion list for self-context sent to Coach and briefing prompts.
|
||
// The static default preserves every pre-existing prompt section for legacy
|
||
// rows without automatically opting future enum members in.
|
||
aiIncludedSections HealthProfileAiSection[] @default([ABOUT_ME, CONDITIONS, ALLERGIES, COACH_FOCUS, FAMILY_HISTORY, SMOKING_STATUS, ALCOHOL_PATTERN, SHIFT_SCHEDULE]) @map("ai_included_sections")
|
||
// Emergency ("Notfalldaten") facts, surfaced on page one of the doctor
|
||
// report and edited in Settings -> Anamnese. Three closed-set enums are
|
||
// plaintext (read directly by the report, like `User.gender`); three free
|
||
// text columns are AES-256-GCM at rest, fail-closed on read like every other
|
||
// `*Encrypted` column. All six are nullable: an unset emergency profile is
|
||
// absent, not empty. The report page is withheld unless the EMERGENCY leaf is
|
||
// admitted AND at least one of these holds a value.
|
||
emergencyBloodType EmergencyBloodType? @map("emergency_blood_type")
|
||
organDonorStatus OrganDonorStatus? @map("organ_donor_status")
|
||
advanceDirectiveStatus AdvanceDirectiveStatus? @map("advance_directive_status")
|
||
emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted")
|
||
emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted")
|
||
emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted")
|
||
/// v1.43 (#614) — "still true" confirmations of the life
|
||
/// circumstance sections: `{ "<section key>": "<ISO instant>" }`. Written by
|
||
/// `POST /api/anamnesis/life-circumstances/confirm`, read as
|
||
/// `lastConfirmedAt` / `reviewDue` by the life-circumstances read route.
|
||
/// Plaintext: section keys and timestamps only, never an answer.
|
||
lifeConfirmedJson Json? @map("life_confirmed_json")
|
||
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")
|
||
}
|
||
|
||
/// ABO/Rh blood group, plus an explicit UNKNOWN so "not known" is a recorded
|
||
/// answer rather than an absent row. Plaintext, read directly by the emergency
|
||
/// report page.
|
||
enum EmergencyBloodType {
|
||
A_POS
|
||
A_NEG
|
||
B_POS
|
||
B_NEG
|
||
AB_POS
|
||
AB_NEG
|
||
O_POS
|
||
O_NEG
|
||
UNKNOWN
|
||
|
||
@@map("emergency_blood_type")
|
||
}
|
||
|
||
/// Organ-donor declaration. UNKNOWN is the explicit "not stated" answer.
|
||
enum OrganDonorStatus {
|
||
YES
|
||
NO
|
||
UNKNOWN
|
||
|
||
@@map("organ_donor_status")
|
||
}
|
||
|
||
/// Whether an advance directive (Patientenverfügung) exists. UNKNOWN is the
|
||
/// explicit "not stated" answer.
|
||
enum AdvanceDirectiveStatus {
|
||
EXISTS
|
||
NONE
|
||
UNKNOWN
|
||
|
||
@@map("advance_directive_status")
|
||
}
|
||
|
||
/// v1.43 — how much background AI work an account asks for (#1217): status
|
||
/// card regeneration cadence, snapshot ceiling, background reasoning, briefing
|
||
/// length and automatic document reading. Wire form is lower case
|
||
/// (`frugal` / `standard` / `full`); the order is the ceiling order.
|
||
enum AiUsageLevel {
|
||
FRUGAL
|
||
STANDARD
|
||
FULL
|
||
|
||
@@map("ai_usage_level")
|
||
}
|
||
|
||
/// Sections of the health profile that may enter AI prompt context. This is an
|
||
/// inclusion list: a section absent from the array is neither decrypted nor
|
||
/// assembled for Coach or comprehensive briefings.
|
||
enum HealthProfileAiSection {
|
||
ABOUT_ME
|
||
CONDITIONS
|
||
ALLERGIES
|
||
COACH_FOCUS
|
||
FAMILY_HISTORY
|
||
SMOKING_STATUS
|
||
ALCOHOL_PATTERN
|
||
SHIFT_SCHEDULE
|
||
/// v1.43 (#614) — life-circumstance sections. Never in the column default
|
||
/// and never listed by `GET /api/coach/about-me` (a closed decoder in older
|
||
/// app builds); switched through
|
||
/// `PATCH /api/anamnesis/life-circumstances/ai-sections`.
|
||
ACTIVITY
|
||
WORK
|
||
HABITS
|
||
GOALS
|
||
|
||
@@map("health_profile_ai_section")
|
||
}
|
||
|
||
/// The three structured profile facts whose answers affect an existing
|
||
/// interpretation path. The answer itself stays encrypted; this enum only
|
||
/// identifies which closed validation set must be applied after decryption.
|
||
enum HealthProfileFactKind {
|
||
SMOKING_STATUS
|
||
ALCOHOL_PATTERN
|
||
SHIFT_SCHEDULE
|
||
/// v1.43 (#614) — life-circumstance facts. The value is a sealed JSON object
|
||
/// with `schema: 1`, one Zod schema per kind. `GET /api/anamnesis/facts`
|
||
/// never returns these (a closed decoder in older app builds); they are read
|
||
/// through `GET /api/anamnesis/life-circumstances`. Readers: the coach
|
||
/// self-context (all six, per released AI section), the doctor report and
|
||
/// FHIR (tobacco, activity, work), the chronotype (free weekdays), the
|
||
/// environment read (work). `HABITS` and `HEALTH_GOALS` are coach-only by
|
||
/// design: diet and goals are context for a conversation, not findings.
|
||
TOBACCO_DETAIL
|
||
ACTIVITY_HABIT
|
||
WORK_CONTEXT
|
||
FREE_WEEKDAYS
|
||
HABITS
|
||
HEALTH_GOALS
|
||
|
||
@@map("health_profile_fact_kind")
|
||
}
|
||
|
||
/// Provenance for an effective-dated fact revision.
|
||
enum HealthProfileFactProvenance {
|
||
USER_REPORTED
|
||
USER_CORRECTION
|
||
|
||
@@map("health_profile_fact_provenance")
|
||
}
|
||
|
||
/// Effective-dated structured health-profile fact. Each non-superseded chain
|
||
/// is half-open `[validFrom, validUntil)`, with exactly one current row per
|
||
/// user and fact kind. Corrections close the current row and mint a replacement
|
||
/// rather than overwriting encrypted history.
|
||
model HealthProfileFactRevision {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
kind HealthProfileFactKind
|
||
valueEncrypted Bytes @map("value_encrypted")
|
||
validFrom DateTime @map("valid_from") @db.Timestamptz(6)
|
||
validUntil DateTime? @map("valid_until") @db.Timestamptz(6)
|
||
provenance HealthProfileFactProvenance @default(USER_REPORTED)
|
||
supersededByRevisionId String? @map("superseded_by_revision_id")
|
||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
supersededBy HealthProfileFactRevision? @relation("HealthProfileFactSupersession", fields: [supersededByRevisionId], references: [id], onDelete: SetNull)
|
||
supersedes HealthProfileFactRevision[] @relation("HealthProfileFactSupersession")
|
||
|
||
@@unique([userId, kind, validFrom])
|
||
@@index([userId, kind, validFrom(sort: Desc)])
|
||
@@index([supersededByRevisionId])
|
||
@@map("health_profile_fact_revisions")
|
||
}
|
||
|
||
// 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")
|
||
/// v1.38.19 — the share of `totalTokens` served by a provider the
|
||
/// OPERATOR funds (`admin-openai` / `admin-codex`). The operator-cost cap is
|
||
/// enforced against this counter and the user-plan cap against
|
||
/// `totalTokens`, so background work served by the user's own plan can no
|
||
/// longer lock the interactive chat out of the operator's ceiling.
|
||
operatorTokens Int @default(0) @map("operator_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])
|
||
@@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`).
|
||
//
|
||
// Record-scoped notification events are durable event anchors rather than
|
||
// channel delivery records. They intentionally name no recipient, which keeps
|
||
// scheduler deduplication independent from future Guardian fan-out.
|
||
model NotificationEvent {
|
||
id String @id @default(cuid())
|
||
recordUserId String @map("record_user_id")
|
||
eventType String @map("event_type")
|
||
dedupKey String @map("dedup_key")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
recordUser User @relation(fields: [recordUserId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([recordUserId, eventType, dedupKey, createdAt(sort: Desc)])
|
||
@@index([createdAt], map: "notification_events_created_at_idx")
|
||
@@map("notification_events")
|
||
}
|
||
|
||
// A managed Guardian channel send crosses a revocable authorization boundary.
|
||
// The dispatcher creates this claim while it holds the managed-profile
|
||
// lifecycle lock, then commits before starting provider I/O. That committed
|
||
// claim is the linearization point: a revocation that commits first prevents
|
||
// the send, while a revocation that commits later does not cancel the one
|
||
// already-authorized provider operation. No notification content, channel
|
||
// credentials, or event deduplication keys are retained here. Completion is
|
||
// observational only; authorization and retry decisions never read it.
|
||
model NotificationEgressAuthorization {
|
||
id String @id @default(cuid())
|
||
recordUserId String @map("record_user_id")
|
||
recipientUserId String @map("recipient_user_id")
|
||
channel String
|
||
eventType String @map("event_type")
|
||
authorizedAt DateTime @default(now()) @map("authorized_at")
|
||
completedAt DateTime? @map("completed_at")
|
||
outcome String?
|
||
|
||
recordUser User @relation("NotificationEgressAuthorizationRecordUser", fields: [recordUserId], references: [id], onDelete: Cascade)
|
||
recipientUser User @relation("NotificationEgressAuthorizationRecipientUser", fields: [recipientUserId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([authorizedAt], map: "notification_egress_authorizations_authorized_at_idx")
|
||
@@index([recordUserId, recipientUserId, authorizedAt(sort: Desc)])
|
||
@@index([recipientUserId, authorizedAt(sort: Desc)], map: "notification_egress_auth_recipient_authorized_at_idx")
|
||
@@map("notification_egress_authorizations")
|
||
}
|
||
|
||
// `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())
|
||
/// Legacy recipient mirror. Migration 0298 preserves established writers
|
||
/// while the database trigger requires it to equal `recipientUserId`.
|
||
userId String @map("user_id")
|
||
/// The health-record subject. This supplies calculation context and content.
|
||
///
|
||
/// Optional HERE and `NOT NULL` in the database, deliberately, and the two do
|
||
/// not disagree: `push_attempt_attribution_guard` is a BEFORE INSERT trigger,
|
||
/// so a write that omits both principals has them filled from `userId` before
|
||
/// the constraint is checked. Declaring them required would force every
|
||
/// established self-delivery writer to name a pair it has no opinion about;
|
||
/// making the columns nullable in SQL would let a partial pair reach the
|
||
/// table, which the trigger exists to refuse. `prisma migrate diff` reports
|
||
/// the difference because it cannot see the trigger — that report is expected,
|
||
/// and `notification-attribution.test.ts` ("persistence maps an omitted pair
|
||
/// from the legacy recipient") is what proves the accommodation still works.
|
||
recordUserId String? @map("record_user_id")
|
||
/// The account whose channel, preferences, and locale receive the delivery.
|
||
/// Nullable for the same reason as `recordUserId` above.
|
||
recipientUserId String? @map("recipient_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")
|
||
|
||
recordUser User? @relation("PushAttemptRecordUser", fields: [recordUserId], references: [id], onDelete: Cascade)
|
||
recipientUser User? @relation("PushAttemptRecipientUser", fields: [recipientUserId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([userId, createdAt(sort: Desc)])
|
||
@@index([recordUserId, createdAt(sort: Desc)])
|
||
@@index([recipientUserId, 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` and `sectionsJson` 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")
|
||
/// The frozen report scope: `{ "v": 2, "leaves": [...] }`. Write-once at
|
||
/// mint, never widened. A blob this build cannot read resolves to the empty
|
||
/// selection, so a drifted row serves nothing rather than defaulting open.
|
||
sectionsJson Json @map("sections_json")
|
||
/// 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([documentId])
|
||
@@map("clinician_share_link_documents")
|
||
}
|
||
|
||
/// v1.36.0 — the access level a grant confers. Only `READ` is ever written in
|
||
/// v1; the type exists from day one so that adding delegated writes is
|
||
/// enforcement work rather than migration work, and so a row states its level
|
||
/// instead of leaving a reader to infer it.
|
||
enum AccountGrantAccess {
|
||
READ
|
||
WRITE
|
||
/// v1.37.0 — full management of the record's data: everything WRITE reaches,
|
||
/// plus the edit, delete and restore verbs beside it, plus the record-wide
|
||
/// derived surfaces. Never the identity and reach surfaces — credentials,
|
||
/// channels, tokens, integrations, grant management, AI configuration —
|
||
/// which stay unreachable at every invited level, because a manager who
|
||
/// could mint a credential would survive their own revocation. MANAGE is
|
||
/// whole-record by construction: `scopeJson` must be NULL on a MANAGE row.
|
||
MANAGE
|
||
|
||
@@map("account_grant_access")
|
||
}
|
||
|
||
/// Who ended a grant. "The owner withdrew it" and "the delegate handed it back"
|
||
/// are different facts about the same person's record, and the consent record
|
||
/// has to be able to tell them apart.
|
||
enum AccountGrantRevoker {
|
||
GRANTOR
|
||
GRANTEE
|
||
|
||
@@map("account_grant_revoker")
|
||
}
|
||
|
||
/// v1.36.0 — one account's standing permission to read another account's
|
||
/// record, and the durable record of the consent that created it.
|
||
///
|
||
/// The row is BOTH. There is no separate receipt table: two records of one act
|
||
/// drift, and then nobody can say which is true. So every fact about the
|
||
/// delegation is a column here — who offered it (`grantorId`, `invitedAt`), who
|
||
/// accepted it (`granteeId`, `acceptedAt`), when it lapses (`expiresAt`), when
|
||
/// it was last exercised (`lastUsedAt`), and who ended it (`revokedAt`,
|
||
/// `revokedBy`).
|
||
///
|
||
/// What is deliberately NOT a column: the address the delegate accepted from.
|
||
/// A consent record has to say who consented and when, and this one does. It
|
||
/// does not need to say from where, and an address kept for as long as both
|
||
/// accounts exist is personal data held past the point it answers anything.
|
||
/// `AuditLog` still records the acceptance with its IP under that table's own
|
||
/// retention.
|
||
///
|
||
/// Consequences worth stating where they can be read next to the columns:
|
||
///
|
||
/// * **Revocation never deletes.** It stamps `revokedAt` + `revokedBy`. A
|
||
/// deleted row cannot answer "who had access, from when to when, and who
|
||
/// ended it" — the question the owner is owed.
|
||
/// * **The uniqueness constraint is partial and lives only in migration
|
||
/// 0292** — `UNIQUE (grantor_id, grantee_id) WHERE revoked_at IS NULL`.
|
||
/// Prisma cannot express the predicate, and declaring the plain pair here
|
||
/// would be worse than declaring nothing: it would hand the client a
|
||
/// `findUnique` over a key the database does not hold unique, so a pair
|
||
/// with several revoked rows would resolve to an arbitrary one.
|
||
/// `Device.apnsToken` carries its partial unique index the same way. At
|
||
/// most one LIVE grant per pair; history accumulates freely underneath.
|
||
/// * **Pending confers nothing and expiry is live.** `acceptedAt IS NULL` is
|
||
/// an invitation, not access, and a grant past `expiresAt` fails the active
|
||
/// predicate on the request that reads it rather than at write time. Both
|
||
/// are decided in `src/lib/sharing/grants.ts` and nowhere else.
|
||
/// * **Both foreign keys cascade.** A grant naming a user who no longer
|
||
/// exists is a dangling authorization — resolvable by nobody, revocable by
|
||
/// nobody — so deleting EITHER account removes it, not just the owner.
|
||
///
|
||
/// Self-grants are refused by the domain module rather than by a database
|
||
/// CHECK; migration 0292 carries the reason (the schema-driven wipe seeder
|
||
/// binds every `users` foreign key to one account, so a row it plants here has
|
||
/// grantor = grantee by construction).
|
||
model AccountGrant {
|
||
id String @id @default(cuid())
|
||
|
||
/// The account whose record is being shared. "Owner" everywhere in the code.
|
||
grantorId String @map("grantor_id")
|
||
/// The account being given access. "Delegate" everywhere in the code.
|
||
granteeId String @map("grantee_id")
|
||
|
||
access AccountGrantAccess @default(READ)
|
||
|
||
/// v1.37.0 — which sections of the record this grant opens.
|
||
///
|
||
/// NULL means the entire record, which is exactly what every row written
|
||
/// before this column existed was consented as — so there is no backfill and
|
||
/// nobody is asked to consent again. A non-NULL value is a non-empty array
|
||
/// of `ShareDomain` keys (`src/lib/sharing/scope.ts`).
|
||
///
|
||
/// Read fail-closed through `normaliseScope` in `src/lib/sharing/grants.ts`
|
||
/// and nowhere else: a malformed non-NULL blob resolves to the empty set and
|
||
/// refuses every section, the opposite direction from the module preference
|
||
/// normaliser, because this column answers an authorization question and
|
||
/// that one answers a presentation question.
|
||
///
|
||
/// Always NULL on a MANAGE row. Management is whole-record: "they can do
|
||
/// anything, but only to part of you" promises a boundary that cannot be
|
||
/// kept once edits are in play.
|
||
scopeJson Json? @map("scope_json")
|
||
|
||
/// When the invitation was offered. A domain event, so re-offering an
|
||
/// invitation on the same pending row moves it; `createdAt` below is row
|
||
/// bookkeeping and never moves. They agree the day the row is written and
|
||
/// are allowed to disagree afterwards.
|
||
invitedAt DateTime @default(now()) @map("invited_at")
|
||
/// When the delegate accepted. NULL = pending, which confers nothing.
|
||
/// One-shot: an accepted grant can never be accepted again.
|
||
acceptedAt DateTime? @map("accepted_at")
|
||
|
||
/// When access ended, and who ended it. Written together, never cleared —
|
||
/// a re-invitation is a NEW row. The database enforces that the two are
|
||
/// either both set or both null.
|
||
revokedAt DateTime? @map("revoked_at")
|
||
revokedBy AccountGrantRevoker? @map("revoked_by")
|
||
|
||
/// Optional lapse date. Checked live on every resolution, so an expired
|
||
/// grant stops conferring access without anything having to sweep it. A
|
||
/// managed-profile invitation may carry an expiry while awaiting consent;
|
||
/// accepting its MANAGE grant clears this field because an active Guardian
|
||
/// ends only through an explicit lifecycle transition.
|
||
expiresAt DateTime? @map("expires_at")
|
||
/// Refreshed fire-and-forget when the grant is exercised, the same posture
|
||
/// as `ApiToken.lastUsedAt`. The owner's panel reads it to answer "is this
|
||
/// access still being used, or is it a grant nobody remembers".
|
||
lastUsedAt DateTime? @map("last_used_at")
|
||
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
grantor User @relation("AccountGrantsGiven", fields: [grantorId], references: [id], onDelete: Cascade)
|
||
grantee User @relation("AccountGrantsReceived", fields: [granteeId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([granteeId, createdAt(sort: Desc)])
|
||
@@index([grantorId, createdAt(sort: Desc)])
|
||
@@map("account_grants")
|
||
}
|
||
|
||
// 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")
|
||
}
|
||
|
||
// v1.39.0 — the per-metric status notes ("Status notes"): what a model wrote
|
||
// about one metric for one person in one language, and the fingerprints that
|
||
// let the next run skip the model when nothing changed.
|
||
//
|
||
// One row per (user, metric, locale), upserted in place. Until v1.39.0 these
|
||
// notes were appended to `audit_logs` as plaintext JSON, one row per metric per
|
||
// day, mixed with the security trail; migration 0344 dropped those rows, so the
|
||
// first night after the upgrade regenerates every note once.
|
||
//
|
||
// A regenerable cache, not a record: excluded from backups, deleted by "Delete
|
||
// All Data", purged when the person withdraws AI consent, and never served
|
||
// while the `statusText` capability is unavailable.
|
||
model InsightStatusCache {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
/// The status scope: `weight`, `blood-pressure`, `general`, …, or a prefixed
|
||
/// generic scope (`metric:<ID>`, `derived-score:<ID>`, `biomarker:<id>`).
|
||
metric String
|
||
/// The UI locale the note was written in.
|
||
locale String
|
||
/// AES-256-GCM ciphertext of the note. Null while the row only carries a
|
||
/// negative-cache window (a generation that stalled before any note existed).
|
||
textEncrypted Bytes? @map("text_encrypted")
|
||
/// AES-256-GCM ciphertext of per-item notes as JSON, for a card that carries
|
||
/// them (medication compliance: one line per medication). Null otherwise.
|
||
itemsEncrypted Bytes? @map("items_encrypted")
|
||
/// Cheap fingerprint of the salient inputs (count + newest reading).
|
||
inputHash String? @map("input_hash")
|
||
/// Fingerprint of the full data snapshot the note was written from.
|
||
snapshotHash String? @map("snapshot_hash")
|
||
/// The person's local YYYY-MM-DD the note counts as current for.
|
||
dateKey String @map("date_key")
|
||
/// When the note was written, or last re-dated by an unchanged-data run.
|
||
generatedAt DateTime? @map("generated_at")
|
||
/// Negative cache: while in the future, a read does not enqueue another
|
||
/// generation. Cleared when a note is written.
|
||
retryAt DateTime? @map("retry_at")
|
||
/// Why the last generation served the deterministic line instead of a note:
|
||
/// `timeout` | `error` | `screened`. Set together with `retryAt`.
|
||
negativeReason String? @map("negative_reason")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([userId, metric, locale])
|
||
@@map("insight_status_caches")
|
||
}
|
||
|
||
// ─── 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 twice over: it is the "just in" marker the
|
||
// reaction surfaces read and the durable identity whose newest same-day
|
||
// revision owns generation. A strictly newer arrival atomically invalidates
|
||
// stale generation state; equal or older deliveries are deduplicated.
|
||
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")
|
||
/// When the newest sample actually reached HealthLog. Unlike `createdAt`,
|
||
/// this advances when a later same-kind arrival refreshes the daily marker.
|
||
arrivedAt DateTime @default(now()) @map("arrived_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")
|
||
/// Opaque owner token and lease for pre-provider setup. A stale worker may
|
||
/// reclaim only while `generationProviderInvokedAt` is null.
|
||
generationClaimId String? @map("generation_claim_id")
|
||
generationClaimedAt DateTime? @map("generation_claimed_at")
|
||
/// The token reservation is written in the same transaction as the
|
||
/// `coach_usage` increment. A reclaimed pre-provider attempt reuses it.
|
||
generationReservedTokens Int? @map("generation_reserved_tokens")
|
||
generationBudgetDateKey String? @map("generation_budget_date_key")
|
||
/// v1.38.19 — who the reservation above was booked to, `"operator"`
|
||
/// or `"user"`. Persisted rather than recomputed: the supersede refund in
|
||
/// `data-arrival.ts` has no chain to ask, and the resume path's chain may
|
||
/// have been reordered by the health ledger since the reservation was made.
|
||
/// Null on rows written before the column existed.
|
||
generationCostOwner String? @map("generation_cost_owner")
|
||
/// Durable provider boundary. Once set, this marker is terminal unless the
|
||
/// encrypted line is committed; no retry may invoke a provider again.
|
||
generationProviderInvokedAt DateTime? @map("generation_provider_invoked_at")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
// One mutable marker per kind and local day. Do not include `refId`: newer
|
||
// same-day referents replace this row atomically.
|
||
@@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")
|
||
/// The start that folded this one in (a start logged a few days earlier is
|
||
/// the same period entered out of order). Set only on the soft-deleted,
|
||
/// folded row; removing the absorbing start restores every row naming it
|
||
/// (`removeCycleStartedOn`). Cleared when the row is live again.
|
||
absorbedIntoId String? @map("absorbed_into_id")
|
||
|
||
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])
|
||
// 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])
|
||
// Keyset support for the `/api/sync/changes` cycle-day feed.
|
||
@@index([userId, updatedAt, id])
|
||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||
@@index([cycleId])
|
||
@@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")
|
||
/// Where on the body the condition sits, in the person's own words
|
||
/// ("knee", "lower back", "L4/L5"), v1.39.2. The same field, codec and
|
||
/// reasoning as `Encounter.bodySiteEncrypted`: free text because no picker
|
||
/// covers every site and a coded vocabulary the person cannot read is worse
|
||
/// than their own word, encrypted because it is health data, and searched
|
||
/// by decrypting the account's own rows server-side
|
||
/// (`src/lib/body-sites/`), never through a plaintext or hashed copy.
|
||
///
|
||
/// Allowed on every type. An injury or a chronic joint condition is the
|
||
/// usual case, but a type edit must not lose what was typed.
|
||
///
|
||
/// The shipped iOS app neither sends nor decodes it, and the PATCH treats
|
||
/// an absent key as "leave alone", so an edit from the phone keeps it.
|
||
bodySiteEncrypted Bytes? @map("body_site_encrypted")
|
||
/// Which side, when the site has one. Same enum and the same NULL meaning
|
||
/// as `Encounter.laterality`: not stated or not applicable. Plaintext,
|
||
/// because a side alone says nothing.
|
||
laterality Laterality?
|
||
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[]
|
||
encounterLinks EncounterConditionLink[]
|
||
medicationLinks MedicationConditionLink[]
|
||
/// Symptom events the person filed against this episode while it was open.
|
||
symptomEvents SymptomEvent[]
|
||
|
||
@@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])
|
||
@@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")
|
||
}
|
||
|
||
/// A symptom the person defined for themselves ("aura", "headache"), v1.40.
|
||
///
|
||
/// Separate from the seeded `IllnessSymptom` catalogue on the illness day log:
|
||
/// that one is a fixed list of cold symptoms rated 1-3 per episode day, this one
|
||
/// is the person's own vocabulary, logged as timestamped events with a 0-10
|
||
/// intensity whether or not an episode is open. Rides the `illness` module.
|
||
/// At most eight active definitions per account
|
||
/// (`MAX_ACTIVE_SYMPTOM_DEFINITIONS`), and each active one is its own
|
||
/// correlation channel `SYMPTOM:<id>`, so the cap also bounds the matrix.
|
||
model SymptomDefinition {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
/// The person's own name for the symptom, AES-256-GCM encrypted at rest. A
|
||
/// symptom name is health data in the person's own words.
|
||
labelEncrypted Bytes @map("label_encrypted")
|
||
/// Lucide icon name from `SYMPTOM_ICON_ALLOWLIST` (the cycle custom-symptom
|
||
/// list, which the iOS client already maps). NULL = the default glyph.
|
||
icon String?
|
||
sortOrder Int @default(0) @map("sort_order")
|
||
/// FALSE = hidden: not offered in the quick entry, not a correlation
|
||
/// channel, history kept. Does not count toward the cap.
|
||
isActive Boolean @default(true) @map("is_active")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
events SymptomEvent[]
|
||
|
||
@@index([userId, sortOrder])
|
||
@@map("symptom_definitions")
|
||
}
|
||
|
||
/// One occurrence of a person-defined symptom: when, how strong (0-10, the
|
||
/// same numeric rating scale the pain score uses), an optional note, and an
|
||
/// optional link to the illness episode it happened during. v1.40.
|
||
model SymptomEvent {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
definitionId String @map("definition_id")
|
||
/// The instant it happened (UTC). Day grouping is the reader's concern.
|
||
occurredAt DateTime @map("occurred_at")
|
||
/// 0-10, CHECK-constrained in the migration.
|
||
intensity Int
|
||
/// Optional free-text note, AES-256-GCM encrypted at rest.
|
||
noteEncrypted Bytes? @map("note_encrypted")
|
||
/// The episode the person said this happened during. Only an open episode
|
||
/// of the same account can be linked (the route checks); deleting the
|
||
/// episode row keeps the event and drops the link.
|
||
episodeId String? @map("episode_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
definition SymptomDefinition @relation(fields: [definitionId], references: [id], onDelete: Cascade)
|
||
episode IllnessEpisode? @relation(fields: [episodeId], references: [id], onDelete: SetNull)
|
||
|
||
@@index([userId, occurredAt])
|
||
@@index([definitionId, occurredAt])
|
||
@@index([episodeId])
|
||
@@map("symptom_events")
|
||
}
|
||
|
||
// ─── 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
|
||
/// A certificate of incapacity for work (in Germany the
|
||
/// Arbeitsunfähigkeitsbescheinigung) and its equivalents.
|
||
SICK_NOTE
|
||
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
|
||
/// @internal: reserved, no writer. An extraction in flight is the state of
|
||
/// its background run (`DocumentAiRun`), not of the document, so the vault
|
||
/// never shows a status nothing renders.
|
||
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")
|
||
/// When the most recent content-index attempt finished (auto job, corpus
|
||
/// backfill, or the manual index route — they share one recorder),
|
||
/// successful or not. Null until any attempt ran. Diagnostic state the
|
||
/// detail view pairs with `lastIndexOutcome`; carried verbatim in the
|
||
/// disaster-recovery backup like its summary siblings.
|
||
lastIndexAttemptAt DateTime? @map("last_index_attempt_at")
|
||
/// Why the most recent index attempt produced NO index — a closed string
|
||
/// enum (`DocumentIndexOutcomeValue` in
|
||
/// `src/lib/validations/inbound-documents.ts`): local-empty |
|
||
/// local-unsupported | decrypt-error | raster-failed | provider-error |
|
||
/// pdf-needs-anthropic | empty-transcription. Null after a successful
|
||
/// attempt (the `DocumentContentIndex` row itself is the success signal)
|
||
/// and before the first attempt. Lets the detail view tell "never tried"
|
||
/// from "tried and here is why it failed" instead of showing a bare
|
||
/// not-indexed state for both.
|
||
lastIndexOutcome String? @map("last_index_outcome")
|
||
/// v1.39.2 (#1038) — where an imported document came from: a closed string
|
||
/// enum (`DOCUMENT_SOURCE_SYSTEMS` in
|
||
/// `src/lib/validations/inbound-documents.ts`): PAPERLESS | PAPRA | OTHER.
|
||
/// Null for a document added in HealthLog itself. Sent by the import script
|
||
/// and the Paperless workflow recipe; shown on the detail sheet.
|
||
sourceSystem String? @map("source_system") @db.VarChar(16)
|
||
/// v1.39.2 (#1038) — the document's id in `sourceSystem`. Together with
|
||
/// `userId`, `sourceSystem` and `sourceInstance` it is unique across live
|
||
/// AND tombstoned rows (raw SQL in migrations 0353 and 0356, not expressible
|
||
/// in Prisma; see `sourceInstance` for the current definition).
|
||
/// Including tombstones is the point: re-running an importer must not bring
|
||
/// back a document the person deleted. The purge carries the key over to
|
||
/// `DocumentImportKey` when it removes the row.
|
||
sourceId String? @map("source_id") @db.VarChar(128)
|
||
/// v1.39.3 (#1038) — which instance of `sourceSystem` the document came
|
||
/// from: the normalised origin (`scheme://host[:port]`) of the Paperless-ngx
|
||
/// or Papra it was fetched from, so id 123 on one Paperless and id 123 on
|
||
/// another are two keys. Plaintext; an address, not health data. Null for a
|
||
/// key sent without one (every v1.39.2 import, an older script, a workflow
|
||
/// that does not send it): such a key matches any instance of its system,
|
||
/// and a lookup without an instance matches any stored instance. Part of the
|
||
/// source-key unique index since migration 0356, which rebuilt it as
|
||
/// (user_id, source_system, source_instance, source_id) NULLS NOT DISTINCT
|
||
/// WHERE source_id IS NOT NULL.
|
||
sourceInstance String? @map("source_instance") @db.VarChar(512)
|
||
/// v1.39.2 (#1038) — the upload asked for `aiRead=defer`: nothing may send
|
||
/// this document to an AI provider on its own (the summary catch-up, the
|
||
/// summary job, lab auto-staging all skip it). Cleared when the person
|
||
/// explicitly reads it with AI or generates its summary.
|
||
aiReadDeferred Boolean @default(false) @map("ai_read_deferred")
|
||
|
||
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?
|
||
thumbnailFailure DocumentThumbnailFailure?
|
||
/// 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[]
|
||
/// Further import source keys that resolved to this document by content.
|
||
sourceAliases DocumentSourceAlias[]
|
||
/// Background AI reads of this document (v1.40). Transient; cascades.
|
||
aiRuns DocumentAiRun[]
|
||
|
||
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)
|
||
encounterLinks EncounterDocumentLink[]
|
||
/// v1.43 — lab results and medications filed against this document.
|
||
labResultLinks LabResultDocumentLink[]
|
||
medicationLinks MedicationDocumentLink[]
|
||
/// Vaccination memberships (m:n via `VaccinationDocumentLink`): a scanned
|
||
/// Impfpass page carries many doses, and a dose may be evidenced by more
|
||
/// than one page.
|
||
vaccinationLinks VaccinationDocumentLink[]
|
||
|
||
@@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")
|
||
}
|
||
|
||
/// v1.39.2 (#1038) — a source key an import sent for bytes that were already
|
||
/// stored under another document (a manual upload, or the same file under a
|
||
/// second id in the source). The upload answered "duplicate" with that
|
||
/// document, so the key belongs to it: a re-send resolves to it, and when the
|
||
/// purge removes the document the key moves to `DocumentImportKey` with its
|
||
/// own, so deleting the document keeps every key that ever reached it deleted.
|
||
/// Cascades with the document; the purge copies it first.
|
||
model DocumentSourceAlias {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
documentId String @map("document_id")
|
||
/// Same closed vocabulary as `InboundDocument.sourceSystem`.
|
||
sourceSystem String @map("source_system") @db.VarChar(16)
|
||
sourceId String @map("source_id") @db.VarChar(128)
|
||
/// v1.39.3 — the source instance, as on `InboundDocument.sourceInstance`
|
||
/// (null matches any instance of the system).
|
||
sourceInstance String? @map("source_instance") @db.VarChar(512)
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||
|
||
// NULLS NOT DISTINCT in migration 0356 (hand-written; Prisma would
|
||
// regenerate it NULLS DISTINCT): a key stored without an instance still
|
||
// collides with the same key without one.
|
||
@@unique([userId, sourceSystem, sourceInstance, sourceId])
|
||
@@index([documentId])
|
||
@@map("document_source_aliases")
|
||
}
|
||
|
||
/// v1.39.2 (#1038) — the source key of an imported document that the daily
|
||
/// tombstone purge has physically removed. While the tombstone exists the key
|
||
/// lives on the document row itself; once the purge takes the row, this is the
|
||
/// only memory that the person deleted it, so a re-run of the importer answers
|
||
/// "deleted" instead of storing the document again. Written by the purge only;
|
||
/// read by the upload route's source-key check. Holds no document content.
|
||
model DocumentImportKey {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
/// Same closed vocabulary as `InboundDocument.sourceSystem`.
|
||
sourceSystem String @map("source_system") @db.VarChar(16)
|
||
sourceId String @map("source_id") @db.VarChar(128)
|
||
/// v1.39.3 — the source instance, as on `InboundDocument.sourceInstance`
|
||
/// (null matches any instance of the system).
|
||
sourceInstance String? @map("source_instance") @db.VarChar(512)
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
// NULLS NOT DISTINCT in migration 0356 (hand-written; Prisma would
|
||
// regenerate it NULLS DISTINCT): a key stored without an instance still
|
||
// collides with the same key without one.
|
||
@@unique([userId, sourceSystem, sourceInstance, sourceId])
|
||
@@map("document_import_keys")
|
||
}
|
||
|
||
/// v1.39.3 (#1038) — one person's connection to a Paperless-ngx or Papra
|
||
/// instance, used by the document picker to search that system by name, tag
|
||
/// and date and import the documents the person picks. One per system per
|
||
/// person. Only reachable when the operator lists the instance's origin in
|
||
/// `DOCUMENT_SOURCE_ORIGINS`; the origin is re-checked on every request, so
|
||
/// removing it from the list cuts the connection off without touching the
|
||
/// row. No background job reads it: every request to the source is one the
|
||
/// person started. Not carried in backups (a third-party credential for a
|
||
/// service on the operator's network; see `NOT_IN_BACKUP_MODELS`); removed by
|
||
/// Delete all data and with the account.
|
||
model DocumentSourceConnection {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
/// `PAPERLESS` | `PAPRA` (`DOCUMENT_PICKER_SYSTEMS` in
|
||
/// `src/lib/documents/sources/types.ts`).
|
||
system String @map("system") @db.VarChar(16)
|
||
/// Base address of the instance, origin plus an optional path. Plaintext:
|
||
/// its origin is compared with the operator's list on every use, and the
|
||
/// settings card shows it. Not health data.
|
||
baseUrl String @map("base_url") @db.VarChar(2048)
|
||
/// Papra organization id; null for Paperless-ngx.
|
||
organizationId String? @map("organization_id") @db.VarChar(128)
|
||
/// The API token (Paperless-ngx) or API key (Papra), AES-256-GCM via
|
||
/// `encrypt()`. No route returns it, in whole or in part.
|
||
tokenEncrypted String @map("token_encrypted")
|
||
/// Last time the source answered a test with this token: the save's live
|
||
/// check or the settings card's Test button. Shown on the card.
|
||
lastVerifiedAt DateTime? @map("last_verified_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, system])
|
||
@@map("document_source_connections")
|
||
}
|
||
|
||
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 `CONTENT_TOKENIZER_VERSION`
|
||
/// (`src/lib/documents/content-index.ts`) to force a re-index on a scheme
|
||
/// change. Consumer: the content-index backfill job re-tokenises any row
|
||
/// whose stored version trails the current one from the decrypted
|
||
/// `textEncrypted` (`retokeniseStaleRows` in
|
||
/// `src/lib/jobs/document-content-index-backfill.ts`).
|
||
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")
|
||
/// @internal: source MIME the preview was rendered from — operator-side
|
||
/// diagnostic provenance only; no query selects it and no client surfaces it.
|
||
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")
|
||
}
|
||
|
||
/// Why a document has no preview thumbnail, kept so the boot-time backfill
|
||
/// stops queueing a document whose render cannot succeed. A PDF whose first
|
||
/// page renders blank was queued, rendered, refused and queued again at
|
||
/// every boot, without end. A deterministic refusal (`raster-failed`,
|
||
/// `empty-render`, `pixel-cap`) is final after one attempt; any other reason
|
||
/// gets three. Either way the document is tried again once it changes
|
||
/// (`updatedAt` past `failedAt`), and a direct enqueue (an upload) still
|
||
/// renders it. A preview that is made deletes the row.
|
||
/// Consumers: `runThumbnailBackfillForUser()` and
|
||
/// `enqueueBootTimeThumbnailBackfill()` in
|
||
/// `src/lib/jobs/document-thumbnail-backfill.ts`.
|
||
model DocumentThumbnailFailure {
|
||
documentId String @id @map("document_id")
|
||
userId String @map("user_id")
|
||
/// The renderer's reason (`ThumbnailSkipReason`), or `decrypt-failed`.
|
||
reason String
|
||
/// Renders that failed since the last success or change.
|
||
attempts Int @default(1)
|
||
/// When the newest of them failed.
|
||
failedAt DateTime @map("failed_at")
|
||
|
||
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([userId])
|
||
@@map("document_thumbnail_failures")
|
||
}
|
||
|
||
// ─── 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")
|
||
}
|
||
|
||
// ─── Visits: encounters and practitioners (v1.38.0) ────────────────
|
||
//
|
||
// The whole of the previous "which doctor" model was one free-text
|
||
// column (`MeasurementReminder.location`). A person already typed the
|
||
// practice name, once per reminder, with no way to reach anything else
|
||
// that happened at that practice. These five models turn that string
|
||
// into a record: a visit that happened or is booked, the address book
|
||
// it points at, and three links to the things the visit produced.
|
||
|
||
/// A single contact with the healthcare system: one visit that happened,
|
||
/// or one that is booked.
|
||
///
|
||
/// Slim by decision. The diagnosis lives on the linked `IllnessEpisode`,
|
||
/// the address on the linked `Practitioner`, and everything a person
|
||
/// would otherwise retype is a link rather than a column. A free-text
|
||
/// `diagnosis` beside the first-class condition entity would be a
|
||
/// second, weaker copy of the same fact and would diverge within a
|
||
/// release.
|
||
///
|
||
/// No module gate, deliberately, and the absence is a decision rather
|
||
/// than an oversight. The 18 `ModuleKey` entries have three shapes: an
|
||
/// opt-out data vertical, an egress surface, and one output format. A
|
||
/// visit is none of them — it is not a tracked signal, it egresses
|
||
/// nothing, and it is not an output. `/checkups` sets the precedent and
|
||
/// states the reasoning (`src/components/layout/nav-model.ts`): gating
|
||
/// the entry would orphan reminders the user can still create. One gate
|
||
/// over an encounter would also contradict its own members, since an
|
||
/// encounter links to documents, labs and conditions — three separately
|
||
/// toggleable modules with no truthful shared off-state. The gate is
|
||
/// relocated to the link pickers, which follow the modules that own the
|
||
/// things they point at.
|
||
model Encounter {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
/// The visit instant (UTC; wall-clock anchoring is the reader's
|
||
/// concern, per the schema's UTC-only convention). Future instants are
|
||
/// legal — that is the whole appointment half.
|
||
occurredAt DateTime @map("occurred_at")
|
||
/// Where in its lifecycle. PLANNED is a future appointment; DONE is a
|
||
/// visit that happened; CANCELLED and NO_SHOW are terminal and keep
|
||
/// the row so the history stays honest.
|
||
///
|
||
/// The FHIR mapping happens at the export boundary, not here:
|
||
/// PLANNED → `Appointment.booked`, DONE → `Encounter.finished`,
|
||
/// CANCELLED → `cancelled`, NO_SHOW → `Appointment.noshow`. FHIR
|
||
/// splits the future from the past across two resource types, which is
|
||
/// a serialisation concern and not a storage one. No FHIR resource
|
||
/// type is added for visits in this release and
|
||
/// `FHIR_REST_RESOURCE_TYPES` (`src/lib/fhir/rest.ts`) is deliberately
|
||
/// untouched — a capability statement that names a type with no route
|
||
/// module lies.
|
||
status EncounterStatus @default(DONE)
|
||
kind EncounterKind @default(OTHER)
|
||
practitionerId String? @map("practitioner_id")
|
||
/// The person's own framing of why they went or are going. Encrypted
|
||
/// at rest (AES-256-GCM, the `*Encrypted` Bytes convention).
|
||
reasonEncrypted Bytes? @map("reason_encrypted")
|
||
/// What came out of it — plan, follow-up, what to watch. Same codec.
|
||
/// One field rather than a treatment/follow-up pair whose boundary
|
||
/// nobody can state: two columns would be two chances to put the text
|
||
/// in the wrong one, and the actionable half of a follow-up is not
|
||
/// prose at all, it is the next appointment.
|
||
outcomeEncrypted Bytes? @map("outcome_encrypted")
|
||
/// Where on the body a procedure was done, in the person's own words
|
||
/// ("knee", "gallbladder", "L4/L5"). Free text, because no picker covers
|
||
/// everything a surgeon operates on, and a coded vocabulary the person
|
||
/// cannot read is worse than their own word. Health data, so encrypted
|
||
/// like `reasonEncrypted`.
|
||
///
|
||
/// Searching it does not need plaintext at rest: the procedures read
|
||
/// (`src/lib/encounters/procedures.ts`) decrypts the account's own
|
||
/// procedures server-side and matches there. That set is a person's
|
||
/// surgical history — tens of rows, not thousands — so a decrypt pass
|
||
/// per search is cheap, and a plaintext or hashed search column would be
|
||
/// a second copy of the most identifying field on the row.
|
||
///
|
||
/// Allowed on any kind, surfaced for PROCEDURE: switching a visit's kind
|
||
/// away and back must not lose what was typed.
|
||
bodySiteEncrypted Bytes? @map("body_site_encrypted")
|
||
/// Which side, when the site has one. Separate from the text so "left
|
||
/// knee" and "right knee" group and filter as the same site on two sides
|
||
/// without parsing prose, and plaintext because a side alone says
|
||
/// nothing. NULL means not stated or not applicable, deliberately one
|
||
/// value rather than a NONE beside NULL that would mean the same.
|
||
laterality Laterality?
|
||
/// Optional link to the reminder this visit closes. Set when the user
|
||
/// files the visit from a due checkup, or minted here when the visit
|
||
/// is PLANNED. Satisfying the reminder is the side effect, not this
|
||
/// column.
|
||
reminderId String? @map("reminder_id")
|
||
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)
|
||
practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull)
|
||
reminder MeasurementReminder? @relation(fields: [reminderId], references: [id], onDelete: SetNull)
|
||
documentLinks EncounterDocumentLink[]
|
||
labLinks EncounterLabLink[]
|
||
conditionLinks EncounterConditionLink[]
|
||
medicationLinks EncounterMedicationLink[]
|
||
vaccinationLinks EncounterVaccinationLink[]
|
||
/// Doses administered at this visit. SetNull on the far side: deleting the
|
||
/// visit leaves the immunization history intact.
|
||
vaccinations VaccinationRecord[]
|
||
|
||
@@index([userId, deletedAt, occurredAt])
|
||
@@index([userId, status, occurredAt])
|
||
@@index([practitionerId])
|
||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||
@@index([reminderId])
|
||
@@map("encounters")
|
||
}
|
||
|
||
/// Where a visit sits in its lifecycle. Terminal states keep the row.
|
||
enum EncounterStatus {
|
||
PLANNED
|
||
DONE
|
||
CANCELLED
|
||
NO_SHOW
|
||
|
||
@@map("encounter_status")
|
||
}
|
||
|
||
/// What kind of contact it was. Defaulted to OTHER so it is never a
|
||
/// required pick, and small enough to stay readable at a glance in a
|
||
/// list. Maps to FHIR `Encounter.class` at the export boundary.
|
||
///
|
||
/// PROCEDURE (v1.39.1) marks a surgery or procedure. A kind rather than a
|
||
/// boolean beside the kind, because the kind already answers "what was
|
||
/// this contact" and a flag would make HOSPITAL-plus-flag and PROCEDURE two
|
||
/// spellings of one visit with no rule for which to use. The cost is that a
|
||
/// surgery done in an emergency is filed as PROCEDURE and not EMERGENCY; the
|
||
/// operation is the fact a surgical history asks for, and the reason text
|
||
/// keeps the rest. Additive for every client in the field: the shipped iOS
|
||
/// app does not decode visits at all, and the web client ships with the
|
||
/// server.
|
||
enum EncounterKind {
|
||
ROUTINE
|
||
ACUTE
|
||
SPECIALIST
|
||
PREVENTIVE
|
||
EMERGENCY
|
||
HOSPITAL
|
||
THERAPY
|
||
OTHER
|
||
PROCEDURE
|
||
|
||
@@map("encounter_kind")
|
||
}
|
||
|
||
/// The side of the body a site is on. See `Encounter.laterality`; since
|
||
/// v1.39.2 also `IllnessEpisode.laterality`.
|
||
enum Laterality {
|
||
LEFT
|
||
RIGHT
|
||
BOTH
|
||
|
||
@@map("laterality")
|
||
}
|
||
|
||
/// The account's own address book of doctors and practices.
|
||
///
|
||
/// User-scoped, and the scoping is the point: a global registry with an
|
||
/// instance-wide unique practice name leaks one household member's
|
||
/// cardiologist into another's picker and makes two self-hosters on one
|
||
/// instance share a namespace. There is no uniqueness across accounts of
|
||
/// any kind here.
|
||
model Practitioner {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
/// "Dr. med. …" or a practice name — whichever the person thinks of
|
||
/// first. Plaintext and bounded, so the picker can ILIKE-search and
|
||
/// ORDER BY in SQL (the `InboundDocument.title` tradeoff).
|
||
name String
|
||
/// Free-text specialty, NOT an enum: German practice signage does not
|
||
/// fit a closed list, and an enum here would force a migration per
|
||
/// specialty for no reader that needs the closed set.
|
||
specialty String?
|
||
/// Practice or clinic name when it differs from `name`.
|
||
practice String?
|
||
/// @internal: legacy readable address, read only as a fallback for rows the
|
||
/// free-text encryption backfill has not reached yet (v1.39.4). Every write
|
||
/// since then goes to `locationEncrypted` and leaves this NULL; the backfill
|
||
/// nulls it on the old rows. DROP this column in a later release, once the
|
||
/// backfill reports zero remaining rows on every instance.
|
||
location String?
|
||
/// @internal: legacy readable phone number, same status as `location`
|
||
/// above. DROP this column in a later release, once the backfill reports
|
||
/// zero remaining rows on every instance.
|
||
phone String?
|
||
/// v1.39.4 — AES-256-GCM ciphertext of the free-text address / "wo". Same
|
||
/// Bytes codec as the note below.
|
||
locationEncrypted Bytes? @map("location_encrypted")
|
||
/// v1.39.4 — AES-256-GCM ciphertext of the phone number.
|
||
phoneEncrypted Bytes? @map("phone_encrypted")
|
||
/// Free-text note (encrypted): parking, which entrance, who to ask for.
|
||
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)
|
||
encounters Encounter[]
|
||
vaccinations VaccinationRecord[]
|
||
|
||
@@index([userId, deletedAt, name])
|
||
// `practitioners_contact_backfill_idx` is a partial index over
|
||
// `(user_id) WHERE phone IS NOT NULL OR location IS NOT NULL` — see
|
||
// migration 0361 for the encryption backfill's discovery scan. Raw SQL only
|
||
// (partial predicate not expressible in Prisma).
|
||
@@map("practitioners")
|
||
}
|
||
|
||
/// Visit ↔ stored document. Never mandatory: a visit saves without one,
|
||
/// and a document files without a visit.
|
||
model EncounterDocumentLink {
|
||
id String @id @default(cuid())
|
||
encounterId String @map("encounter_id")
|
||
documentId String @map("document_id")
|
||
userId String @map("user_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([encounterId, documentId])
|
||
@@index([documentId])
|
||
@@index([userId])
|
||
@@map("encounter_document_links")
|
||
}
|
||
|
||
/// Visit ↔ lab result.
|
||
///
|
||
/// m:n rather than a `LabResult.encounterId` scalar for two reasons: a
|
||
/// re-run panel breaks the 1:many the scalar assumes, and a scalar would
|
||
/// put a write on the hot lab-import path. `relevanceNote` is
|
||
/// deliberately omitted — a per-link annotation is the first step from a
|
||
/// link table towards a general edge store, which this venture refuses.
|
||
model EncounterLabLink {
|
||
id String @id @default(cuid())
|
||
encounterId String @map("encounter_id")
|
||
labResultId String @map("lab_result_id")
|
||
userId String @map("user_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||
labResult LabResult @relation(fields: [labResultId], references: [id], onDelete: Cascade)
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([encounterId, labResultId])
|
||
@@index([labResultId])
|
||
@@index([userId])
|
||
@@map("encounter_lab_links")
|
||
}
|
||
|
||
/// Visit ↔ condition episode. A visit legitimately touches two
|
||
/// conditions, exactly as a discharge letter does, which is why this is
|
||
/// a link table and not a scalar FK (the `DocumentConditionLink` shape).
|
||
model EncounterConditionLink {
|
||
id String @id @default(cuid())
|
||
encounterId String @map("encounter_id")
|
||
episodeId String @map("episode_id")
|
||
userId String @map("user_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||
episode IllnessEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([encounterId, episodeId])
|
||
@@index([episodeId])
|
||
@@index([userId])
|
||
@@map("encounter_condition_links")
|
||
}
|
||
|
||
// ─── Vaccinations ─────────────────────────────────────────────
|
||
|
||
/// v1.38.0 — one administered dose, the Impfpass line made structured.
|
||
///
|
||
/// The model is deliberately slim, and every absence below is a decision:
|
||
///
|
||
/// - The disease, the typical series length and the booster interval are
|
||
/// NOT columns. They belong to the antigen, not to the dose, and live on
|
||
/// the catalogue entry behind `antigenSlug`
|
||
/// (`src/lib/vaccinations/vaccine-catalog.ts`) where they can be corrected
|
||
/// in a patch release instead of a migration.
|
||
/// - The practice address is not here either; it is on the linked
|
||
/// `Practitioner`. The scanned Pass page is not here; it is on the
|
||
/// document link.
|
||
/// - Either `antigenSlug` or `vaccineName` must be present, and that rule
|
||
/// lives in Zod (`src/lib/validations/vaccinations.ts`), not in a DB
|
||
/// CHECK. A CHECK would refuse a legacy row on restore, and a backup that
|
||
/// cannot round-trip is not a backup.
|
||
/// - There is no manufacturer column and no route column. Route follows the
|
||
/// antigen and derives from the catalogue at the FHIR boundary. A
|
||
/// manufacturer column would pull trade names into shipped content; the
|
||
/// only place a trade name legitimately appears is `vaccineName`, which is
|
||
/// the person's own transcription of their Pass — user data, not ours.
|
||
///
|
||
/// Module gate: `"vaccinations"`, SURFACE-gated like `medications`. The nav
|
||
/// entry, the dashboard and report surfaces and the picker hide when the
|
||
/// module is off; these data routes stay exempt so a restore or an import
|
||
/// keeps working and re-enabling finds every row intact.
|
||
model VaccinationRecord {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
/// The day the dose was given, at UTC midnight. An Impfpass carries dates,
|
||
/// never times, so the time component is not meaningful and is not
|
||
/// collected. The API refuses future instants: a vaccination is logged
|
||
/// after the fact, and a planned booster is a reminder, not a record.
|
||
occurredAt DateTime @map("occurred_at")
|
||
/// Catalogue identity. NULL = a free-text-only row, which is the arm that
|
||
/// makes a 1987 Pass line loggable at all.
|
||
///
|
||
/// Slugs are append-only by convention (the `BIOMARKER_CATALOG` precedent,
|
||
/// `src/lib/labs/biomarker-catalog.ts`). Storage is deliberately tolerant
|
||
/// where the API is strict: `POST` refuses a slug the catalogue does not
|
||
/// resolve, but a restore writes whatever the backup held. A slug that
|
||
/// stops resolving degrades to the free-text arm — a tested behaviour, not
|
||
/// an accident (`src/lib/vaccinations/__tests__/catalog-integrity.test.ts`).
|
||
antigenSlug String? @map("antigen_slug")
|
||
/// The Pass's own wording, verbatim. May be a trade name; that is user
|
||
/// data. Plaintext and bounded so the list can render and search it.
|
||
vaccineName String? @map("vaccine_name")
|
||
/// The Pass's claim about which dose this is. Optional — the series
|
||
/// derivation falls back to the chronological index per component antigen.
|
||
doseNumber Int? @map("dose_number")
|
||
/// Per-record override of the catalogue's typical series length, for the
|
||
/// schedule a person was actually given.
|
||
seriesDoses Int? @map("series_doses")
|
||
/// Chargenbezeichnung (IfSG §22). A batch code, not a secret: plaintext and
|
||
/// bounded so the list and the report can render it without a decrypt.
|
||
lotNumber String? @map("lot_number")
|
||
site VaccinationSite?
|
||
practitionerId String? @map("practitioner_id")
|
||
encounterId String? @map("encounter_id")
|
||
/// The booster reminder this dose satisfied, when one matched. The act runs
|
||
/// through `satisfyReminder`; this column is the record of it, not the act.
|
||
/// One id for possibly several satisfied rows — the engine keeps
|
||
/// `lastSatisfiedAt` per reminder, this keeps the first match in catalogue
|
||
/// component order.
|
||
reminderId String? @map("reminder_id")
|
||
/// v1.42 (#1005) — the user's own vaccine definition when the dose is for a
|
||
/// product the catalogue does not list, resolved through
|
||
/// `src/lib/vaccinations/resolve-vaccine-entry.ts`. Catalogue wins when both
|
||
/// resolve. Removing the definition unlinks the dose and leaves it a name
|
||
/// (`removeCustomVaccine`); SetNull covers a hard delete the same way.
|
||
customVaccineId String? @map("custom_vaccine_id")
|
||
/// The one free-text field (AES-256-GCM at rest, the `*Encrypted` Bytes
|
||
/// convention). Impfreaktionen live here in v1 rather than in a structured
|
||
/// reaction model nobody has asked for yet.
|
||
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)
|
||
practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull)
|
||
encounter Encounter? @relation(fields: [encounterId], references: [id], onDelete: SetNull)
|
||
reminder MeasurementReminder? @relation(fields: [reminderId], references: [id], onDelete: SetNull)
|
||
customVaccine CustomVaccine? @relation(fields: [customVaccineId], references: [id], onDelete: SetNull)
|
||
documentLinks VaccinationDocumentLink[]
|
||
encounterLinks EncounterVaccinationLink[]
|
||
|
||
@@index([userId, deletedAt, occurredAt])
|
||
@@index([userId, antigenSlug, occurredAt])
|
||
@@index([customVaccineId])
|
||
// Leads the foreign key, so a cascade from the parent row is an index lookup.
|
||
@@index([encounterId])
|
||
@@index([practitionerId])
|
||
@@index([reminderId])
|
||
@@map("vaccination_records")
|
||
}
|
||
|
||
/// v1.42 (#1005) — a vaccine the user defines for a product the shipped
|
||
/// catalogue does not list. Same shape the resolver hands out for a
|
||
/// catalogue entry (`components` = antigen slugs, typical series length,
|
||
/// booster interval), so series derivation and booster matching treat both
|
||
/// alike. Soft-deleted; a deleted definition leaves its doses standing on
|
||
/// their `vaccineName`.
|
||
model CustomVaccine {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
/// Display name, unique per user (the API also refuses a case-only twin).
|
||
name String
|
||
/// Antigen slugs the product covers, from `ANTIGEN_SLUGS`; storage is
|
||
/// tolerant, the resolver skips a slug it does not know.
|
||
components String[]
|
||
/// Typical primary-series length; the series total for its doses.
|
||
typicalSeriesDoses Int? @map("typical_series_doses")
|
||
/// Booster interval in months, the booster offer's prefill; NULL = none.
|
||
boosterIntervalMonths Int? @map("booster_interval_months")
|
||
/// Soft-delete tombstone. NULL = live.
|
||
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)
|
||
records VaccinationRecord[]
|
||
|
||
@@unique([userId, name])
|
||
@@map("custom_vaccines")
|
||
}
|
||
|
||
/// Where the dose went. Small and closed, because the Pass's own vocabulary
|
||
/// is small and closed; OTHER absorbs the rest rather than growing the enum.
|
||
enum VaccinationSite {
|
||
LEFT_ARM
|
||
RIGHT_ARM
|
||
LEFT_THIGH
|
||
RIGHT_THIGH
|
||
ORAL
|
||
NASAL
|
||
OTHER
|
||
|
||
@@map("vaccination_site")
|
||
}
|
||
|
||
/// Dose ↔ stored document, the `DocumentConditionLink` shape. m:n because a
|
||
/// scanned Impfpass page carries every dose on it, and a dose may be
|
||
/// evidenced by more than one page. Never mandatory: a dose saves without a
|
||
/// document, and a document files without a dose. `userId` is denormalised
|
||
/// for the owner-scoped list query. This is the only link table the
|
||
/// vaccination source owns — practitioner and encounter attach by scalar FK,
|
||
/// because a dose is administered once.
|
||
model VaccinationDocumentLink {
|
||
id String @id @default(cuid())
|
||
vaccinationId String @map("vaccination_id")
|
||
documentId String @map("document_id")
|
||
userId String @map("user_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
vaccination VaccinationRecord @relation(fields: [vaccinationId], references: [id], onDelete: Cascade)
|
||
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([vaccinationId, documentId])
|
||
@@index([documentId])
|
||
@@index([userId])
|
||
@@map("vaccination_document_links")
|
||
}
|
||
|
||
// ─── Onboarding (needs-based flow) ─────────────────────────────
|
||
|
||
/// v1.39 (C1) — what a record answered when it was set up, and how far the
|
||
/// flow got.
|
||
///
|
||
/// One row per RECORD, not per person: a managed profile is an ordinary `User`
|
||
/// row with no credentials, so keying on `userId` is what lets a guardian run
|
||
/// a child's setup and have the answers land on the child's record. Created
|
||
/// lazily by the first answer; an account that never entered the flow has no
|
||
/// row and reads as "nothing answered, nine steps pending".
|
||
///
|
||
/// The three JSON columns hold closed enum vocabularies, not free text, so
|
||
/// nothing here is encrypted: the encryption convention in this repository
|
||
/// covers free text a person wrote (`UserHealthProfile.aboutMeEncrypted`, the
|
||
/// Coach transcript, the emergency note) and identifiers (the KVNR), and an
|
||
/// answer drawn from nine fixed chips is neither. It is the same class of
|
||
/// value as `modulePreferencesJson`, which sits in plaintext beside it.
|
||
/// Readers are fail-soft rather than schema-validated at the database edge —
|
||
/// see `src/lib/onboarding/needs.ts`.
|
||
model OnboardingRecord {
|
||
id String @id @default(cuid())
|
||
userId String @unique @map("user_id")
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
/// The answers as given: `{ recordTarget, areas[], medication, sources[],
|
||
/// visit, units }`. Read by `parseOnboardingNeeds`, published on
|
||
/// `GET /api/auth/me` as `onboarding.needs`, and the input the module
|
||
/// derivation reads once at confirm time.
|
||
needsJson Json? @map("needs_json")
|
||
|
||
/// The ordered steps as `[{ id, status }]` with `status` one of
|
||
/// `pending | done | skipped`. Stored as a status lookup — the ORDER on the
|
||
/// wire is always this release's `ONBOARDING_STEP_IDS`, so a step added or
|
||
/// retired needs no backfill.
|
||
stepsJson Json? @map("steps_json")
|
||
|
||
/// The one task the flow offered at the end, and whether it produced its
|
||
/// result: `{ task, target, completedAt }`. Null until the first-result
|
||
/// step names one.
|
||
firstResultJson Json? @map("first_result_json")
|
||
|
||
/// When the module map was derived from `needsJson`. The derivation is
|
||
/// once-only per run of the flow: `POST /api/onboarding/complete` skips it
|
||
/// while this is set, and `POST /api/onboarding/restart` clears it so a
|
||
/// re-run may derive again. Without it a repeated confirm would re-apply
|
||
/// the answers over decisions taken in Settings since.
|
||
modulesDerivedAt DateTime? @map("modules_derived_at")
|
||
|
||
/// When the needs flow was completed. Deliberately NOT
|
||
/// `User.onboardingCompletedAt`: that column gates the first-run redirect in
|
||
/// `src/proxy.ts` and keeps that meaning, while this one may be cleared by
|
||
/// "Set up again" without pushing the person back through the redirect.
|
||
completedAt DateTime? @map("completed_at")
|
||
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
@@map("onboarding_records")
|
||
}
|
||
|
||
/// v1.40 — what a background document AI run is doing.
|
||
enum DocumentAiRunKind {
|
||
/// "Read with AI": transcribe the stored original into the content index,
|
||
/// then continue into lab staging.
|
||
DOCUMENT_INDEX
|
||
/// A lab report scan (photo, PDF or browser-read text) into proposed rows
|
||
/// for the review screen. Nothing is written until the person commits.
|
||
LABS_OCR_EXTRACT
|
||
/// A plain-language summary or the transcribed text of a stored document;
|
||
/// stored on the document only when the request asked for that.
|
||
DOCUMENT_SUMMARY
|
||
/// Filing suggestions (title, kind, date) for a stored document. Drafts
|
||
/// only: nothing is written to the document.
|
||
DOCUMENT_SUGGEST
|
||
/// Structured facts read out of a stored document, staged PENDING for the
|
||
/// review step. The confirm route stays the only write into the record.
|
||
DOCUMENT_EXTRACT
|
||
/// v1.43 — a supplement label read into a proposed composition. Nothing is
|
||
/// stored until the person saves it through the ingredients route.
|
||
SUPPLEMENT_LABEL_EXTRACT
|
||
|
||
@@map("document_ai_run_kind")
|
||
}
|
||
|
||
/// v1.40 — where a background document AI run stands.
|
||
enum DocumentAiRunStatus {
|
||
QUEUED
|
||
RUNNING
|
||
SUCCEEDED
|
||
FAILED
|
||
|
||
@@map("document_ai_run_status")
|
||
}
|
||
|
||
/// v1.40 — one model read that runs in the background instead of inside the
|
||
/// request that asked for it, so a proxy that cuts long requests cannot cut
|
||
/// the read. The request answers 202 with the run id; the client polls
|
||
/// `GET /api/ai-runs/{id}`. A ticket, not a record: the input is dropped the
|
||
/// moment the run finishes and the whole row is deleted an hour later
|
||
/// (`document-ai-run-reaper`). Never in a backup.
|
||
model DocumentAiRun {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
kind DocumentAiRunKind
|
||
/// The document a document run reads. Null for a lab scan, whose input is
|
||
/// the upload itself.
|
||
documentId String? @map("document_id")
|
||
status DocumentAiRunStatus @default(QUEUED)
|
||
/// How to run it: the input mode, the upload's sniffed MIME, the budget
|
||
/// reservation taken at enqueue. Never health data.
|
||
paramsJson Json @map("params_json")
|
||
/// AES-256-GCM (`encryptBytes`, labelled) of the input the worker needs and
|
||
/// cannot read anywhere else: the uploaded scan, or the text the browser
|
||
/// read off it. Null for a run that reads a stored document, and set to
|
||
/// null when the run finishes.
|
||
inputEncrypted Bytes? @map("input_encrypted")
|
||
/// AES-256-GCM (`encryptBytes`, labelled) of the result as JSON. It holds
|
||
/// model text and lab values. Served by the poll route until the row goes.
|
||
resultEncrypted Bytes? @map("result_encrypted")
|
||
/// The failure code a client branches on, the same code the synchronous
|
||
/// route would have answered with.
|
||
errorCode String? @map("error_code")
|
||
/// The HTTP status the synchronous route would have answered with, so a
|
||
/// client handles a failed run as it handled the failed request.
|
||
errorStatus Int? @map("error_status")
|
||
/// The failure in one sanitised sentence. Never upstream text.
|
||
errorMessage String? @map("error_message")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
startedAt DateTime? @map("started_at")
|
||
finishedAt DateTime? @map("finished_at")
|
||
/// The next deadline the reaper acts on: queued runs fail as
|
||
/// `aiRuns.workerUnavailable`, running ones as `aiRuns.timedOut`, finished
|
||
/// ones are deleted.
|
||
expiresAt DateTime @map("expires_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
document InboundDocument? @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([userId, createdAt])
|
||
@@index([documentId, kind, status])
|
||
@@index([status, expiresAt])
|
||
@@map("document_ai_runs")
|
||
}
|
||
|
||
/// A dated anchor in the person's life for the timeline (v1.42, #613): a move,
|
||
/// a birth, a new job, a loss. Not a health reading and not a symptom; it gives
|
||
/// the years around the readings something to hang on.
|
||
///
|
||
/// Shared under the `profile` domain, beside allergies, visits and the
|
||
/// immunization history. Never sent to a model in any form: the Coach, the
|
||
/// MCP endpoint and every AI job leave it out, and the day loader's model-facing
|
||
/// projection drops it.
|
||
///
|
||
/// Dates are calendar dates without a time zone (`YYYY-MM-DD`), the
|
||
/// `src/lib/tz/date-only.ts` convention, because "we moved in September 2023"
|
||
/// names a day on a calendar, not an instant. `precision` says how much of the
|
||
/// date the person actually knows; at MONTH or YEAR the stored date is the
|
||
/// first day of that month or year, and only a DAY event appears in a day view.
|
||
model LifeEvent {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
/// The kind of event, from a deliberately short list.
|
||
category LifeEventCategory
|
||
/// First day of the event, `YYYY-MM-DD`. At MONTH or
|
||
/// YEAR precision the first day of that month or year.
|
||
startDate String @map("start_date")
|
||
/// Last day, `YYYY-MM-DD`, for an event that spans a
|
||
/// period (parental leave, a year abroad). NULL = a single date.
|
||
endDate String? @map("end_date")
|
||
/// How much of the date is known. Applies to both
|
||
/// ends.
|
||
precision LifeEventPrecision
|
||
/// What happened, in the person's own words,
|
||
/// AES-256-GCM encrypted at rest.
|
||
titleEncrypted Bytes @map("title_encrypted")
|
||
/// An optional note, AES-256-GCM encrypted at rest.
|
||
noteEncrypted Bytes? @map("note_encrypted")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
/// Soft-delete tombstone. NULL = live.
|
||
deletedAt DateTime? @map("deleted_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([userId, deletedAt, startDate])
|
||
@@map("life_events")
|
||
}
|
||
|
||
/// What kind of life event. Few on purpose: travel already comes from the
|
||
/// environment module, and a long list turns an anchor into a diary.
|
||
enum LifeEventCategory {
|
||
FAMILY
|
||
HOME
|
||
WORK
|
||
LOSS
|
||
OTHER
|
||
|
||
@@map("life_event_category")
|
||
}
|
||
|
||
/// How much of a life event's date the person knows.
|
||
enum LifeEventPrecision {
|
||
DAY
|
||
MONTH
|
||
YEAR
|
||
|
||
@@map("life_event_precision")
|
||
}
|
||
|
||
/// v1.42 — the fold repair has been through this account.
|
||
///
|
||
/// Before v1.42 the daily-mean consolidation and the dense hourly fold could
|
||
/// fold a local day in two runs, the second overwriting the stored mean with
|
||
/// the mean of the later part alone. The one-time repair
|
||
/// (`src/lib/jobs/measurement-fold-repair.ts`) recomputes every such mean from
|
||
/// the samples still in the table, the fold's own tombstones among them. The
|
||
/// compaction-tombstone purge deletes exactly those tombstones, so it leaves an
|
||
/// account alone until this row exists, and a restore that writes back a file
|
||
/// older than `completedAt`'s run removes the row so the repair goes again.
|
||
model MeasurementFoldRepair {
|
||
userId String @id @map("user_id")
|
||
/// When the repair finished the account. A restore compares it with the
|
||
/// file's export instant; the purge only asks whether the row exists.
|
||
completedAt DateTime @map("completed_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@map("measurement_fold_repairs")
|
||
}
|
||
|
||
// ─── v1.43 contract tables ───────────────────────────────────
|
||
|
||
/// v1.43 (D1223) — another spelling of a biomarker on one account: a name a
|
||
/// merge folded in, a name chosen at import, or one typed on the marker. The
|
||
/// resolver (`src/lib/labs/biomarker-resolve.ts`) matches a printed name
|
||
/// against `nameFolded` after the marker name and before the catalogue.
|
||
model BiomarkerAlias {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
biomarkerId String @map("biomarker_id")
|
||
/// The alias as written, shown on the marker.
|
||
name String @db.VarChar(120)
|
||
/// `foldForMatch(name)`, the match key; unique per account.
|
||
nameFolded String @map("name_folded") @db.VarChar(120)
|
||
/// `MERGE`, `IMPORT_CHOICE` or `MANUAL` (CHECK in migration 0403).
|
||
source String
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
biomarker Biomarker @relation(fields: [biomarkerId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([userId, nameFolded])
|
||
@@index([biomarkerId])
|
||
@@map("biomarker_aliases")
|
||
}
|
||
|
||
/// v1.43 (D1223) — one source marker folded into a target. The source row is
|
||
/// gone after the merge; this ledger is what answers `GET /api/biomarkers/{id}`
|
||
/// for a merged id with `meta.mergedInto`, and what a later split would need.
|
||
model BiomarkerMerge {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
/// The marker the source was folded into.
|
||
targetBiomarkerId String @map("target_biomarker_id")
|
||
/// Id of the deleted source marker (no FK: the row is gone).
|
||
sourceBiomarkerId String @map("source_biomarker_id")
|
||
/// The source marker's name at merge time.
|
||
sourceName String @map("source_name") @db.VarChar(120)
|
||
/// How many readings moved from the source.
|
||
readingCount Int @map("reading_count")
|
||
/// When the merge ran.
|
||
mergedAt DateTime @default(now()) @map("merged_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
target Biomarker @relation(fields: [targetBiomarkerId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([userId, sourceBiomarkerId])
|
||
@@index([targetBiomarkerId])
|
||
@@map("biomarker_merges")
|
||
}
|
||
|
||
/// v1.43 — what one unit (tablet, capsule) of a medication contains, in the
|
||
/// nutrient catalogue's canonical unit. Every taken dose contributes
|
||
/// `amountPerUnit × unitsPerDose` to that day's `NutrientIntakeDay` row with
|
||
/// `source = "SUPPLEMENT"`. A changed formula is a new medication.
|
||
model MedicationIngredient {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
medicationId String @map("medication_id")
|
||
/// Nutrient catalogue code (`src/lib/nutrients/catalog.ts`).
|
||
nutrient String @db.VarChar(40)
|
||
/// Amount per unit in the canonical unit (> 0, CHECK in migration 0404).
|
||
amountPerUnit Float @map("amount_per_unit")
|
||
/// The catalogue's canonical unit for `nutrient`.
|
||
unit String @db.VarChar(8)
|
||
/// The amount as entered, when it was entered in another input unit.
|
||
enteredAmount Float? @map("entered_amount")
|
||
/// The input unit it was entered in (e.g. `IU`).
|
||
enteredUnit String? @map("entered_unit") @db.VarChar(8)
|
||
/// `MANUAL` or `LABEL_AI` (CHECK in migration 0404).
|
||
source String
|
||
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)
|
||
|
||
@@unique([medicationId, nutrient])
|
||
@@index([userId])
|
||
@@map("medication_ingredients")
|
||
}
|
||
|
||
/// v1.43 — one local day of Dawarich movement context. Daily values only:
|
||
/// no coordinate, no place name, no visit is ever stored. The country code is
|
||
/// sealed under its own label. Part of the `environment` module.
|
||
model DawarichDay {
|
||
id String @id @default(cuid())
|
||
userId String @map("user_id")
|
||
/// Local calendar day (`YYYY-MM-DD`) in the account's timezone.
|
||
date String @db.VarChar(10)
|
||
/// Timeline `summary.total_distance`, metres.
|
||
totalDistanceM Int? @map("total_distance_m")
|
||
/// Timeline `summary.mode_distances.driving`, metres.
|
||
drivingDistanceM Int? @map("driving_distance_m")
|
||
/// Timeline `summary.mode_distances.train`, metres.
|
||
trainDistanceM Int? @map("train_distance_m")
|
||
/// Timeline `summary.places_visited`.
|
||
placesVisited Int? @map("places_visited")
|
||
/// Minutes away from the home place; NULL = no visit coverage that day.
|
||
awayFromHomeMinutes Int? @map("away_from_home_minutes")
|
||
/// Sealed ISO-3166 alpha-2 country of the day (`/residency`).
|
||
countryCodeEncrypted Bytes? @map("country_code_encrypted")
|
||
/// Country differs from the home country.
|
||
isTravelDay Boolean? @map("is_travel_day")
|
||
/// When the day was last fetched from Dawarich.
|
||
fetchedAt DateTime @map("fetched_at")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
updatedAt DateTime @updatedAt @map("updated_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([userId, date])
|
||
@@map("dawarich_days")
|
||
}
|
||
|
||
/// v1.43 — lab result ↔ document (the report it was read from). A scan commit
|
||
/// with a `documentId` files the link itself.
|
||
model LabResultDocumentLink {
|
||
id String @id @default(cuid())
|
||
labResultId String @map("lab_result_id")
|
||
documentId String @map("document_id")
|
||
userId String @map("user_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
labResult LabResult @relation(fields: [labResultId], references: [id], onDelete: Cascade)
|
||
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([labResultId, documentId])
|
||
@@index([documentId])
|
||
@@index([userId])
|
||
@@map("lab_result_document_links")
|
||
}
|
||
|
||
/// v1.43 — visit ↔ medication (prescribed, changed or reviewed at the visit).
|
||
model EncounterMedicationLink {
|
||
id String @id @default(cuid())
|
||
encounterId String @map("encounter_id")
|
||
medicationId String @map("medication_id")
|
||
userId String @map("user_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([encounterId, medicationId])
|
||
@@index([medicationId])
|
||
@@index([userId])
|
||
@@map("encounter_medication_links")
|
||
}
|
||
|
||
/// v1.43 — visit ↔ vaccination dose (given at the visit).
|
||
model EncounterVaccinationLink {
|
||
id String @id @default(cuid())
|
||
encounterId String @map("encounter_id")
|
||
vaccinationId String @map("vaccination_id")
|
||
userId String @map("user_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||
vaccination VaccinationRecord @relation(fields: [vaccinationId], references: [id], onDelete: Cascade)
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([encounterId, vaccinationId])
|
||
@@index([vaccinationId])
|
||
@@index([userId])
|
||
@@map("encounter_vaccination_links")
|
||
}
|
||
|
||
/// v1.43 — medication ↔ document (a prescription, a package insert, a plan).
|
||
model MedicationDocumentLink {
|
||
id String @id @default(cuid())
|
||
medicationId String @map("medication_id")
|
||
documentId String @map("document_id")
|
||
userId String @map("user_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([medicationId, documentId])
|
||
@@index([documentId])
|
||
@@index([userId])
|
||
@@map("medication_document_links")
|
||
}
|
||
|
||
/// v1.43 — medication ↔ condition episode (the indication).
|
||
model MedicationConditionLink {
|
||
id String @id @default(cuid())
|
||
medicationId String @map("medication_id")
|
||
episodeId String @map("episode_id")
|
||
userId String @map("user_id")
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
|
||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||
episode IllnessEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([medicationId, episodeId])
|
||
@@index([episodeId])
|
||
@@index([userId])
|
||
@@map("medication_condition_links")
|
||
}
|