diff --git a/messages/de.json b/messages/de.json index 329c13522..5b0e56882 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6739,6 +6739,7 @@ "restoreSkippedIllnessSymptom": "Krankheitssymptom", "restoreSkippedMoodFactor": "Stimmungsfaktor", "restoreSkippedMoodTag": "Stimmungs-Tag", + "restoreSkippedVisitReference": "Besuchsverknüpfung", "docsLink": "Dokumentation" }, "danger-zone": { diff --git a/messages/en.json b/messages/en.json index 76cc7a265..01de7f3bb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6739,6 +6739,7 @@ "restoreSkippedIllnessSymptom": "Illness symptom", "restoreSkippedMoodFactor": "Mood factor", "restoreSkippedMoodTag": "Mood tag", + "restoreSkippedVisitReference": "Visit reference", "docsLink": "Documentation" }, "danger-zone": { diff --git a/messages/es.json b/messages/es.json index 9dadf056b..ec784cde8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 553da5fe7..9a0f9a1be 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 792bb59bf..6620b9fa7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6739,6 +6739,7 @@ "restoreSkippedIllnessSymptom": "Sintomo di malattia", "restoreSkippedMoodFactor": "Fattore dell'umore", "restoreSkippedMoodTag": "Etichetta dell'umore", + "restoreSkippedVisitReference": "Riferimento alla visita", "docsLink": "Documentazione" }, "danger-zone": { diff --git a/messages/pl.json b/messages/pl.json index 0c8475bde..82918dd3e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6739,6 +6739,7 @@ "restoreSkippedIllnessSymptom": "Objaw choroby", "restoreSkippedMoodFactor": "Czynnik nastroju", "restoreSkippedMoodTag": "Tag nastroju", + "restoreSkippedVisitReference": "Odwołanie wizyty", "docsLink": "Dokumentacja" }, "danger-zone": { diff --git a/prisma/migrations/0320_encounter_practitioner/migration.sql b/prisma/migrations/0320_encounter_practitioner/migration.sql new file mode 100644 index 000000000..7c8d7b35f --- /dev/null +++ b/prisma/migrations/0320_encounter_practitioner/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index affc5e70c..9043f629c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/public/sw.js b/public/sw.js index d2632934b..d62c81e76 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index f764c55df..72d2d5df5 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -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 diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index 88bc30426..a1b1d2209 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -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([ { diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index a6196cf0c..da328d9aa 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -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) }; }, diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index b1823aecb..16e1c3292 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -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(() => { diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 4ac966527..f1e7b2723 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -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([]) }, diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 74707883b..e13d8d443 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -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"); } diff --git a/src/lib/crypto/encrypted-columns.ts b/src/lib/crypto/encrypted-columns.ts index 3e3cc1241..fe4c371b3 100644 --- a/src/lib/crypto/encrypted-columns.ts +++ b/src/lib/crypto/encrypted-columns.ts @@ -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 diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 5b4778f77..801a83f16 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -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; diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index e55bf7b69..8380077ea 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -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. diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 3a58e8206..96e706a05 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -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. */ diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 0d3375c99..9d88b6193 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -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), }, }; } diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts index 326ea093b..022be17b9 100644 --- a/src/lib/export/restore-skips.ts +++ b/src/lib/export/restore-skips.ts @@ -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 { diff --git a/src/lib/export/visits-backup.ts b/src/lib/export/visits-backup.ts new file mode 100644 index 000000000..4b5eae712 --- /dev/null +++ b/src/lib/export/visits-backup.ts @@ -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 { + 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 = { [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 { + 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 { + 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, + write: ( + rows: Array<{ + encounterId: string; + targetId: string; + createdAt: Date; + }>, + ) => Promise, + ): Promise { + 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, + }; +} diff --git a/src/lib/measurement-reminders/dto.ts b/src/lib/measurement-reminders/dto.ts index d07453f53..96c868fce 100644 --- a/src/lib/measurement-reminders/dto.ts +++ b/src/lib/measurement-reminders/dto.ts @@ -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, diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index b8f671448..de950a76f 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -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() diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 9a290d81b..c7112c16f 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -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 { 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 { 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 { 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) {