mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
A calendar feed is a standing credential whose contents end up in a
third party's calendar cloud. Putting a record's cycle dates there is
the decision of the person they are about, or of the guardian of
somebody who cannot make it, and no delegate of an adult's record,
MANAGE included.
The subscription routes resolve through requireGuardianAuth instead of
requireRecordAuth("manage", "cycle"): the owner on their own record, or
a MANAGE grant on a record carrying the managed-profile marker. Every
verb follows, the revoke too. The feed re-reads the marker on each fetch
as well as the guardian's grant, so a row minted for somebody else on an
adult's record is a 404 whatever grant stands behind it. The settings
card is offered only where the routes answer.
The routes move from the delegable and MANAGE literals to the guardian
list, and the MANAGE handler inventory is back to 93.
10532 lines
566 KiB
Plaintext
10532 lines
566 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")
|
||
|
||
/// When deletion of this account was asked for. Non-null means the
|
||
/// account is pending deletion: it is locked (every session, refresh token
|
||
/// and API token was revoked in the same transaction, and sign-in is
|
||
/// refused), every background pass skips it (`ACTIVE_ACCOUNT`,
|
||
/// `src/lib/account-deletion/active-account.ts`), and its data, stored
|
||
/// copies and off-host objects stay untouched until `deletionPurgeAt`. An
|
||
/// admin can undo it until then. Null on every account nobody asked to
|
||
/// delete. Readers: the session and Bearer resolvers, the sign-in refusal,
|
||
/// the admin users list and the undo route.
|
||
deletionRequestedAt DateTime? @map("deletion_requested_at")
|
||
/// When the account-deletion purge job erases the account for good:
|
||
/// `deletionRequestedAt` plus `ACCOUNT_DELETION_GRACE_DAYS`. The sign-in
|
||
/// refusal names this date. Reader: `processDueAccountDeletions`.
|
||
deletionPurgeAt DateTime? @map("deletion_purge_at")
|
||
/// Who asked: the account itself, or the Guardian who deleted a
|
||
/// managed profile. A plain id, not a relation: the requester's own
|
||
/// deletion must not touch this row. Reader: the admin users list.
|
||
deletionRequestedById String? @map("deletion_requested_by_id")
|
||
|
||
// 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.47 — Garmin Personal API (#1333, per-user, encrypted at app level). The
|
||
// person creates a key in Garmin Connect and pastes it once; the sync
|
||
// exchanges it for a short-lived token on every run, so the key is the only
|
||
// credential at rest. The token itself is never stored.
|
||
/// Sealed Garmin Personal API key.
|
||
garminApiKeyEncrypted String? @map("garmin_api_key_encrypted")
|
||
/// v1.47 — when the Garmin key stops working: learned from the token's own expiry once it is capped by the key's, else the validity the person picked at connect counted from then. Null when neither is known. Drives the card's expiry warning.
|
||
garminKeyExpiresAt DateTime? @map("garmin_key_expires_at")
|
||
/// v1.47 — when the current Garmin key was connected; the anchor the picked validity counts from.
|
||
garminConnectedAt DateTime? @map("garmin_connected_at")
|
||
/// v1.47 — which Garmin service accepted the key at connect: `global` (personal.connectapi.garmin.com) or `china` (…garmin.cn). Not a secret; the sync reads it to pick the host.
|
||
garminRegion String? @map("garmin_region")
|
||
/// v1.47 — how far the Garmin history backfill has got, per resource (see `src/lib/garmin/backfill-progress.ts`). Cleared on connect and disconnect.
|
||
garminBackfillProgress Json? @map("garmin_backfill_progress")
|
||
/// v1.47 — the Garmin history backfill finished (discovery predicate `garmin_backfill_completed_at IS NULL`). Cleared on connect.
|
||
garminBackfillCompletedAt DateTime? @map("garmin_backfill_completed_at")
|
||
/// 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")
|
||
/// 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")
|
||
// #1350 — the subscribable cycle forecast. `cycleCalendarFeeds` is the
|
||
// record the feed serves; `cycleCalendarFeedsMinted` is the carrier who
|
||
// minted it, which is the owner on their own record and the guardian on a
|
||
// managed profile.
|
||
cycleCalendarFeeds CycleCalendarFeed[] @relation("CycleCalendarFeedSubject")
|
||
cycleCalendarFeedsMinted CycleCalendarFeed[] @relation("CycleCalendarFeedCreator")
|
||
|
||
// 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])
|
||
@@index([deletionPurgeAt], map: "users_deletion_purge_at_idx")
|
||
@@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.
|
||
// v1.47 — Garmin Personal API (#1333). Garmin's own scores keep types of
|
||
// their own, so a device-native figure never shares a bucket with the
|
||
// COMPUTED proxies or another vendor's score of a different scale.
|
||
BODY_BATTERY // score (0–100) — Garmin Body Battery, the day's highest charge. Source GARMIN.
|
||
STRESS_LEVEL // score (0–100) — Garmin's all-day average stress level. Device-native and kept apart from the COMPUTED STRESS_SCORE, whose readers are single-source.
|
||
TRAINING_READINESS // score (0–100) — Garmin Training Readiness. Not in the Personal API; written by a bridge through the ingest token (source EXTERNAL).
|
||
ACUTE_TRAINING_LOAD // load (unit-less) — Garmin acute training load. Not in the Personal API; written by a bridge through the ingest token (source EXTERNAL).
|
||
// v1.47.1 — resting energy (#1340). Its own type beside ACTIVE_ENERGY_BURNED,
|
||
// never folded into it and never summed with it into a total.
|
||
BASAL_ENERGY_BURNED // kcal — HealthKit `basalEnergyBurned`, the energy the body uses at rest; cumulative, daily SUM.
|
||
|
||
@@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 reader's app language when the administration was taken. It selects
|
||
/// the crisis-resource copy; with `wording` it says which text was shown.
|
||
locale String
|
||
/// Which text the answers were given in: "validated" (the published
|
||
/// validated version for `locale`, the original for English),
|
||
/// "unvalidated_translation" (the app's own translation, shown with a note)
|
||
/// or "original_en" (the English original, chosen by a reader of another
|
||
/// language). Null on rows recorded before it was stored. Closed set:
|
||
/// ASSESSMENT_WORDINGS in src/lib/mental-health/instrument-wording.ts.
|
||
wording 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
|
||
// v1.47 — Garmin Personal API (#1333). Server-owned: only the Garmin sync
|
||
// writes these rows, from the key the user pasted, so the batch +
|
||
// single-POST surfaces reject it like the other providers. iOS DTO must
|
||
// mirror this spelling so the /api/sync/changes delta feed decodes the rows.
|
||
GARMIN
|
||
|
||
@@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")
|
||
/// The `measurements:write` token that carried this reading, set on every
|
||
/// `EXTERNAL` row (#1272). Read through to the token's `mirrorToAppleHealth`
|
||
/// when the row is answered; never on the wire itself. SET NULL if the token
|
||
/// row is ever removed (a revoke keeps it), and the row then stops being
|
||
/// mirrored.
|
||
ingestTokenId String? @map("ingest_token_id")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
ingestToken ApiToken? @relation(fields: [ingestTokenId], references: [id], onDelete: SetNull)
|
||
// 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])
|
||
// The rows a token carried, walked by the `measurementMirror` feed domain
|
||
// in id order and by the FK's SET NULL (migration 0477).
|
||
@@index([ingestTokenId, 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
|
||
/// #1280 — the instant the latest course was ended with "End course", the
|
||
/// projection of `MedicationCourse.endedAt` written next to `startsOn` /
|
||
/// `endsOn` by `src/lib/medications/courses.ts` only. On the `endsOn` day;
|
||
/// no dose slot at or after it is due, reminded or expected, and the course
|
||
/// reads as ended from it on. NULL = the course runs to the end of `endsOn`.
|
||
endedAt DateTime? @map("ended_at") @db.Timestamptz(6)
|
||
/// 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
|
||
/// #1280 — set by "End course": the instant the course ended, on the
|
||
/// `endsOn` day. A dose slot at or after it is not part of the course (not
|
||
/// due, not reminded, not expected by compliance), so the course ends when
|
||
/// the person ends it rather than at local midnight. NULL = the whole
|
||
/// `endsOn` day counts, the reading of an end date set in the editor. Any
|
||
/// later write that moves `endsOn` clears it.
|
||
endedAt DateTime? @map("ended_at") @db.Timestamptz(6)
|
||
/// 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")
|
||
/// Whether the HealthLog app writes the readings this `measurements:write`
|
||
/// token carried into Apple Health (#1272). The person decides per token;
|
||
/// the server resolves it onto every row as `appleHealthMirror`
|
||
/// (`src/lib/measurements/apple-health-mirror.ts`). False for every other
|
||
/// kind of token, which `PATCH /api/tokens/{id}` refuses to set.
|
||
mirrorToAppleHealth Boolean @default(false) @map("mirror_to_apple_health")
|
||
/// When `mirrorToAppleHealth` last changed. The keyset of the
|
||
/// `measurementMirror` domain of `/api/sync/changes`, which re-delivers the
|
||
/// token's rows with their new value without touching the rows themselves.
|
||
mirrorChangedAt DateTime? @map("mirror_changed_at")
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull)
|
||
ingestedMeasurements Measurement[]
|
||
// 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 Health user id; null when the connect could not read it
|
||
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")
|
||
|
||
/// 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, and the status routes, which read from it whether the
|
||
/// heavy lane put a queued restore back (`backup-restore-wait.ts`).
|
||
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`, `data-backup-offhost`, or
|
||
/// `account-deletion-purge` (the hourly erasure of accounts whose deletion
|
||
/// window ended, which takes an account whose erasure failed last).
|
||
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")
|
||
}
|
||
|
||
/// The monthly restore drill's last result, one row for the instance. pg-boss
|
||
/// forgets a failed run after seven days and the failing-jobs card looks back
|
||
/// 72 hours, so the drill's verdict lives here for as long as it is the last
|
||
/// one. Written by `recordRestoreDrillOutcome` at the end of every drill that
|
||
/// had something to check; consumer: `readRecoveryReadiness` (the readiness
|
||
/// tile and the hourly evaluation).
|
||
model RestoreDrillOutcome {
|
||
id String @id @default("singleton")
|
||
/// When the last drill finished. Consumer: the tile's drill row.
|
||
lastRunAt DateTime @map("last_run_at")
|
||
/// When a drill last passed every object it checked. A failure stays the
|
||
/// verdict until a later success. Consumer: the readiness model.
|
||
lastSuccessAt DateTime? @map("last_success_at")
|
||
/// When a drill last failed. Consumer: the readiness model.
|
||
lastFailureAt DateTime? @map("last_failure_at")
|
||
/// One line naming why, already redacted. Consumer: the tile's drill row.
|
||
lastFailureReason String? @map("last_failure_reason")
|
||
/// Objects the last drill checked and how many of them failed. Consumer:
|
||
/// the tile's drill row.
|
||
accountsChecked Int @default(0) @map("accounts_checked")
|
||
accountsFailed Int @default(0) @map("accounts_failed")
|
||
|
||
@@map("restore_drill_outcomes")
|
||
}
|
||
|
||
/// Per readiness signal, the state the admins were last notified about. The
|
||
/// hourly evaluation compares the current state against it: a change to
|
||
/// attention or failing notifies once, a failing signal is reminded at most
|
||
/// once a day, and a return to ok is announced once. The update is a
|
||
/// compare-and-set on both columns, so two workers never send the same
|
||
/// notice. Written and read by `notifyReadinessChanges`.
|
||
model RecoveryReadinessNotice {
|
||
/// The signal's id (`database_dump`, `weekly_copy`, …).
|
||
signal String @id
|
||
/// `ok`, `attention` or `failing`; null until anything was announced.
|
||
notifiedState String? @map("notified_state")
|
||
notifiedAt DateTime? @map("notified_at")
|
||
/// Since when the evaluation has seen the signal apply (not `inactive`);
|
||
/// cleared when it stops applying. Consumer: the readiness model, which
|
||
/// judges a dump that has never been attempted against it.
|
||
watchedSince DateTime? @map("watched_since")
|
||
|
||
@@map("recovery_readiness_notices")
|
||
}
|
||
|
||
/// Per background queue of the instance, the state the admins were last
|
||
/// notified about. The hourly system job evaluation compares a queue's run
|
||
/// history against it: three failed runs in a row notify once, a queue still
|
||
/// failing is reminded at most once a day, and its first successful run after
|
||
/// that is announced once. The update is a compare-and-set on both columns, so
|
||
/// two workers never send the same notice. Written and read by
|
||
/// `notifySystemJobFailures`.
|
||
model SystemJobAlertNotice {
|
||
/// The pg-boss queue name (`rollup-full-backfill`, …).
|
||
queue String @id
|
||
/// `ok` or `failing`; null once a queue's history has aged out.
|
||
notifiedState String? @map("notified_state")
|
||
notifiedAt DateTime? @map("notified_at")
|
||
|
||
@@map("system_job_alert_notices")
|
||
}
|
||
|
||
/// The recovery kit an admin confirmed: every key a restore needs, by kind,
|
||
/// id and fingerprint, never the key itself. The kit is due again as soon as
|
||
/// a key it needs is not in this set (a rotation, a new backup key). Written
|
||
/// by `POST /api/admin/recovery-kit/confirm`; consumer: `readRecoveryKit`
|
||
/// (the kit card, the dashboard banner for admins and the readiness model).
|
||
model RecoveryKitConfirmation {
|
||
id String @id @default("singleton")
|
||
confirmedAt DateTime @map("confirmed_at")
|
||
/// Plain id, not a relation: the instance row must not become user-scoped,
|
||
/// and a deleted admin leaves the confirmation standing. Consumer: the kit
|
||
/// card names who confirmed while the account still exists.
|
||
confirmedByUserId String? @map("confirmed_by_user_id")
|
||
/// `[{ kind, id, fingerprint }]`, validated on read
|
||
/// (`parseKitEntries`). Consumer: `assessRecoveryKit`.
|
||
entries Json
|
||
|
||
@@map("recovery_kit_confirmations")
|
||
}
|
||
|
||
/// 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")
|
||
/// The purge leaves this request alone until then. A data wipe
|
||
/// writes its request with the end of the deletion grace window here, so
|
||
/// the off-host copies from before the wipe stay restorable as long as the
|
||
/// account copy taken before it. Null: due at once (every account deletion,
|
||
/// which writes its request only when the purge erases the account).
|
||
notBefore DateTime? @map("not_before")
|
||
|
||
@@index([requestedAt])
|
||
@@map("offhost_purge_requests")
|
||
}
|
||
|
||
/// One run of the nightly whole-database dump (the `database-backup` job):
|
||
/// whether a dump was written on this server and copied off-host, and why not
|
||
/// when it was not. Instance metadata, not an account's data. Written by
|
||
/// `runDatabaseBackup`; read by `getDatabaseBackupHealth`, which feeds the
|
||
/// database row of the admin backups card and the backup alerts. A row left
|
||
/// `running` by a process that died is read as a failed run once the job's
|
||
/// expiry has passed.
|
||
model DatabaseBackupRun {
|
||
id String @id @default(cuid())
|
||
startedAt DateTime @map("started_at")
|
||
finishedAt DateTime? @map("finished_at")
|
||
/// `running`, `succeeded`, `failed` or `skipped`.
|
||
status String
|
||
/// The dump's file name, once one was written and checked.
|
||
fileName String? @map("file_name")
|
||
/// Size of the dump in bytes.
|
||
dumpBytes BigInt? @map("dump_bytes")
|
||
/// `written` (kept in DB_BACKUP_DIR), `no_directory` (no writable
|
||
/// directory, the dump went off-host only), `not_persistent` (writable but
|
||
/// in the container's layer or in memory, so nothing was kept there),
|
||
/// `skipped`, or `failed`.
|
||
localResult String @map("local_result")
|
||
/// `uploaded`, `not_configured` or `failed`.
|
||
offhostResult String @map("offhost_result")
|
||
/// Why the run failed or was skipped, trimmed. Never key material.
|
||
error String?
|
||
/// The application key id the dump's rows were written under (its
|
||
/// manifest's active key). A restore refuses without it. Reader:
|
||
/// `readBackupKeyNeeds`, which keeps the key in the recovery kit while the
|
||
/// off-host copy of this dump may still be in the bucket.
|
||
activeKeyId String? @map("active_key_id")
|
||
|
||
@@index([startedAt])
|
||
@@map("database_backup_runs")
|
||
}
|
||
|
||
// ─── 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")
|
||
/// Generations in a row that ended without a note; sets the length of the
|
||
/// next negative-cache window. Reset to 0 when a note is written or re-dated.
|
||
failureStreak Int @default(0) @map("failure_streak")
|
||
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")
|
||
}
|
||
|
||
/// #1350 — how the feed titles its events. DISCREET shows a dot and names the
|
||
/// calendar "HealthLog"; DESCRIPTIVE names the period and the calendar.
|
||
enum CycleCalendarFeedTitleMode {
|
||
DISCREET
|
||
DESCRIPTIVE
|
||
|
||
@@map("cycle_calendar_feed_title_mode")
|
||
}
|
||
|
||
/// #1350 — a subscribable calendar feed of one record's cycle forecast.
|
||
///
|
||
/// The row is a URL credential. Only the HMAC of the raw `hlf_` token is
|
||
/// stored (`hashToken`, the same key as `ApiToken.tokenHash`); the raw token is
|
||
/// returned once at mint and is unrecoverable afterwards. The feed is computed
|
||
/// live on every fetch from the record's own rows, so nothing about the
|
||
/// forecast is stored here.
|
||
///
|
||
/// At most one live feed per record: a partial unique index on `user_id WHERE
|
||
/// revoked_at IS NULL`, written in the migration because Prisma cannot express
|
||
/// it. Revocation stamps `revokedAt` and never deletes, so the status read can
|
||
/// say when a link stopped working. Not in the backup: a restore would bring
|
||
/// back a link the owner revoked (see `backup-plan.ts`).
|
||
model CycleCalendarFeed {
|
||
id String @id @default(cuid())
|
||
/// The record whose forecast the feed serves.
|
||
userId String @map("user_id")
|
||
/// Who minted it: the owner, or the guardian of a managed profile. A
|
||
/// guardian's feed is re-checked against their live MANAGE grant and the
|
||
/// managed-profile marker on every fetch.
|
||
createdById String @map("created_by_id")
|
||
|
||
/// HMAC-SHA256 of the raw `hlf_` token.
|
||
tokenHash String @unique @map("token_hash")
|
||
/// The first 8 characters of the raw token, shown in settings so the owner
|
||
/// can tell which link is live. Not enough to rebuild it.
|
||
tokenPrefix String @map("token_prefix")
|
||
|
||
createdAt DateTime @default(now()) @map("created_at")
|
||
revokedAt DateTime? @map("revoked_at")
|
||
|
||
/// Fetch statistics, written fire-and-forget by the feed route and read by
|
||
/// the status endpoint ("last fetched 3 h ago by Google Calendar").
|
||
lastFetchedAt DateTime? @map("last_fetched_at")
|
||
fetchCount Int @default(0) @map("fetch_count")
|
||
/// The last fetcher's User-Agent, truncated to 80 characters.
|
||
lastFetcherUa String? @map("last_fetcher_ua")
|
||
|
||
/// SHA-256 of the last served body without its timestamps. When a fetch
|
||
/// builds a different body, `contentChangedAt` moves; both drive DTSTAMP,
|
||
/// LAST-MODIFIED and the ETag.
|
||
bodyHash String? @map("body_hash")
|
||
contentChangedAt DateTime @default(now()) @map("content_changed_at")
|
||
|
||
/// Forecast cycles ahead (1..6). The feed caps itself further once the
|
||
/// band grows past ±10 days or half a cycle.
|
||
horizonCycles Int @default(3) @map("horizon_cycles")
|
||
/// Observed cycles included as history (0..12).
|
||
historyCycles Int @default(0) @map("history_cycles")
|
||
/// The fertile window of the next cycle, for TRYING_TO_CONCEIVE only.
|
||
includeFertile Boolean @default(false) @map("include_fertile")
|
||
titleMode CycleCalendarFeedTitleMode @default(DISCREET) @map("title_mode")
|
||
|
||
user User @relation("CycleCalendarFeedSubject", fields: [userId], references: [id], onDelete: Cascade)
|
||
createdBy User @relation("CycleCalendarFeedCreator", fields: [createdById], references: [id], onDelete: Cascade)
|
||
|
||
@@index([userId, createdAt(sort: Desc)])
|
||
@@index([createdById])
|
||
@@map("cycle_calendar_feeds")
|
||
}
|
||
|
||
/// 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 | resource-limit | 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")
|
||
/// Whether the facts staged by the last extraction may leave part of the
|
||
/// document out: a read stopped at the provider's output limit, came back
|
||
/// unreadable or was never sent, the text or pages ran past what the reads
|
||
/// take, or the document held more facts than one review takes. Written by
|
||
/// both stagings (the extract route or run, and lab auto-staging) with the
|
||
/// facts they replace, so a review opened later says so, not only the
|
||
/// notice at the end of the run. False for a document never extracted.
|
||
extractionIncomplete Boolean @default(false) @map("extraction_incomplete")
|
||
|
||
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")
|
||
}
|