Add the visit record and the carriers that keep it

A person's whole 'which doctor' history was one free-text column on a
reminder: they typed the practice name once and could reach nothing else
that happened there. This turns that string into a record.

Five tables in migration 0320, all additive: an encounter that happened
or is booked, the account's own address book of practitioners, and three
links from a visit to the documents, lab results and conditions it
produced. The encounter is slim on purpose — the diagnosis stays on the
linked condition, the address on the linked practitioner, and the reason
each field was kept or cut is written at the model. No module gate, and
the schema says why rather than leaving the absence to be read as an
oversight.

Every carrier lands with the tables rather than after them. All five are
classified as travelling both ways and are proven doing it: a seeded row
of each goes out through the real export builder, the account is deleted
so the schema's cascade decides what survives, and the rows are counted
back through the real restore route. Two references deliberately do not
survive and are named instead of dropped quietly — the reminder a visit
points at is not in the backup, and a link whose far side a portable
export does not carry cannot resolve. The wipe plan deletes the links
before the lab results and documents they hang off, or the audit row
would report zero for rows that existed. The three encrypted columns are
registered for key rotation, without which dropping a legacy key would
make those notes permanently unreadable.

Widening the reminder origin enum is a type error at the reminder DTO,
which is the right outcome: the mapper now refuses an appointment row
outright rather than relabelling it as a checkup.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 07:14:02 +02:00
1 parent 2f68b3dc79
commit 24425e9aff
25 files changed
+1466 -174

No files matched your search

+1
View File
@@ -6739,6 +6739,7 @@
"restoreSkippedIllnessSymptom": "Krankheitssymptom",
"restoreSkippedMoodFactor": "Stimmungsfaktor",
"restoreSkippedMoodTag": "Stimmungs-Tag",
"restoreSkippedVisitReference": "Besuchsverknüpfung",
"docsLink": "Dokumentation"
},
"danger-zone": {
+1
View File
@@ -6739,6 +6739,7 @@
"restoreSkippedIllnessSymptom": "Illness symptom",
"restoreSkippedMoodFactor": "Mood factor",
"restoreSkippedMoodTag": "Mood tag",
"restoreSkippedVisitReference": "Visit reference",
"docsLink": "Documentation"
},
"danger-zone": {
+1
View File
@@ -6739,6 +6739,7 @@
"restoreSkippedIllnessSymptom": "Síntoma de enfermedad",
"restoreSkippedMoodFactor": "Factor de ánimo",
"restoreSkippedMoodTag": "Etiqueta de ánimo",
"restoreSkippedVisitReference": "Referencia de visita",
"docsLink": "Documentación"
},
"danger-zone": {
+1
View File
@@ -6739,6 +6739,7 @@
"restoreSkippedIllnessSymptom": "Symptôme de maladie",
"restoreSkippedMoodFactor": "Facteur d'humeur",
"restoreSkippedMoodTag": "Étiquette d'humeur",
"restoreSkippedVisitReference": "Référence de visite",
"docsLink": "Documentation"
},
"danger-zone": {
+1
View File
@@ -6739,6 +6739,7 @@
"restoreSkippedIllnessSymptom": "Sintomo di malattia",
"restoreSkippedMoodFactor": "Fattore dell'umore",
"restoreSkippedMoodTag": "Etichetta dell'umore",
"restoreSkippedVisitReference": "Riferimento alla visita",
"docsLink": "Documentazione"
},
"danger-zone": {
+1
View File
@@ -6739,6 +6739,7 @@
"restoreSkippedIllnessSymptom": "Objaw choroby",
"restoreSkippedMoodFactor": "Czynnik nastroju",
"restoreSkippedMoodTag": "Tag nastroju",
"restoreSkippedVisitReference": "Odwołanie wizyty",
"docsLink": "Dokumentacja"
},
"danger-zone": {
@@ -0,0 +1,169 @@
-- Visits: the encounter record and the address book it points at.
--
-- The whole of the previous "which doctor" model was one free-text column on a
-- reminder. A person typed the practice name once per reminder and could reach
-- nothing else that happened there. These five tables turn that string into a
-- record: a visit that happened or is booked, the practitioners it points at,
-- and three links to the documents, lab results and conditions it produced.
--
-- Additive only. No existing table is altered, nothing is backfilled, and the
-- new enum value on `reminder_origin` is not used by any statement here, so the
-- ADD VALUE is safe inside the migration transaction.
-- CreateEnum
CREATE TYPE "encounter_status" AS ENUM ('PLANNED', 'DONE', 'CANCELLED', 'NO_SHOW');
-- CreateEnum
CREATE TYPE "encounter_kind" AS ENUM ('ROUTINE', 'ACUTE', 'SPECIALIST', 'PREVENTIVE', 'EMERGENCY', 'HOSPITAL', 'THERAPY', 'OTHER');
-- AlterEnum
-- A planned visit mints one one-shot reminder on the existing engine rather
-- than starting a second one. ENCOUNTER rows are excluded from every Vorsorge
-- read surface; the published `origin` enum stays at two members.
ALTER TYPE "reminder_origin" ADD VALUE IF NOT EXISTS 'ENCOUNTER';
-- CreateTable
CREATE TABLE "encounters" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"occurred_at" TIMESTAMP(3) NOT NULL,
"status" "encounter_status" NOT NULL DEFAULT 'DONE',
"kind" "encounter_kind" NOT NULL DEFAULT 'OTHER',
"practitioner_id" TEXT,
"reason_encrypted" BYTEA,
"outcome_encrypted" BYTEA,
"reminder_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "encounters_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "practitioners" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"specialty" TEXT,
"practice" TEXT,
"location" TEXT,
"phone" TEXT,
"note_encrypted" BYTEA,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "practitioners_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "encounter_document_links" (
"id" TEXT NOT NULL,
"encounter_id" TEXT NOT NULL,
"document_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "encounter_document_links_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "encounter_lab_links" (
"id" TEXT NOT NULL,
"encounter_id" TEXT NOT NULL,
"lab_result_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "encounter_lab_links_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "encounter_condition_links" (
"id" TEXT NOT NULL,
"encounter_id" TEXT NOT NULL,
"episode_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "encounter_condition_links_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "encounters_user_id_deleted_at_occurred_at_idx" ON "encounters"("user_id", "deleted_at", "occurred_at");
-- CreateIndex
CREATE INDEX "encounters_user_id_status_occurred_at_idx" ON "encounters"("user_id", "status", "occurred_at");
-- CreateIndex
CREATE INDEX "encounters_practitioner_id_idx" ON "encounters"("practitioner_id");
-- CreateIndex
CREATE INDEX "practitioners_user_id_deleted_at_name_idx" ON "practitioners"("user_id", "deleted_at", "name");
-- CreateIndex
CREATE INDEX "encounter_document_links_document_id_idx" ON "encounter_document_links"("document_id");
-- CreateIndex
CREATE INDEX "encounter_document_links_user_id_idx" ON "encounter_document_links"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "encounter_document_links_encounter_id_document_id_key" ON "encounter_document_links"("encounter_id", "document_id");
-- CreateIndex
CREATE INDEX "encounter_lab_links_lab_result_id_idx" ON "encounter_lab_links"("lab_result_id");
-- CreateIndex
CREATE INDEX "encounter_lab_links_user_id_idx" ON "encounter_lab_links"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "encounter_lab_links_encounter_id_lab_result_id_key" ON "encounter_lab_links"("encounter_id", "lab_result_id");
-- CreateIndex
CREATE INDEX "encounter_condition_links_episode_id_idx" ON "encounter_condition_links"("episode_id");
-- CreateIndex
CREATE INDEX "encounter_condition_links_user_id_idx" ON "encounter_condition_links"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "encounter_condition_links_encounter_id_episode_id_key" ON "encounter_condition_links"("encounter_id", "episode_id");
-- AddForeignKey
ALTER TABLE "encounters" ADD CONSTRAINT "encounters_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounters" ADD CONSTRAINT "encounters_practitioner_id_fkey" FOREIGN KEY ("practitioner_id") REFERENCES "practitioners"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounters" ADD CONSTRAINT "encounters_reminder_id_fkey" FOREIGN KEY ("reminder_id") REFERENCES "measurement_reminders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "practitioners" ADD CONSTRAINT "practitioners_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounter_document_links" ADD CONSTRAINT "encounter_document_links_encounter_id_fkey" FOREIGN KEY ("encounter_id") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounter_document_links" ADD CONSTRAINT "encounter_document_links_document_id_fkey" FOREIGN KEY ("document_id") REFERENCES "inbound_documents"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounter_document_links" ADD CONSTRAINT "encounter_document_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounter_lab_links" ADD CONSTRAINT "encounter_lab_links_encounter_id_fkey" FOREIGN KEY ("encounter_id") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounter_lab_links" ADD CONSTRAINT "encounter_lab_links_lab_result_id_fkey" FOREIGN KEY ("lab_result_id") REFERENCES "lab_results"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounter_lab_links" ADD CONSTRAINT "encounter_lab_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounter_condition_links" ADD CONSTRAINT "encounter_condition_links_encounter_id_fkey" FOREIGN KEY ("encounter_id") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounter_condition_links" ADD CONSTRAINT "encounter_condition_links_episode_id_fkey" FOREIGN KEY ("episode_id") REFERENCES "illness_episodes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounter_condition_links" ADD CONSTRAINT "encounter_condition_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+408 -168
View File
@@ -716,107 +716,107 @@ model User {
passkeyUpgradeNudgeDismissed Boolean @default(false) @map("passkey_upgrade_nudge_dismissed")
// Relations
passkeys Passkey[]
sessions Session[] @relation("SessionOwner")
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[]
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[]
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[]
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[]
apiTokens ApiToken[]
stepUpElevations StepUpElevation[]
measurements Measurement[]
medications Medication[]
intakeEvents MedicationIntakeEvent[]
medicationPauseEras MedicationPauseEra[]
apiTokens ApiToken[]
// v1.24 — MCP remote OAuth 2.1 connections (1:N). Each row is a granted
// authorization for an MCP client, audience-bound to health:read/health:write.
mcpOAuthConnections McpOAuthConnection[]
refreshTokens RefreshToken[]
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?
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[]
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[]
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[]
fitbitConnection FitbitConnection?
fitbitOAuthStates FitbitOAuthState[]
// v1.27.0 — Google Health API integration (separate provider from the classic
// Fitbit Web API above). Mirrors the connection (1:1) + OAuth-state ledger
// (1:N) relations.
googleHealthConnection GoogleHealthConnection?
googleHealthOAuthStates GoogleHealthOAuthState[]
moodEntries MoodEntry[]
mentalHealthAssessments MentalHealthAssessment[]
moodTagsCustom MoodTag[] @relation("UserMoodTags")
moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
moodTagsHidden MoodTagHidden[]
notificationChannels NotificationChannel[]
pushSubscriptions PushSubscription[]
dataBackups DataBackup[]
achievements UserAchievement[]
recommendationFeedback RecommendationFeedback[]
idempotencyKeys IdempotencyKey[]
devices Device[]
integrationStatuses IntegrationStatus[]
coachConversations CoachConversation[]
coachFacts CoachFact[]
coachPlans CoachPlan[]
coachReminders CoachReminder[]
coachUsage CoachUsage[]
googleHealthConnection GoogleHealthConnection?
googleHealthOAuthStates GoogleHealthOAuthState[]
moodEntries MoodEntry[]
mentalHealthAssessments MentalHealthAssessment[]
moodTagsCustom MoodTag[] @relation("UserMoodTags")
moodTagCategoriesCustom MoodTagCategory[] @relation("UserMoodTagCategories")
moodTagsHidden MoodTagHidden[]
notificationChannels NotificationChannel[]
pushSubscriptions PushSubscription[]
dataBackups DataBackup[]
achievements UserAchievement[]
recommendationFeedback RecommendationFeedback[]
idempotencyKeys IdempotencyKey[]
devices Device[]
integrationStatuses IntegrationStatus[]
coachConversations CoachConversation[]
coachFacts CoachFact[]
coachPlans CoachPlan[]
coachReminders CoachReminder[]
coachUsage CoachUsage[]
// v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings
// getworkouts / future Strava). Schema lands now so the v1.5 iOS
// session can build against a stable DTO. Routes hang off Workout
// (1:1), not User — the User → WorkoutRoute path is two hops by
// design.
workouts Workout[]
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[]
personalRecords PersonalRecord[]
// Today rail dismiss ledger — observational PriorityItem kinds only.
dismissedPriorityItems DismissedPriorityItem[]
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[]
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[]
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")
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
@@ -824,134 +824,143 @@ model User {
// 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[]
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[]
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[]
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[]
moodEntryRollups MoodEntryRollup[]
// v1.4.39 W-MED — per-(user, medication, day) compliance rollup.
// Per-write hook on MedicationIntakeEvent mutations + the scheduler
// keep the row in sync so `/api/medications/intake?scope=compliance`
// and `computeUserHealthScoreFastPath` skip the per-event findMany.
medicationComplianceRollups MedicationComplianceRollup[]
medicationComplianceRollups MedicationComplianceRollup[]
// v1.4.40 SB-10 — AI consent receipts the iOS app POSTs when the user
// grants AI processing during onboarding. Persisted audit trail
// covers App-Store Guideline 5.1.2(i) ("data-collection consent must
// be verifiable") plus the operator's GDPR Art. 7 documentation
// burden. Append-only — a user revoking + re-granting consent mints
// a fresh row so the timeline reads chronologically.
consentReceipts ConsentReceipt[]
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[]
pushAttemptsForRecord PushAttempt[] @relation("PushAttemptRecordUser")
pushAttemptsReceived PushAttempt[] @relation("PushAttemptRecipientUser")
notificationEvents NotificationEvent[]
notificationEgressAuthorizationsForRecord NotificationEgressAuthorization[] @relation("NotificationEgressAuthorizationRecordUser")
notificationEgressAuthorizationsReceived NotificationEgressAuthorization[] @relation("NotificationEgressAuthorizationRecipientUser")
telegramReminderMessages TelegramReminderMessage[]
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[]
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[]
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")
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[]
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[]
insightNarratives InsightNarrative[]
// v1.31.0 — the data-arrival spine's durable per-day reaction marker. One
// row per (kind, local day); the unique index IS the once-per-kind-per-day
// claim every reaction surface reads. Retained 14 days.
arrivalReactions ArrivalReaction[]
workoutInsights WorkoutInsight[]
workoutInsightGenerationClaims WorkoutInsightGenerationClaim[]
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[]
measurementReminders MeasurementReminder[]
// v1.17.1 — minimal structured lab store (LDL, HbA1c, ferritin, TSH, …)
// that pairs with the Vorsorge "annual blood panel" reminder so a result
// has somewhere to land. Soft-deleted.
labResults LabResult[]
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[]
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[]
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[]
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[]
illnessEpisodes IllnessEpisode[]
illnessDayLogs IllnessDayLog[]
// v1.25 (W-RECORDS) — structured health-record entries. Allergies map to
// FHIR AllergyIntolerance; family-history entries map to FHIR
// FamilyMemberHistory. Reference records (not time-series signals); the
// sensitive free-text fields are encrypted at rest.
allergies Allergy[]
familyHistory FamilyHistoryEntry[]
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[]
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[]
nutrientIntakeDays NutrientIntakeDay[]
// v1.25 (W-DOCS-IN) — inbound clinical documents (doctor reports /
// discharge letters) the user uploads. The raw document is encrypted at
// rest; extracted facts land in a review-then-confirm staging area
// (ExtractedFact) and only reach the structured stores on explicit
// approval. Opt-in module, off by default.
inboundDocuments InboundDocument[]
extractedFacts ExtractedFact[]
documentConditionLinks DocumentConditionLink[]
documentContentIndexes DocumentContentIndex[]
documentThumbnails DocumentThumbnail[]
inboundDocuments InboundDocument[]
extractedFacts ExtractedFact[]
documentConditionLinks DocumentConditionLink[]
documentContentIndexes DocumentContentIndex[]
documentThumbnails DocumentThumbnail[]
// v1.19.0 — full Withings ECG waveform recordings (encrypted at rest).
// The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION
// Measurement EVENT row (v1.18.11); this holds the raw signal samples.
ecgRecordings EcgRecording[]
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.7.0 profile — optional patient-identity fields surfaced on the
// health-record export cover (PDF) and the FHIR `Patient` resource.
@@ -990,16 +999,16 @@ model User {
// v1.15.20 — user-authored "about me" context for the AI surfaces
// (1:1, encrypted free text). See `UserHealthProfile`.
healthProfile 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")
invitesCreated InviteToken[] @relation("InviteCreator")
invitesUsed InviteToken[] @relation("InviteConsumer")
// v1.16.0 — per-signup invite redemption ledger (multi-use history).
inviteRedemptions InviteRedemption[] @relation("InviteRedemptions")
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
@@ -5010,7 +5019,11 @@ model MeasurementReminder {
/// 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)
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[]
@@index([userId, deletedAt])
@@index([userId, nextDueAt])
@@ -5024,10 +5037,22 @@ model MeasurementReminder {
/// Provenance of a MeasurementReminder. `VORSORGE` = user-created through the
/// Vorsorge surface (default); `COACH` = minted by the AI coach from a cadence
/// suggestion. Both feed the same engine / endpoint / cron.
/// 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")
}
@@ -5042,8 +5067,8 @@ enum ReminderOrigin {
/// 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")
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".
@@ -5055,15 +5080,15 @@ model LabResult {
/// 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")
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")
referenceLow Float? @map("reference_low")
referenceHigh Float? @map("reference_high")
/// The reference range as PRINTED ON THE SOURCE REPORT for this one reading.
///
@@ -5089,12 +5114,12 @@ model LabResult {
/// 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")
sourceReferenceLow Float? @map("source_reference_low")
sourceReferenceHigh Float? @map("source_reference_high")
sourceReferenceText String? @map("source_reference_text")
/// 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")
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
@@ -5102,19 +5127,20 @@ model LabResult {
/// 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")
biomarkerId String? @map("biomarker_id")
/// Provenance: "MANUAL" today; import paths set their own source string.
source String @default("MANUAL")
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")
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")
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull)
encounterLinks EncounterLabLink[]
@@index([userId, analyte, takenAt])
@@index([userId, deletedAt])
@@ -5186,27 +5212,27 @@ model Biomarker {
/// 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")
id String @id @default(cuid())
userId String @map("user_id")
/// Free-text display name, e.g. "Morning grip strength".
name String
name String
/// Free-text unit label, e.g. "kg", "reps", "score".
unit String
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")
targetLow Float? @map("target_low")
targetHigh Float? @map("target_high")
/// Optional fixed decimal places for display (null = sensible default).
decimals Int?
decimals Int?
/// Optional free-text description ("what this measures"). Plaintext.
description String?
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")
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")
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
entries CustomMetricEntry[]
@@ -5312,12 +5338,12 @@ enum HealthScoreBand {
/// 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")
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)
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.
@@ -5325,27 +5351,27 @@ model HealthScoreRecord {
/// The composite 0-100 as shown. Integer, matching the composite's own
/// rounding.
composite Int
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
band HealthScoreBand
/// Algorithm identity the row was computed under.
scoreVersion Int @map("score_version")
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[]
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")
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 @db.VarChar(64) @map("input_fingerprint")
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
@@ -5358,7 +5384,7 @@ model HealthScoreRecord {
/// 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")
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
@@ -5730,30 +5756,30 @@ model CoachReminder {
// 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")
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")
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")
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")
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")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
aiIncludedSections HealthProfileAiSection[] @default([ABOUT_ME, CONDITIONS, ALLERGIES, COACH_FOCUS, FAMILY_HISTORY, SMOKING_STATUS, ALCOHOL_PATTERN, SHIFT_SCHEDULE]) @map("ai_included_sections")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -5800,18 +5826,18 @@ enum HealthProfileFactProvenance {
/// 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)
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)
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])
@@ -5938,12 +5964,12 @@ model NotificationEvent {
// 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")
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")
eventType String @map("event_type")
authorizedAt DateTime @default(now()) @map("authorized_at")
completedAt DateTime? @map("completed_at")
outcome String?
@@ -6845,12 +6871,13 @@ model IllnessEpisode {
/// 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)
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[]
parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull)
flares IllnessEpisode[] @relation("IllnessFlares")
dayLogs IllnessDayLog[]
documentLinks DocumentConditionLink[]
encounterLinks EncounterConditionLink[]
@@index([userId, deletedAt])
@@index([userId, onsetAt])
@@ -7308,7 +7335,8 @@ model InboundDocument {
/// Soft-delete tombstone. NULL = live; non-NULL = discarded.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
encounterLinks EncounterDocumentLink[]
@@index([userId, createdAt])
// The list always filters `deletedAt: null` and sorts by `documentDate`, so
@@ -7535,3 +7563,215 @@ model NutrientIntakeDay {
@@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")
/// 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[]
@@index([userId, deletedAt, occurredAt])
@@index([userId, status, occurredAt])
@@index([practitionerId])
@@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.
enum EncounterKind {
ROUTINE
ACUTE
SPECIALIST
PREVENTIVE
EMERGENCY
HOSPITAL
THERAPY
OTHER
@@map("encounter_kind")
}
/// 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?
/// Free-text address / "wo", the `MeasurementReminder.location` shape.
location String?
phone String?
/// 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[]
@@index([userId, deletedAt, name])
@@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")
}
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.37.0";
/* @sw-version-fallback */ "v1.37.1";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+17
View File
@@ -534,6 +534,23 @@ async function main() {
),
);
// ───── v1.38.0 visit free text (Bytes columns) ─────
// Why the person went, what came out of it, and the practice note. Always
// encrypted on write, so every non-null row rotates.
results.push(
await rotateBytesColumn("Encounter", "reasonEncrypted", prisma.encounter),
);
results.push(
await rotateBytesColumn("Encounter", "outcomeEncrypted", prisma.encounter),
);
results.push(
await rotateBytesColumn(
"Practitioner",
"noteEncrypted",
prisma.practitioner,
),
);
// ───── Inbound clinical document (Bytes column, codec-dispatched) ─────
// The raw uploaded document. Two layouts recorded per row in
// `contentCodec` ("base64v1" string codec | "binary2" binary codec), so
@@ -282,6 +282,14 @@ function sourceClient() {
},
]),
},
// The visit tables. Empty here: this file asserts the sections it seeds,
// and an account with no visits is the ordinary case the builder must
// still answer for.
practitioner: { findMany: vi.fn().mockResolvedValue([]) },
encounter: { findMany: vi.fn().mockResolvedValue([]) },
encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
customMetric: {
findMany: vi.fn().mockResolvedValue([
{
@@ -48,6 +48,7 @@ import {
import { restoreProfileData } from "@/lib/export/profile-backup";
import { restoreIntradayProfileData } from "@/lib/export/intraday-profile-backup";
import { restoreHealthScoreData } from "@/lib/export/health-score-backup";
import { restoreVisitsData } from "@/lib/export/visits-backup";
import { invalidateUserData } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -88,6 +89,9 @@ interface RestoreResponse {
correlationPatterns: number;
intradayProfiles: number;
healthScoreRecords: number;
practitioners: number;
encounters: number;
encounterLinks: number;
};
}
@@ -1271,6 +1275,19 @@ const handler = apiHandler(
});
}
// Visits, the address book and the three link tables. Last on
// purpose: a link is written only when both of its ends exist, and
// the documents above are the final far side to land. Both ends of
// this section live in `src/lib/export/visits-backup.ts`, so a grep
// of THIS file alone will not find them — the same delegation as the
// cycle, profile, intraday and score restores above.
const visitsCleared = await restoreVisitsData(
tx,
ownerId,
payload,
skips,
);
const cleared = {
measurements: measurements.count,
medications: meds.count,
@@ -1297,6 +1314,9 @@ const handler = apiHandler(
correlationPatterns: profileCleared.correlationPatterns,
intradayProfiles: intradayCleared.intradayProfiles,
healthScoreRecords: healthScoreCleared.healthScoreRecords,
practitioners: visitsCleared.practitioners,
encounters: visitsCleared.encounters,
encounterLinks: visitsCleared.encounterLinks,
};
return { cleared, skipped: summarizeRestoreSkips(skips) };
},
@@ -28,6 +28,14 @@ vi.mock("@/lib/db", () => ({
// `buildIntradayProfileBackupSection`.
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
// The visit tables. Empty here: this file asserts the sections it seeds,
// and an account with no visits is the ordinary case the builder must
// still answer for.
practitioner: { findMany: vi.fn().mockResolvedValue([]) },
encounter: { findMany: vi.fn().mockResolvedValue([]) },
encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
// v1.15.0 — cycle tables read by the full-backup helper.
cycleProfile: { findUnique: vi.fn() },
menstrualCycle: { findMany: vi.fn() },
@@ -98,6 +106,15 @@ beforeEach(() => {
[] as never,
);
vi.mocked(prisma.healthScoreRecord.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.practitioner.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.encounter.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.encounterDocumentLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.encounterLabLink.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.encounterConditionLink.findMany).mockResolvedValue(
[] as never,
);
});
afterEach(() => {
@@ -26,6 +26,14 @@ vi.mock("@/lib/db", () => ({
// `buildIntradayProfileBackupSection`.
intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) },
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
// The visit tables. Empty here: this file asserts the sections it seeds,
// and an account with no visits is the ordinary case the builder must
// still answer for.
practitioner: { findMany: vi.fn().mockResolvedValue([]) },
encounter: { findMany: vi.fn().mockResolvedValue([]) },
encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) },
menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) },
cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) },
+3
View File
@@ -198,6 +198,9 @@ function catalogueLabel(
if (catalogue === "moodTag") {
return t("admin.section.backups.restoreSkippedMoodTag");
}
if (catalogue === "visitReference") {
return t("admin.section.backups.restoreSkippedVisitReference");
}
return t("admin.section.backups.restoreSkippedMoodFactor");
}
+9
View File
@@ -229,6 +229,15 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
{ model: "Allergy", field: "notesEncrypted", kind: "bytes" },
{ model: "FamilyHistoryEntry", field: "notesEncrypted", kind: "bytes" },
// ───── v1.38.0 visit free text (Bytes columns) ─────
// Why the person went, what came out of it, and the practice note. The
// structured columns a picker sorts and searches on — the practitioner's
// name, the kind, the status — stay queryable plaintext, the same split the
// structured health records above make.
{ model: "Encounter", field: "reasonEncrypted", kind: "bytes" },
{ model: "Encounter", field: "outcomeEncrypted", kind: "bytes" },
{ model: "Practitioner", field: "noteEncrypted", kind: "bytes" },
// ───── Inbound clinical document (Bytes column, codec-dispatched) ─────
// The raw uploaded document (the most sensitive blob in the corpus; never
// logged, never in wide-event meta). Two layouts, recorded per row in
+14
View File
@@ -76,6 +76,17 @@ export const WIPE_MODELS = [
"MoodReminderDispatch",
"MoodTagHidden",
// ── Visits ──────────────────────────────────────────────────────────────
// The three link tables cascade from the encounter AND from the far side
// they point at — a lab result, a document, a condition episode — so they
// are deleted before any of those, or their per-model counts read zero on an
// audit row that claims to say what was removed. The encounter follows its
// own links; the address book it points at is a parent and goes last.
"EncounterDocumentLink",
"EncounterLabLink",
"EncounterConditionLink",
"Encounter",
// ── Clinical record ─────────────────────────────────────────────────────
"MentalHealthAssessment",
"LabResult",
@@ -188,6 +199,9 @@ export const WIPE_MODELS = [
"IllnessEpisode",
"Workout",
"InboundDocument",
// Encounters point here with SetNull, so the address book outlives the
// visits it is attached to and is removed after them.
"Practitioner",
"ApiToken",
] as const;
@@ -247,6 +247,14 @@ function makePrisma() {
]),
},
healthScoreRecord: { findMany: vi.fn().mockResolvedValue([]) },
// The visit tables. Empty here: this file asserts the sections it seeds,
// and an account with no visits is the ordinary case the builder must
// still answer for.
practitioner: { findMany: vi.fn().mockResolvedValue([]) },
encounter: { findMany: vi.fn().mockResolvedValue([]) },
encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) },
encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) },
// Left unmocked on purpose: `buildProfileBackupSection` runs for real
// against these, so the assertions below exercise the builder rather than
// a stand-in that would agree with whatever the payload happened to do.
+25
View File
@@ -134,6 +134,13 @@ export const BACKED_UP_MODELS = [
"DocumentConditionLink",
"ExtractedFact",
// ── Visits ────────────────────────────────────────────────────────────────
"Practitioner",
"Encounter",
"EncounterDocumentLink",
"EncounterLabLink",
"EncounterConditionLink",
// ── Coach ─────────────────────────────────────────────────────────────────
"CoachConversation",
"CoachMessage",
@@ -176,6 +183,12 @@ export const BACKUP_WRITER_FILES: readonly string[] = [
"src/lib/export/profile-backup.ts",
"src/lib/export/intraday-profile-backup.ts",
"src/lib/export/health-score-backup.ts",
// Visits, the address book and the three link tables. Each model reads
// through its OWN delegate here rather than riding a relation, because
// `documentLinks` is a field name on two different models and a
// relation-shaped proof would be attributed to whichever one a matcher found
// first — the `symptomLinks` confusion, avoided by construction.
"src/lib/export/visits-backup.ts",
"src/lib/cycle/backup.ts",
];
@@ -184,6 +197,7 @@ export const BACKUP_RESTORE_FILES: readonly string[] = [
"src/lib/export/profile-backup.ts",
"src/lib/export/intraday-profile-backup.ts",
"src/lib/export/health-score-backup.ts",
"src/lib/export/visits-backup.ts",
"src/lib/cycle/backup.ts",
];
@@ -235,6 +249,17 @@ export const TWO_ENDED_MODELS = [
"Workout",
"NutrientIntakeDay",
"InboundDocument",
// Visits travel both ways from the release that introduces them. Two
// COVERAGE_PENDING neighbours already say what the alternative costs:
// reminders restore silent, and the filing between documents and conditions
// is lost. Leaving the visit links pending would return visits and documents
// with nothing between them — the same loss, one layer deeper — so the links
// land carried rather than owed.
"Practitioner",
"Encounter",
"EncounterDocumentLink",
"EncounterLabLink",
"EncounterConditionLink",
] as const;
/** One model claimed to travel both ways. */
+18 -1
View File
@@ -37,6 +37,12 @@ import {
type HealthScoreBackupCounts,
type HealthScoreBackupSection,
} from "@/lib/export/health-score-backup";
import {
buildVisitsBackupSection,
countVisitsBackupSection,
type VisitsBackupCounts,
type VisitsBackupSection,
} from "@/lib/export/visits-backup";
import {
buildIntradayProfileBackupSection,
countIntradayProfileBackupSection,
@@ -49,7 +55,8 @@ export interface FullBackupCounts
RecordsBackupCounts,
ProfileBackupCounts,
IntradayProfileBackupCounts,
HealthScoreBackupCounts {
HealthScoreBackupCounts,
VisitsBackupCounts {
measurements: number;
medications: number;
intakeEvents: number;
@@ -171,6 +178,7 @@ export async function buildFullBackupPayload(
profile,
intradayProfiles,
healthScoreRecords,
visits,
nutrientDays,
] = await Promise.all([
disasterRecovery
@@ -295,6 +303,12 @@ export async function buildFullBackupPayload(
buildHealthScoreBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
// Visits, the address book behind them, and the three link tables. Both
// ends live in `src/lib/export/visits-backup.ts` beside each other, so a
// grep of the restore route alone will not find them.
buildVisitsBackupSection(prisma, userId, {
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
}),
// Nutrient day totals were absent from every export path, which
// contradicted the schema's own reason for denormalising the unit column
// ("rows stay self-describing in exports even if the catalog ever drifts").
@@ -321,6 +335,7 @@ export async function buildFullBackupPayload(
const profileSection: ProfileBackupSection = profile;
const intradaySection: IntradayProfileBackupSection = intradayProfiles;
const healthScoreSection: HealthScoreBackupSection = healthScoreRecords;
const visitsSection: VisitsBackupSection = visits;
const payload = {
schemaVersion: BACKUP_SCHEMA_VERSION,
@@ -544,6 +559,7 @@ export async function buildFullBackupPayload(
...profileSection,
...intradaySection,
...healthScoreSection,
...visitsSection,
nutrientDays: nutrientDays.map((n) => ({
day: n.day,
nutrient: n.nutrient,
@@ -571,6 +587,7 @@ export async function buildFullBackupPayload(
...countProfileBackupSection(profile),
...countIntradayProfileBackupSection(intradayProfiles),
...countHealthScoreBackupSection(healthScoreRecords),
...countVisitsBackupSection(visits),
},
};
}
+17 -2
View File
@@ -28,9 +28,24 @@
* edge rather than a record.
*/
/** Which seeded catalogue a key failed to resolve against. */
/**
* Which lookup a key failed to resolve against.
*
* Four are seeded catalogues, as described above. The fifth, `visitReference`,
* is a different kind of unresolvable and is here rather than in its own
* mechanism because the cost and the honest response are identical: an edge is
* lost, the record it hangs off survives, and the operator is told which one.
* What it names is a row the file pointed at that the restore did not put
* back — a reminder, which is on the coverage-pending register and never
* travels, or a document, lab result or condition episode whose id a portable
* export does not carry.
*/
export type SkippedCatalogue =
"cycleSymptom" | "illnessSymptom" | "moodFactor" | "moodTag";
| "cycleSymptom"
| "illnessSymptom"
| "moodFactor"
| "moodTag"
| "visitReference";
/** One key this instance does not know, and the links it cost. */
export interface SkippedCatalogueKey {
+573
View File
@@ -0,0 +1,573 @@
/**
* Visits and the address book behind them, with both backup ends in one file.
*
* The builder and the restore live together deliberately, the way
* `health-score-backup.ts` and `src/lib/cycle/backup.ts` do: a reader asking
* "is this carried at both ends?" answers it here, and a reader who greps only
* the restore ROUTE gets a false negative, because the route delegates.
*
* Five models ride: the practitioners, the encounters, and the three link
* tables between an encounter and a document, a lab result or a condition
* episode. All five are carried rather than owed. Two neighbours on the debt
* register already say what the alternative costs — reminders restore silent,
* and the filing between documents and conditions is lost — and a restore that
* returned visits and documents with nothing between them would be the same
* loss one layer deeper.
*
* Every read is scoped to the owner. Each model uses its own named select
* constant and its own delegate call rather than riding a parent's `include`:
* `documentLinks` is a relation field name on two different models, so a
* relation-shaped write here would be attributed to whichever of them a
* matcher happened to find first. Delegate calls cannot be confused that way.
*
* Two references deliberately do not survive a restore, and both are reported
* rather than dropped:
*
* - `Encounter.reminderId` points at a `MeasurementReminder`, which is on the
* coverage-pending register and does not come back. The column resolves to
* NULL, never to a dangling id.
* - A link whose far side was not restored. A portable export carries no lab
* result ids, so a lab link in one cannot resolve; a disaster-recovery
* payload carries every id and they all do.
*/
import { Buffer } from "node:buffer";
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
import {
recordUnknownKeys,
type RestoreSkipLog,
} from "@/lib/export/restore-skips";
export interface VisitsBackupOptions {
purpose?: "portable-export" | "disaster-recovery";
}
/** One entry in the account's own address book of doctors and practices. */
export interface PractitionerBackupEntry {
/** Always carried: an encounter references a practitioner by id and nothing
* else about the row is unique enough to rebuild the reference from. */
id: string;
name: string;
specialty: string | null;
practice: string | null;
location: string | null;
phone: string | null;
/** Base64 ciphertext, carried verbatim — never decrypted into the file. */
noteEncrypted: string | null;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
/** One contact with the healthcare system: happened, or booked. */
export interface EncounterBackupEntry {
/** Always carried, for the same reason as the practitioner id above: the
* three link tables address an encounter by it. */
id: string;
occurredAt: string;
status: EncounterStatus;
kind: EncounterKind;
practitionerId: string | null;
reasonEncrypted: string | null;
outcomeEncrypted: string | null;
/**
* Carried so the loss is visible in the file rather than inferred from its
* absence. The restore resolves it to NULL and names the drop: the reminder
* it points at is not in the backup.
*/
reminderId: string | null;
createdAt: string;
updatedAt: string;
deletedAt?: string | null;
}
/** One edge from a visit to something it produced. */
export interface EncounterLinkBackupEntry {
encounterId: string;
targetId: string;
createdAt: string;
}
export interface VisitsBackupSection {
practitioners: PractitionerBackupEntry[];
encounters: EncounterBackupEntry[];
encounterDocumentLinks: EncounterLinkBackupEntry[];
encounterLabLinks: EncounterLinkBackupEntry[];
encounterConditionLinks: EncounterLinkBackupEntry[];
}
export interface VisitsBackupCounts {
practitioners: number;
encounters: number;
encounterLinks: number;
}
/** Base64 for a `Bytes` ciphertext column, or null when the column is empty. */
function encodeCiphertext(value: Uint8Array | null): string | null {
if (!value || value.byteLength === 0) return null;
return Buffer.from(value).toString("base64");
}
/**
* Named select constants, one per model.
*
* Named rather than inline because a structural matcher binds a model to the
* literal beside its delegate call, and an inline object inside a `Promise.all`
* is a shape earlier work in this repository had to refactor away from for
* exactly that reason.
*/
const PRACTITIONER_BACKUP_SELECT = {
id: true,
name: true,
specialty: true,
practice: true,
location: true,
phone: true,
noteEncrypted: true,
createdAt: true,
updatedAt: true,
deletedAt: true,
} as const;
const ENCOUNTER_BACKUP_SELECT = {
id: true,
occurredAt: true,
status: true,
kind: true,
practitionerId: true,
reasonEncrypted: true,
outcomeEncrypted: true,
reminderId: true,
createdAt: true,
updatedAt: true,
deletedAt: true,
} as const;
const ENCOUNTER_DOCUMENT_LINK_BACKUP_SELECT = {
encounterId: true,
documentId: true,
createdAt: true,
} as const;
const ENCOUNTER_LAB_LINK_BACKUP_SELECT = {
encounterId: true,
labResultId: true,
createdAt: true,
} as const;
const ENCOUNTER_CONDITION_LINK_BACKUP_SELECT = {
encounterId: true,
episodeId: true,
createdAt: true,
} as const;
/**
* Build the visits slice of a user's full backup.
*
* Takes the delegates it uses rather than a whole client, matching the other
* section builders. A portable export omits tombstones so a restore cannot
* resurrect them; a disaster-recovery payload keeps them so the account comes
* back as itself.
*/
export async function buildVisitsBackupSection(
prisma: Pick<
PrismaClient,
| "practitioner"
| "encounter"
| "encounterDocumentLink"
| "encounterLabLink"
| "encounterConditionLink"
>,
userId: string,
options: VisitsBackupOptions = {},
): Promise<VisitsBackupSection> {
const disasterRecovery = options.purpose === "disaster-recovery";
const [
practitionerRows,
encounterRows,
documentLinkRows,
labLinkRows,
conditionLinkRows,
] = await Promise.all([
prisma.practitioner.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { name: "asc" },
select: PRACTITIONER_BACKUP_SELECT,
}),
prisma.encounter.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { occurredAt: "desc" },
select: ENCOUNTER_BACKUP_SELECT,
}),
prisma.encounterDocumentLink.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
select: ENCOUNTER_DOCUMENT_LINK_BACKUP_SELECT,
}),
prisma.encounterLabLink.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
select: ENCOUNTER_LAB_LINK_BACKUP_SELECT,
}),
prisma.encounterConditionLink.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
select: ENCOUNTER_CONDITION_LINK_BACKUP_SELECT,
}),
]);
return {
practitioners: practitionerRows.map((row) => ({
id: row.id,
name: row.name,
specialty: row.specialty,
practice: row.practice,
location: row.location,
phone: row.phone,
noteEncrypted: encodeCiphertext(row.noteEncrypted),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
...(disasterRecovery
? { deletedAt: row.deletedAt?.toISOString() ?? null }
: {}),
})),
encounters: encounterRows.map((row) => ({
id: row.id,
occurredAt: row.occurredAt.toISOString(),
status: row.status,
kind: row.kind,
practitionerId: row.practitionerId,
reasonEncrypted: encodeCiphertext(row.reasonEncrypted),
outcomeEncrypted: encodeCiphertext(row.outcomeEncrypted),
reminderId: row.reminderId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
...(disasterRecovery
? { deletedAt: row.deletedAt?.toISOString() ?? null }
: {}),
})),
encounterDocumentLinks: documentLinkRows.map((row) => ({
encounterId: row.encounterId,
targetId: row.documentId,
createdAt: row.createdAt.toISOString(),
})),
encounterLabLinks: labLinkRows.map((row) => ({
encounterId: row.encounterId,
targetId: row.labResultId,
createdAt: row.createdAt.toISOString(),
})),
encounterConditionLinks: conditionLinkRows.map((row) => ({
encounterId: row.encounterId,
targetId: row.episodeId,
createdAt: row.createdAt.toISOString(),
})),
};
}
/** Row counts for the audit trail, mirroring the other section counters. */
export function countVisitsBackupSection(
section: VisitsBackupSection,
): VisitsBackupCounts {
return {
practitioners: section.practitioners.length,
encounters: section.encounters.length,
encounterLinks:
section.encounterDocumentLinks.length +
section.encounterLabLinks.length +
section.encounterConditionLinks.length,
};
}
/** Counts the visit restore wiped, for the audit trail. */
export interface VisitsRestoreCleared {
practitioners: number;
encounters: number;
encounterLinks: number;
}
/**
* The slice of a parsed backup this restore consumes.
*
* Required rather than optional: the payload schema defaults every key, so
* every caller already satisfies this, and one that stops satisfying it fails
* to compile instead of passing `undefined` into a loop that iterates zero
* times and reports success.
*/
export interface VisitsRestoreInput {
practitioners: RestoredPractitioner[];
encounters: RestoredEncounter[];
encounterDocumentLinks: EncounterLinkBackupEntry[];
encounterLabLinks: EncounterLinkBackupEntry[];
encounterConditionLinks: EncounterLinkBackupEntry[];
}
/**
* What the restore reads, as the parsed file actually presents it.
*
* The builder's entry types above describe what this release WRITES. These
* describe what a restore may be handed, which is a wider set: a portable
* export omits the tombstone, and every optional column in the payload schema
* arrives as `undefined` rather than `null`. Modelling the read side as the
* write side would compile only because the two happen to agree today, and
* would break the first time an older file reached it.
*/
type OptionalNullable<T> = { [K in keyof T]?: T[K] | undefined };
export type RestoredPractitioner = Pick<
PractitionerBackupEntry,
"id" | "name" | "createdAt" | "updatedAt"
> &
OptionalNullable<
Pick<
PractitionerBackupEntry,
"specialty" | "practice" | "location" | "phone" | "noteEncrypted"
>
> & { deletedAt?: string | null };
export type RestoredEncounter = Pick<
EncounterBackupEntry,
"id" | "occurredAt" | "status" | "kind" | "createdAt" | "updatedAt"
> &
OptionalNullable<
Pick<
EncounterBackupEntry,
"practitionerId" | "reasonEncrypted" | "outcomeEncrypted" | "reminderId"
>
> & { deletedAt?: string | null };
function decodeCiphertext(encoded: string): Uint8Array<ArrayBuffer> {
const decoded = Buffer.from(encoded, "base64");
const bytes = new Uint8Array(new ArrayBuffer(decoded.byteLength));
bytes.set(decoded);
return bytes;
}
/**
* Re-create the account's visits, address book and link rows.
*
* Delete-then-recreate inside the caller's transaction, matching every other
* section. Parents first — practitioners, then encounters, then the links —
* because a link addresses both of its ends by id.
*
* MUST be called after the documents, lab results and condition episodes have
* been restored: a link is written only when both of its ends exist, and the
* existence check is what decides. Calling it earlier would report every link
* as unresolvable and be counted as a successful restore of nothing.
*/
export async function restoreVisitsData(
tx: Prisma.TransactionClient,
ownerId: string,
payload: VisitsRestoreInput,
skips: RestoreSkipLog,
): Promise<VisitsRestoreCleared> {
const clearedLinks =
(await tx.encounterDocumentLink.deleteMany({ where: { userId: ownerId } }))
.count +
(await tx.encounterLabLink.deleteMany({ where: { userId: ownerId } }))
.count +
(await tx.encounterConditionLink.deleteMany({ where: { userId: ownerId } }))
.count;
const clearedEncounters = await tx.encounter.deleteMany({
where: { userId: ownerId },
});
const clearedPractitioners = await tx.practitioner.deleteMany({
where: { userId: ownerId },
});
if (payload.practitioners.length > 0) {
await tx.practitioner.createMany({
data: payload.practitioners.map((entry) => ({
id: entry.id,
userId: ownerId,
name: entry.name,
specialty: entry.specialty ?? null,
practice: entry.practice ?? null,
location: entry.location ?? null,
phone: entry.phone ?? null,
noteEncrypted:
entry.noteEncrypted == null
? null
: decodeCiphertext(entry.noteEncrypted),
createdAt: new Date(entry.createdAt),
updatedAt: new Date(entry.updatedAt),
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
})),
});
}
// A practitioner the file references but does not carry cannot be invented,
// and a dangling id would fail the foreign key and roll the whole restore
// back over one visit. The reference is dropped to NULL instead — the visit
// itself, its date, its reason and its outcome all survive.
const restoredPractitioners = new Set(
payload.practitioners.map((entry) => entry.id),
);
const droppedPractitioners: string[] = [];
// The reminder is on the coverage-pending register: it is not in the file at
// all, so every reference to one is a known, reportable drop.
const droppedReminders: string[] = [];
if (payload.encounters.length > 0) {
await tx.encounter.createMany({
data: payload.encounters.map((entry) => {
const practitionerId =
entry.practitionerId &&
restoredPractitioners.has(entry.practitionerId)
? entry.practitionerId
: null;
if (entry.practitionerId && practitionerId === null) {
droppedPractitioners.push(entry.practitionerId);
}
if (entry.reminderId) droppedReminders.push(entry.reminderId);
return {
id: entry.id,
userId: ownerId,
occurredAt: new Date(entry.occurredAt),
status: entry.status,
kind: entry.kind,
practitionerId,
reasonEncrypted:
entry.reasonEncrypted == null
? null
: decodeCiphertext(entry.reasonEncrypted),
outcomeEncrypted:
entry.outcomeEncrypted == null
? null
: decodeCiphertext(entry.outcomeEncrypted),
// Never the id the file carried: the row it named is not restored.
reminderId: null,
createdAt: new Date(entry.createdAt),
updatedAt: new Date(entry.updatedAt),
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
};
}),
});
}
recordUnknownKeys(
skips,
"visitReference",
[...new Set(droppedPractitioners)],
droppedPractitioners,
);
recordUnknownKeys(
skips,
"visitReference",
[...new Set(droppedReminders)],
droppedReminders,
);
const restoredEncounters = new Set(payload.encounters.map((e) => e.id));
/**
* Write the links whose far side actually came back, and name the rest.
*
* The far side is checked against the database rather than against the
* payload, because the document, the lab result and the episode are restored
* by other branches in the same transaction and this is the only place that
* can see all three.
*/
async function restoreLinks(
entries: EncounterLinkBackupEntry[],
existingTargetIds: Set<string>,
write: (
rows: Array<{
encounterId: string;
targetId: string;
createdAt: Date;
}>,
) => Promise<void>,
): Promise<void> {
const writable: Array<{
encounterId: string;
targetId: string;
createdAt: Date;
}> = [];
const dropped: string[] = [];
for (const entry of entries) {
if (
restoredEncounters.has(entry.encounterId) &&
existingTargetIds.has(entry.targetId)
) {
writable.push({
encounterId: entry.encounterId,
targetId: entry.targetId,
createdAt: new Date(entry.createdAt),
});
} else {
dropped.push(entry.targetId);
}
}
if (writable.length > 0) await write(writable);
recordUnknownKeys(skips, "visitReference", [...new Set(dropped)], dropped);
}
const [documentIds, labResultIds, episodeIds] = await Promise.all([
tx.inboundDocument.findMany({
where: { userId: ownerId },
select: { id: true },
}),
tx.labResult.findMany({
where: { userId: ownerId },
select: { id: true },
}),
tx.illnessEpisode.findMany({
where: { userId: ownerId },
select: { id: true },
}),
]);
await restoreLinks(
payload.encounterDocumentLinks,
new Set(documentIds.map((row) => row.id)),
async (rows) => {
await tx.encounterDocumentLink.createMany({
data: rows.map((row) => ({
userId: ownerId,
encounterId: row.encounterId,
documentId: row.targetId,
createdAt: row.createdAt,
})),
});
},
);
await restoreLinks(
payload.encounterLabLinks,
new Set(labResultIds.map((row) => row.id)),
async (rows) => {
await tx.encounterLabLink.createMany({
data: rows.map((row) => ({
userId: ownerId,
encounterId: row.encounterId,
labResultId: row.targetId,
createdAt: row.createdAt,
})),
});
},
);
await restoreLinks(
payload.encounterConditionLinks,
new Set(episodeIds.map((row) => row.id)),
async (rows) => {
await tx.encounterConditionLink.createMany({
data: rows.map((row) => ({
userId: ownerId,
encounterId: row.encounterId,
episodeId: row.targetId,
createdAt: row.createdAt,
})),
});
},
);
return {
practitioners: clearedPractitioners.count,
encounters: clearedEncounters.count,
encounterLinks: clearedLinks,
};
}
+25
View File
@@ -24,6 +24,13 @@ export interface MeasurementReminderDtoShape {
/**
* v1.18.1 — provenance: `VORSORGE` (user-created) or `COACH` (minted from
* a Coach cadence suggestion). The UI labels the source.
*
* Deliberately two members, while `ReminderOrigin` in the schema has three.
* The third, `ENCOUNTER`, belongs to a booked visit and never reaches a
* Vorsorge surface: every query that feeds this mapper excludes it, and the
* published enum stays closed at these two so the reminder contract does not
* move. Widening this union is the wrong repair for a type error here — the
* right one is finding the read site that lost its exclusion.
*/
origin: "VORSORGE" | "COACH";
notifyHour: number;
@@ -35,9 +42,27 @@ export interface MeasurementReminderDtoShape {
updatedAt: string;
}
/**
* The invariant this mapper enforces: an `ENCOUNTER`-origin row is a booked
* visit's one-shot reminder and belongs to no Vorsorge surface. Four read
* sites exclude it — the reminder list route, the daily digest, the insights
* preventive-care block and the MCP preventive-care tool. Reaching here means
* one of them lost its filter, and the honest outcome is a loud failure rather
* than an appointment relabelled as a checkup.
*
* Proven by `src/__tests__/encounter-reminder-exclusion.test.ts`.
*/
export function toMeasurementReminderDto(
row: MeasurementReminder,
): MeasurementReminderDtoShape {
if (row.origin === "ENCOUNTER") {
throw new Error(
`toMeasurementReminderDto received an ENCOUNTER-origin reminder (${row.id}). ` +
"Appointment reminders are excluded from every Vorsorge read surface; " +
"a read site has lost its origin filter. See " +
"src/__tests__/encounter-reminder-exclusion.test.ts.",
);
}
return {
id: row.id,
label: row.label,
+61
View File
@@ -32,6 +32,8 @@ import {
ContraceptiveKind,
CycleTrackingGoal,
DocumentSummaryState,
EncounterKind,
EncounterStatus,
FamilyRelationship,
FlowLevel,
GlucoseContext,
@@ -793,6 +795,56 @@ const illnessEpisodeBackupSchema = z
})
.passthrough();
/**
* A practitioner, an encounter, and the edges between an encounter and the
* things it produced.
*
* `id` is required on both records, unlike the lab result above: a link
* addresses an encounter and its far side by id and nothing else on either row
* is unique enough to rebuild the reference from. The ciphertext columns ride
* verbatim as base64 — a visit note is never decrypted into the file.
*/
const practitionerBackupSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
specialty: z.string().nullable().optional(),
practice: z.string().nullable().optional(),
location: z.string().nullable().optional(),
phone: z.string().nullable().optional(),
noteEncrypted: base64BytesSchema.nullable().optional(),
createdAt: isoDateTime,
updatedAt: isoDateTime,
deletedAt: isoDateTime.nullable().optional(),
})
.passthrough();
const encounterBackupSchema = z
.object({
id: z.string().min(1),
occurredAt: isoDateTime,
status: z.enum(EncounterStatus),
kind: z.enum(EncounterKind),
practitionerId: z.string().nullable().optional(),
reasonEncrypted: base64BytesSchema.nullable().optional(),
outcomeEncrypted: base64BytesSchema.nullable().optional(),
// Carried so the loss is visible in the file. The restore resolves it to
// NULL and names the drop: the reminder it points at does not travel.
reminderId: z.string().nullable().optional(),
createdAt: isoDateTime,
updatedAt: isoDateTime,
deletedAt: isoDateTime.nullable().optional(),
})
.passthrough();
const encounterLinkBackupSchema = z
.object({
encounterId: z.string().min(1),
targetId: z.string().min(1),
createdAt: isoDateTime,
})
.passthrough();
const allergyBackupSchema = z
.object({
id: z.string().min(1),
@@ -936,6 +988,15 @@ export const backupPayloadSchema = z
// the sections above: a file written before the table existed carries no
// key, and an account whose score never resolved writes [].
healthScoreRecords: z.array(healthScoreRecordBackupSchema).default([]),
// Visits, the address book behind them, and the three link tables.
// Defaulted for the same reason as the sections above: a file written
// before the tables existed carries no key, and an account that has never
// filed a visit writes [].
practitioners: z.array(practitionerBackupSchema).default([]),
encounters: z.array(encounterBackupSchema).default([]),
encounterDocumentLinks: z.array(encounterLinkBackupSchema).default([]),
encounterLabLinks: z.array(encounterLinkBackupSchema).default([]),
encounterConditionLinks: z.array(encounterLinkBackupSchema).default([]),
manifest: backupManifestSchema.nullable().default(null),
})
.passthrough()
+59 -2
View File
@@ -140,6 +140,14 @@ const COUNT_BACK: Record<
p.nutrientIntakeDay.count({ where: { userId } }),
InboundDocument: (p, userId) =>
p.inboundDocument.count({ where: { userId } }),
Practitioner: (p, userId) => p.practitioner.count({ where: { userId } }),
Encounter: (p, userId) => p.encounter.count({ where: { userId } }),
EncounterDocumentLink: (p, userId) =>
p.encounterDocumentLink.count({ where: { userId } }),
EncounterLabLink: (p, userId) =>
p.encounterLabLink.count({ where: { userId } }),
EncounterConditionLink: (p, userId) =>
p.encounterConditionLink.count({ where: { userId } }),
};
/**
@@ -239,7 +247,7 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
const biomarker = await prisma.biomarker.create({
data: { userId: OWNER_ID, name: "Ferritin", unit: "ng/mL" },
});
await prisma.labResult.create({
const labResult = await prisma.labResult.create({
data: {
userId: OWNER_ID,
biomarkerId: biomarker.id,
@@ -390,7 +398,7 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
new ArrayBuffer(documentBytes.byteLength),
);
contentEncrypted.set(documentBytes);
await prisma.inboundDocument.create({
const document = await prisma.inboundDocument.create({
data: {
userId: OWNER_ID,
kind: "LAB_RESULT",
@@ -402,6 +410,55 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
contentCodec: "binary2",
},
});
// One visit, the practice it was at, and one link of each of the three
// kinds. The links reach the document, the lab result and the condition
// episode seeded above rather than fresh rows, because a link to something
// the restore did not put back is the exact case the restore reports as a
// skip — seeding it against a row that does come back is what makes a
// returning count mean the filing survived.
const practitioner = await prisma.practitioner.create({
data: {
userId: OWNER_ID,
name: "Round-trip practice",
specialty: "Internal medicine",
noteEncrypted: encryptToBytes("ring the upper bell"),
},
});
const encounter = await prisma.encounter.create({
data: {
userId: OWNER_ID,
occurredAt: AT("2026-06-30T08:00:00.000Z"),
status: "DONE",
kind: "ROUTINE",
practitionerId: practitioner.id,
reasonEncrypted: encryptToBytes("annual check"),
outcomeEncrypted: encryptToBytes(
"ferritin back in range, recheck in a year",
),
},
});
await prisma.encounterDocumentLink.create({
data: {
userId: OWNER_ID,
encounterId: encounter.id,
documentId: document.id,
},
});
await prisma.encounterLabLink.create({
data: {
userId: OWNER_ID,
encounterId: encounter.id,
labResultId: labResult.id,
},
});
await prisma.encounterConditionLink.create({
data: {
userId: OWNER_ID,
encounterId: encounter.id,
episodeId: episode.id,
},
});
}
async function createOwner(prisma: PrismaClient) {