Files
temetro/backend/src/db/schema/patients.ts
T
Khalid Abdi dd64d689c8 backend: Patient Portal over the relay + wallet linking
- services/portal.ts: clinic-scoped portal actions (clinic info, doctors,
  availability, linkWallet, conflict-checked booking, results, downloadable lab
  files) plus handlePortalRequest to dispatch a relayed portal:request.
- relay-client.ts: handle portal:request on the hub and ack the result back down
  the relay (device path identifies the patient by verified wallet number).
- patients: new nullable wallet_number column (+ migration); linking stores it,
  and walletNumberForPatient now resolves via it so pushes work after a portal
  link, not only after a permanent share.
- routes/portal.ts: GET /:clinic/link returns the relay-based pairing descriptor
  (clinic signing key + relay URL) for the QR — no more localhost-baked API URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 20:08:36 +03:00

169 lines
5.9 KiB
TypeScript

import {
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core";
import type {
AllergySeverity,
LabFlag,
PatientStatus,
Sex,
Trend,
} from "../../types/patient.js";
import { organization, user } from "./auth.js";
// One row per patient, scoped to a clinic (organization). `fileNumber` (MRN)
// is the chat lookup key and is unique within an organization. Current vitals
// live as columns; the two headline sparkline series and the freeform alert
// list are stored as JSONB. Child collections are separate tables below.
export const patients = pgTable(
"patients",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
fileNumber: text("file_number").notNull(),
name: text("name").notNull(),
age: integer("age").notNull(),
sex: text("sex").$type<Sex>().notNull(),
pcp: text("pcp").notNull(),
status: text("status").$type<PatientStatus>().notNull(),
initials: text("initials").notNull(),
// Contact + clinical demographics. `phone` is a contact/registration field
// (reception may read/write it); `bloodType` is clinical (redacted for the
// reception role, like allergies/vitals).
phone: text("phone").notNull().default(""),
bloodType: text("blood_type").notNull().default(""),
alerts: jsonb("alerts").$type<string[]>().notNull(),
vitalsBp: text("vitals_bp").notNull(),
vitalsHr: text("vitals_hr").notNull(),
vitalsTemp: text("vitals_temp").notNull(),
vitalsSpo2: text("vitals_spo2").notNull(),
vitalsTakenAt: text("vitals_taken_at").notNull(),
vitalsTrend: jsonb("vitals_trend").$type<Trend>().notNull(),
labTrend: jsonb("lab_trend").$type<Trend>().notNull(),
// The clinician responsible for this chart (the patient's "PCP"). Used to
// scope what each doctor sees and to transfer a patient between providers.
// Nullable: legacy/unassigned rows and patients registered by reception.
primaryProviderId: text("primary_provider_id").references(() => user.id, {
onDelete: "set null",
}),
// Provenance: "ai" rows were imported/drafted by the chat agent (possibly
// with auto-generated file numbers or placeholder fields) and are flagged
// for clinician review/edit.
source: text("source").$type<"manual" | "ai">().notNull().default("manual"),
// Provenance for records imported from a patient wallet app, plus the
// auto-delete deadline for a *temporary* share. When `shareExpiresAt` is set
// and passes, a scheduled sweep hard-deletes the row (services/wallet-share).
shareOrigin: text("share_origin").$type<"wallet">(),
shareExpiresAt: timestamp("share_expires_at"),
// The patient's wallet number (tmw_…) once they link their wallet from the
// Patient Portal. Lets clinic→wallet pushes and portal actions resolve to
// this file directly (services/portal.ts, wallet-updates.ts). Nullable.
walletNumber: text("wallet_number"),
createdBy: text("created_by").references(() => user.id, {
onDelete: "set null",
}),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(t) => [
uniqueIndex("patients_org_file_uidx").on(t.organizationId, t.fileNumber),
index("patients_org_idx").on(t.organizationId),
],
);
export const allergies = pgTable(
"patient_allergies",
{
id: uuid("id").primaryKey().defaultRandom(),
patientId: uuid("patient_id")
.notNull()
.references(() => patients.id, { onDelete: "cascade" }),
position: integer("position").notNull().default(0),
substance: text("substance").notNull(),
reaction: text("reaction").notNull(),
severity: text("severity").$type<AllergySeverity>().notNull(),
},
(t) => [index("allergies_patient_idx").on(t.patientId)],
);
export const medications = pgTable(
"patient_medications",
{
id: uuid("id").primaryKey().defaultRandom(),
patientId: uuid("patient_id")
.notNull()
.references(() => patients.id, { onDelete: "cascade" }),
position: integer("position").notNull().default(0),
name: text("name").notNull(),
dose: text("dose").notNull(),
frequency: text("frequency").notNull(),
},
(t) => [index("medications_patient_idx").on(t.patientId)],
);
export const problems = pgTable(
"patient_problems",
{
id: uuid("id").primaryKey().defaultRandom(),
patientId: uuid("patient_id")
.notNull()
.references(() => patients.id, { onDelete: "cascade" }),
position: integer("position").notNull().default(0),
label: text("label").notNull(),
since: text("since").notNull(),
},
(t) => [index("problems_patient_idx").on(t.patientId)],
);
export const labs = pgTable(
"patient_labs",
{
id: uuid("id").primaryKey().defaultRandom(),
patientId: uuid("patient_id")
.notNull()
.references(() => patients.id, { onDelete: "cascade" }),
position: integer("position").notNull().default(0),
name: text("name").notNull(),
value: text("value").notNull(),
flag: text("flag").$type<LabFlag>().notNull(),
takenAt: text("taken_at").notNull(),
},
(t) => [index("labs_patient_idx").on(t.patientId)],
);
export const encounters = pgTable(
"patient_encounters",
{
id: uuid("id").primaryKey().defaultRandom(),
patientId: uuid("patient_id")
.notNull()
.references(() => patients.id, { onDelete: "cascade" }),
position: integer("position").notNull().default(0),
date: text("date").notNull(),
type: text("type").notNull(),
provider: text("provider").notNull(),
summary: text("summary").notNull(),
},
(t) => [index("encounters_patient_idx").on(t.patientId)],
);
export const patientChildTables = {
allergies,
medications,
problems,
labs,
encounters,
} as const;