Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ Guardianship (Phase 8, issue #42 — docs/adr/0011)

DoctorProfile (1:1 User)
id, user_id, full_name, verification_status (enum: pending|verified|rejected)
time_zone (IANA identifier used when interpreting the doctor's availability)
specialties: DoctorSpecialty[]

Specialty (controlled vocabulary, admin-managed)
Expand Down Expand Up @@ -205,6 +206,19 @@ Appointment
# referral that produced several appointments survives the link being
# cleared (Referral.appointments holds the full list).

WaitlistEntry
id, patient_profile_id (required), doctor_profile_id (nullable),
specialty_id (nullable), facility_capability_id (nullable),
availability_slot_id (nullable), status (pending|on_hold|accepted|expired),
earliest_acceptable, latest_acceptable, hold_duration_minutes,
created_at, updated_at
# A patient's waitlist request may target a doctor, specialty, or
# facility capability. A time-boxed offer may reference an availability
# slot; deleting its target profile/capability clears that optional link.

WindowTime
id, start, end (unique pair)

IntakeForm
id, patient_profile_id, symptoms (structured: array of {symptom, duration, severity}), free_text_notes
created_at
Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc
`User` (guardian) —< `Guardianship` >— `PatientProfile` (dependent, `userId` null) (Phase 8, issue #42 — docs/adr/0011; create-only, access ends at `AGE_OF_MAJORITY_YEARS`; authorise any act-for-a-patient path through `patientsService.resolveActingProfile`).
`DoctorProfile` —< `DoctorSpecialty` >— `Specialty`
`AvailabilitySlot` (doctor's calendar) → `Appointment` (`availability_slot_id` nullable — a cancelled appointment releases its slot; booking safety is `claimOpenSlot`, not that column) → `IntakeForm`, `Consultation`
`PatientProfile` → `WaitlistEntry` (optional doctor/specialty/facility capability/slot targets; time-boxed offer status)
`Consultation` (`appointmentId` **UNIQUE** — closing a consultation is a create-once operation, `409` on a second attempt; one writer, `consultationsService.createConsultation`, reached from `POST /consultations`, `POST /appointments/:id/consultation` and the orders flow; see docs/adr/0009) → (outcome=referred) → `Referral` (snapshot! required `target_doctor_id` — routed at creation when only a specialty is named, ADR 0008; self-FK lineage `parent_referral_id`, no-show/loop/admin flags) → new `Appointment`
`Appointment` → `Consultation` → immutable `Prescription`(+`PrescriptionItem`) / `LabOrder`(+`LabResult`)
`FulfillmentRecord` (facility-owned) references an order but doesn't mutate it.
Expand Down
21 changes: 21 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"date-fns-tz": "^3.2.0",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"intl-messageformat": "^10.7.18",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,3 @@ CREATE INDEX "PasswordResetToken_userId_idx" ON "PasswordResetToken"("userId");

-- AddForeignKey
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
-- CreateEnum
CREATE TYPE "WaitlistStatus" AS ENUM ('pending', 'on_hold', 'accepted', 'expired');

-- AlterEnum
ALTER TYPE "AppointmentStatus" ADD VALUE 'late_cancelled';

-- DropForeignKey
ALTER TABLE "LabOrder" DROP CONSTRAINT "LabOrder_supersedesLabOrderId_fkey";

-- DropForeignKey
ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_labOrderId_fkey";

-- DropForeignKey
ALTER TABLE "PaymentIntent" DROP CONSTRAINT "PaymentIntent_prescriptionId_fkey";

-- DropForeignKey
ALTER TABLE "Prescription" DROP CONSTRAINT "Prescription_supersedesPrescriptionId_fkey";

-- AlterTable
-- Existing profiles predate timezone capture; preserve them as UTC until
-- their owners can provide a local timezone.
ALTER TABLE "DoctorProfile" ADD COLUMN "timeZone" TEXT NOT NULL DEFAULT 'UTC';
ALTER TABLE "DoctorProfile" ALTER COLUMN "timeZone" DROP DEFAULT;

-- CreateTable
CREATE TABLE "WaitlistEntry" (
"id" TEXT NOT NULL,
"patientProfileId" TEXT NOT NULL,
"doctorProfileId" TEXT,
"specialtyId" TEXT,
"status" TEXT NOT NULL,
"earliestAcceptable" TIMESTAMP(3) NOT NULL,
"latestAcceptable" TIMESTAMP(3) NOT NULL,
"holdDurationMinutes" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"facilityCapabilityId" TEXT,
"availabilitySlotId" TEXT,

CONSTRAINT "WaitlistEntry_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "WindowTime" (
"id" TEXT NOT NULL,
"start" TIMESTAMP(3) NOT NULL,
"end" TIMESTAMP(3) NOT NULL,

CONSTRAINT "WindowTime_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "WaitlistEntry_status_idx" ON "WaitlistEntry"("status");

-- CreateIndex
CREATE INDEX "WaitlistEntry_patientProfileId_status_idx" ON "WaitlistEntry"("patientProfileId", "status");

-- CreateIndex
CREATE UNIQUE INDEX "WaitlistEntry_patientProfileId_doctorProfileId_specialtyId_key" ON "WaitlistEntry"("patientProfileId", "doctorProfileId", "specialtyId");

-- CreateIndex
CREATE UNIQUE INDEX "WindowTime_start_end_key" ON "WindowTime"("start", "end");

-- AddForeignKey
ALTER TABLE "Prescription" ADD CONSTRAINT "Prescription_supersedesPrescriptionId_fkey" FOREIGN KEY ("supersedesPrescriptionId") REFERENCES "Prescription"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "LabOrder" ADD CONSTRAINT "LabOrder_supersedesLabOrderId_fkey" FOREIGN KEY ("supersedesLabOrderId") REFERENCES "LabOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_patientProfileId_fkey" FOREIGN KEY ("patientProfileId") REFERENCES "PatientProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_doctorProfileId_fkey" FOREIGN KEY ("doctorProfileId") REFERENCES "DoctorProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_specialtyId_fkey" FOREIGN KEY ("specialtyId") REFERENCES "Specialty"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_facilityCapabilityId_fkey" FOREIGN KEY ("facilityCapabilityId") REFERENCES "FacilityCapability"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_availabilitySlotId_fkey" FOREIGN KEY ("availabilitySlotId") REFERENCES "AvailabilitySlot"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_prescriptionId_fkey" FOREIGN KEY ("prescriptionId") REFERENCES "Prescription"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "PaymentIntent" ADD CONSTRAINT "PaymentIntent_labOrderId_fkey" FOREIGN KEY ("labOrderId") REFERENCES "LabOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,3 @@ ALTER TABLE "User" ADD COLUMN "deletedAt" TIMESTAMP(3);

-- AddForeignKey
ALTER TABLE "PatientProfile" ADD CONSTRAINT "PatientProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

73 changes: 61 additions & 12 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ enum AppointmentStatus {
scheduled
completed
cancelled
late_cancelled
no_show
}

Expand Down Expand Up @@ -92,6 +93,13 @@ enum FulfillmentStatus {
rejected
}

enum WaitlistStatus {
pending
on_hold
accepted
expired
}

enum Urgency {
routine
urgent
Expand Down Expand Up @@ -128,21 +136,21 @@ model User {
// refresh-token revocation list (issue #18) — this is the narrow "revoke
// everything for one account" case that deactivation needs.
sessionsInvalidatedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Per-user refresh-token version. Embedded in every access/refresh token
// as `tv` and compared against this column at refresh time. Bumping it
// (password reset, Phase 8 #33) invalidates every outstanding refresh
// token for the user, since none of them carry the new value — the
// interim kill switch issue #33 asked for while #18 doesn't exist yet.
tokenVersion Int @default(0)
tokenVersion Int @default(0)
// Phase 8 (issue #41, docs/adr/0010): set once, irreversibly, when the
// account is deleted. The row is anonymised in place (email tombstoned,
// phone cleared, password unusable) rather than deleted, because clinical
// and payment records still reference its patient profile. Refresh is
// refused once set. Distinct from admin deactivation (#39), which is
// reversible.
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?

patientProfile PatientProfile?
doctorProfile DoctorProfile?
Expand Down Expand Up @@ -409,6 +417,7 @@ model DoctorProfile {
userId String @unique
fullName String
verificationStatus VerificationStatus @default(pending)
timeZone String // IANA time zone identifier, e.g. "Europe/London", "America/New_York"
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Expand All @@ -418,6 +427,7 @@ model DoctorProfile {
appointments Appointment[]
prescriptions Prescription[]
labOrders LabOrder[]
waitlistEntries WaitlistEntry[]
// Referral sides a doctor can sit on: `referringReferrals` are ones
// they created; `targetedReferrals` are ones aimed at them (whether
// they ultimately see the patient or not).
Expand All @@ -432,7 +442,8 @@ model Specialty {
name String @unique
slug String @unique

doctors DoctorSpecialty[]
doctors DoctorSpecialty[]
waitlistEntries WaitlistEntry[]
}

model DoctorSpecialty {
Expand Down Expand Up @@ -465,7 +476,8 @@ model FacilityProfile {
}

model FacilityCapability {
id String @id @default(uuid())
waitlistEntries WaitlistEntry[]
id String @id @default(uuid())
facilityProfileId String
capabilityType CapabilityType
// e.g. a lab test category, or a medication class — free text on purpose,
Expand All @@ -486,7 +498,7 @@ model FacilityCapability {
/// medication code the facility publishes), matching FacilityCapability.detail
/// — no controlled vocabulary has been decided yet.
model FacilityPrice {
id String @id @default(uuid())
id String @id @default(uuid())
facilityId String
facility FacilityProfile @relation(fields: [facilityId], references: [id], onDelete: Cascade)
catalogItemCode String
Expand All @@ -496,7 +508,7 @@ model FacilityPrice {
asset String
amount BigInt
effectiveFrom DateTime
createdAt DateTime @default(now())
createdAt DateTime @default(now())

// Two versions of the same item/asset cannot share an effective instant.
@@unique([facilityId, catalogItemCode, asset, effectiveFrom])
Expand All @@ -505,13 +517,14 @@ model FacilityPrice {
}

model AvailabilitySlot {
id String @id @default(uuid())
id String @id @default(uuid())
waitlistEntries WaitlistEntry[]
doctorProfileId String
status SlotStatus @default(open)
status SlotStatus @default(open)
startTime DateTime
endTime DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

doctorProfile DoctorProfile @relation(fields: [doctorProfileId], references: [id], onDelete: Cascade)
appointment Appointment?
Expand Down Expand Up @@ -803,6 +816,42 @@ model FulfillmentRecord {
// into PrescriptionFulfillment / LabOrderFulfillment before implementation.
}

model WaitlistEntry {
id String @id @default(uuid())
patientProfileId String
doctorProfileId String?
specialtyId String?
status String // pending | on_hold | accepted | expired
// Time window during which the patient is willing to accept a slot
earliestAcceptable DateTime
latestAcceptable DateTime
// Time-boxed hold: when a slot is offered, it's held for this duration
holdDurationMinutes Int
// Audit trail
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id], onDelete: Cascade)
doctorProfile DoctorProfile? @relation(fields: [doctorProfileId], references: [id], onDelete: SetNull)
specialty Specialty? @relation(fields: [specialtyId], references: [id], onDelete: SetNull)
facilityCapability FacilityCapability? @relation(fields: [facilityCapabilityId], references: [id])
facilityCapabilityId String?
availabilitySlot AvailabilitySlot? @relation(fields: [availabilitySlotId], references: [id])
availabilitySlotId String?

@@unique([patientProfileId, doctorProfileId, specialtyId])
@@index([status])
@@index([patientProfileId, status])
}

model WindowTime {
id String @id @default(uuid())
start DateTime
end DateTime

@@unique([start, end])
}

model Notification {
id String @id @default(uuid())
type String
Expand Down
Loading
Loading