diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6e314ec8..25cf1fe7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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) @@ -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 diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 47a62b66..ddeb35aa 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -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. diff --git a/backend/package-lock.json b/backend/package-lock.json index 8ff289a0..5373f504 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,6 +15,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", @@ -3090,6 +3091,26 @@ "node": ">=12" } }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/backend/package.json b/backend/package.json index 837065d4..0e610a7c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/prisma/migrations/20261015175723_add_password_reset_tokens/migration.sql b/backend/prisma/migrations/20261015175723_add_password_reset_tokens/migration.sql index 77f2c836..094bc27a 100644 --- a/backend/prisma/migrations/20261015175723_add_password_reset_tokens/migration.sql +++ b/backend/prisma/migrations/20261015175723_add_password_reset_tokens/migration.sql @@ -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; - diff --git a/backend/prisma/migrations/20261020145534_waitlist_and_doctor_timezone/migration.sql b/backend/prisma/migrations/20261020145534_waitlist_and_doctor_timezone/migration.sql new file mode 100644 index 00000000..2616a8dd --- /dev/null +++ b/backend/prisma/migrations/20261020145534_waitlist_and_doctor_timezone/migration.sql @@ -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; diff --git a/backend/prisma/migrations/20261021120000_account_deletion_retention/migration.sql b/backend/prisma/migrations/20261021120000_account_deletion_retention/migration.sql index 9817bfb1..5fddc42e 100644 --- a/backend/prisma/migrations/20261021120000_account_deletion_retention/migration.sql +++ b/backend/prisma/migrations/20261021120000_account_deletion_retention/migration.sql @@ -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; - diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9859f19f..4a65d18a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -52,6 +52,7 @@ enum AppointmentStatus { scheduled completed cancelled + late_cancelled no_show } @@ -92,6 +93,13 @@ enum FulfillmentStatus { rejected } +enum WaitlistStatus { + pending + on_hold + accepted + expired +} + enum Urgency { routine urgent @@ -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? @@ -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 @@ -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). @@ -432,7 +442,8 @@ model Specialty { name String @unique slug String @unique - doctors DoctorSpecialty[] + doctors DoctorSpecialty[] + waitlistEntries WaitlistEntry[] } model DoctorSpecialty { @@ -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, @@ -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 @@ -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]) @@ -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? @@ -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 diff --git a/backend/src/modules/appointments/controllers/index.ts b/backend/src/modules/appointments/controllers/index.ts index 6a0004dc..b97d67e9 100644 --- a/backend/src/modules/appointments/controllers/index.ts +++ b/backend/src/modules/appointments/controllers/index.ts @@ -19,6 +19,58 @@ export async function listMine(req: Request, res: Response) { res.status(200).json(appointments); } +export async function reschedule(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { newSlotId } = rescheduleSlotBodySchema.parse(req.body); + const appointmentId = req.params.id as string; + const appointment = await appointmentsService.rescheduleSlot(auditActor(req), appointmentId, newSlotId); + res.status(200).json(appointment); +} + +export async function cancel(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const appointmentId = req.params.id as string; + const appointment = await appointmentsService.cancelAppointment(auditActor(req), appointmentId); + res.status(200).json(appointment); +} + +export async function joinWaitlist(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { doctorProfileId, specialtyId, earliestAcceptable, latestAcceptable, holdDurationMinutes } = waitlistJoinSchema.parse(req.body); + // Convert datetime strings to Date objects (or use defaults) + const earliest = earliestAcceptable ? new Date(earliestAcceptable) : undefined; + const latest = latestAcceptable ? new Date(latestAcceptable) : undefined; + const patientProfile = await patientsService.getMyPatientProfile(req.user.id); + const entry = await appointmentsService.joinWaitlist(auditActor(req), patientProfile.id, doctorProfileId, specialtyId, earliest, latest, holdDurationMinutes); + res.status(201).json(entry); +} + +export async function leaveWaitlist(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { entryId: entryIdParam } = leaveWaitlistSchema.parse(req.body); + // Try to get entryId from params if not in body + const entryId = entryIdParam || (req.params.id as string); + if (!entryId) { + throw new HttpError(400, 'entryId is required.'); + } + const result = await appointmentsService.leaveWaitlist(auditActor(req), entryId); + res.status(200).json(result); +} + +export async function listWaitlist(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const patientProfile = await patientsService.getMyPatientProfile(req.user.id); + const entries = await appointmentsService.listWaitlistEntries(patientProfile.id); + res.status(200).json(entries); +} + +export async function acceptWaitlistOffer(req: Request, res: Response) { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + const { entryId } = acceptWaitlistSchema.parse(req.body); + const appointment = await appointmentsService.acceptWaitlistOffer(auditActor(req), entryId); + res.status(201).json(appointment); +} + export async function listDoctorToday(req: Request, res: Response) { if (!req.user) throw new HttpError(401, 'Authentication required.'); const appointments = await appointmentsService.listDoctorAppointmentsToday(req.user.id); diff --git a/backend/src/modules/appointments/repositories/index.ts b/backend/src/modules/appointments/repositories/index.ts index 7f7316af..97b30507 100644 --- a/backend/src/modules/appointments/repositories/index.ts +++ b/backend/src/modules/appointments/repositories/index.ts @@ -16,6 +16,30 @@ export function createAppointment( return db.appointment.create({ data }); } +/** Find a single appointment by ID (used inside transactions). */ +export function findAppointmentById(db: DbClient, id: string) { + return db.appointment.findUnique({ where: { id } }); +} + +/** Update an appointment (used inside transactions). */ +export async function updateAppointment( + db: DbClient, + id: string, + data: { availabilitySlotId?: string; status?: AppointmentStatus }, +) { + return db.appointment.update({ where: { id }, data }); +} + +/** Update an appointment's availabilitySlotId (used inside transactions). */ +export async function updateAvailabilitySlotId( + db: DbClient, + id: string, + data: { availabilitySlotId: string }, +) { + return db.appointment.update({ where: { id }, data }); +} + + /** One keyset page of a patient's appointments, oldest first (patient data * export, issue #40). Appointment columns only — slot/doctor details come * from those modules' services, not a join. */ @@ -56,10 +80,6 @@ export function findAppointmentDoctorContext(id: string) { return prisma.appointment.findUnique({ where: { id }, include: { doctorProfile: { select: { id: true, userId: true, verificationStatus: true } } } }); } -export function findAppointmentById(db: DbClient, id: string) { - return db.appointment.findUnique({ where: { id } }); -} - /** * The appointment state machine's only write path: a conditional UPDATE * that matches `status = 'scheduled'`. Exactly one concurrent caller can diff --git a/backend/src/modules/appointments/routes/index.ts b/backend/src/modules/appointments/routes/index.ts index dac47c1f..89627369 100644 --- a/backend/src/modules/appointments/routes/index.ts +++ b/backend/src/modules/appointments/routes/index.ts @@ -17,6 +17,51 @@ appointmentsRouter.post( appointmentsRouter.get('/mine', authenticate, requireRole('patient'), appointmentsController.listMine); appointmentsRouter.get('/doctor/today', authenticate, requireRole('doctor'), appointmentsController.listDoctorToday); +// Reschedule an appointment atomically: claim new slot and release old one +// (Phase 2, issue #55). Requires verified email. +appointmentsRouter.post( + '/:id/reschedule', + authenticate, + requireRole('patient'), + requireVerifiedEmail, + appointmentsController.reschedule, +); +// Cancel an appointment (Phase 2, issue #56). Requires verified email. +appointmentsRouter.post( + '/:id/cancel', + authenticate, + requireRole('patient'), + requireVerifiedEmail, + appointmentsController.cancel, +); +// Waitlist operations (Phase 2, issue #57): join, leave, list, accept offer +appointmentsRouter.post( + '/waitlist/join', + authenticate, + requireRole('patient'), + requireVerifiedEmail, + appointmentsController.joinWaitlist, +); +appointmentsRouter.post( + '/waitlist/leave', + authenticate, + requireRole('patient'), + requireVerifiedEmail, + appointmentsController.leaveWaitlist, +); +appointmentsRouter.get( + '/waitlist/list', + authenticate, + requireRole('patient'), + appointmentsController.listWaitlist, +); +appointmentsRouter.post( + '/waitlist/accept', + authenticate, + requireRole('patient'), + requireVerifiedEmail, + appointmentsController.acceptWaitlistOffer, +); // Lifecycle (issue #3 + the cascade trigger for issue #67): only the // treating doctor moves an appointment through completed/cancelled/ // no_show; the patient may cancel their own — nothing else. diff --git a/backend/src/modules/appointments/services/index.ts b/backend/src/modules/appointments/services/index.ts index 7b788880..1587e1c4 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -83,6 +83,345 @@ export async function listMyAppointments(userId: string, patientProfileId?: stri return appointmentsRepo.findAppointmentsForPatient(patientProfile.id); } +/** + * Reschedules an appointment atomically: in one transaction, claims the new + * slot and releases the old one. The new slot must belong to the same + * doctor (or be explicitly allowed). + * + * Requirements from issue #55:\n - Add `POST /appointments/:id/reschedule` with `{ newSlotId }`\n - In one transaction: conditionally claim the new slot (`WHERE status = 'open'`), + * re-point the appointment, and release the old slot back to `open`\n - Respect a configurable cutoff (e.g. no reschedule within 2h of start)\n - Concurrency tests: two patients rescheduling into the same slot, and a + * reschedule racing a fresh booking\n */ +export async function rescheduleSlot( + actor: auditService.AuditActor, + appointmentId: string, + newSlotId: string, + cutoffHours: number = 2, +) { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + const patientProfile = await patientsService.getMyPatientProfile(actor.userId); + + return prisma.$transaction(async (tx) => { + // First, get the existing appointment to find its current slot + const existingAppointment = await appointmentsRepo.findAppointmentById(tx, appointmentId); + if (!existingAppointment) { + throw new HttpError(404, 'Appointment not found.'); + } + + // Check that the appointment belongs to this patient + if (existingAppointment.patientProfileId !== patientProfile.id) { + throw new HttpError(403, 'This appointment does not belong to you.'); + } + const currentSlotId = existingAppointment.availabilitySlotId; + if (!currentSlotId) { + throw new HttpError(409, 'Appointment no longer has an active slot.'); + } + + // Check cutoff: no reschedule within cutoffHours of slot start + const slot = await availabilityService.getSlot(tx, currentSlotId); + if (!slot) { + throw new HttpError(404, 'Original slot not found.'); + } + + const slotStart = slot.startTime; + const now = new Date(); + const cutoffMs = cutoffHours * 60 * 60 * 1000; + if (now.getTime() - slotStart.getTime() < -cutoffMs) { + // Slot hasn't started yet and we're within the cutoff window + throw new HttpError(409, `Rescheduling is not allowed within ${cutoffHours} hours of the slot start time.`); + } + + // Claim the new slot atomically + const newClaimed = await availabilityService.claimOpenSlot(tx, newSlotId); + if (!newClaimed) { + throw new HttpError(409, 'The new slot is no longer available.'); + } + + // Release the old slot atomically (only if it's still booked by this appointment) + await tx.availabilitySlot.updateMany({ + where: { id: currentSlotId, status: 'booked' }, + data: { status: 'open' }, + }); + + // Re-point the appointment to the new slot + const updatedAppointment = await appointmentsRepo.updateAppointment(tx, appointmentId, { + availabilitySlotId: newSlotId, + }); + + await auditService.record(tx, actor, { + entityType: 'Appointment', + entityId: updatedAppointment.id, + action: 'appointment.rescheduled', + fromState: existingAppointment.status, + toState: updatedAppointment.status, + metadata: { + oldSlotId: currentSlotId, + newSlotId, + }, + }); + + return updatedAppointment; + }); +} + +/** + * Cancels an appointment atomically: releases the slot back to `open` and + * marks the appointment as cancelled. Includes cutoff window logic. + * + * Requirements from issue #56:\n - Patient can cancel an appointment\n - Before cutoff (2h before slot start): slot releases to `open`\n - After cutoff: slot stays booked, appointment marked `late_cancelled`\n - Concurrency: slot release is conditional on status = `booked`\n */ +export async function cancelAppointment( + actor: auditService.AuditActor, + appointmentId: string, + reason: 'patient' | 'doctor' = 'patient', +) { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + const patientProfile = await patientsService.getMyPatientProfile(actor.userId); + + return prisma.$transaction(async (tx) => { + // First, get the existing appointment to find its current slot + const existingAppointment = await appointmentsRepo.findAppointmentById(tx, appointmentId); + if (!existingAppointment) { + throw new HttpError(404, 'Appointment not found.'); + } + + // Check that the caller is the patient + if (existingAppointment.patientProfileId !== patientProfile.id) { + throw new HttpError(403, 'Only the patient can cancel this appointment.'); + } + const currentSlotId = existingAppointment.availabilitySlotId; + if (!currentSlotId) { + throw new HttpError(409, 'Appointment no longer has an active slot.'); + } + + // Check cutoff: no cancellation within 2 hours of slot start + const slot = await availabilityService.getSlot(tx, currentSlotId); + if (!slot) { + throw new HttpError(404, 'Original slot not found.'); + } + + const slotStart = slot.startTime; + const now = new Date(); + const cutoffMs = 2 * 60 * 60 * 1000; // 2 hours in milliseconds + + // Determine the new status based on cutoff + let newStatus: string; + let slotStatus: string; + + if (now.getTime() - slotStart.getTime() < -cutoffMs) { + // Slot hasn't started yet and we're within the cutoff window + newStatus = 'cancelled'; + slotStatus = 'open'; + } else { + // Past cutoff: slot stays booked, appointment marked late-cancelled + newStatus = 'late_cancelled'; + slotStatus = 'booked'; + } + + // Update the appointment status + const updatedAppointment = await appointmentsRepo.updateAppointment(tx, appointmentId, { + status: newStatus as any, + }); + + // Release the old slot only if within cutoff window + if (slotStatus === 'open') { + await tx.availabilitySlot.updateMany({ + where: { id: currentSlotId, status: 'booked' }, + data: { status: 'open' }, + }); + } + + await auditService.record(tx, actor, { + entityType: 'Appointment', + entityId: updatedAppointment.id, + action: 'appointment.cancelled', + fromState: existingAppointment.status, + toState: newStatus, + metadata: { + reason, + slotId: currentSlotId, + }, + }); + + return updatedAppointment; + }); +} + +/** + * Waitlist management (Phase 2, issue #57): + - Patient joins waitlist for a doctor/specialty with a time window + - Patient leaves waitlist + - Patient lists their waitlist entries + - System offers slots to waitlist entries when slots become open + - Patient accepts offer, which converts the hold to a booking in one transaction + */ + +export async function joinWaitlist( + actor: auditService.AuditActor, + patientProfileId: string, + doctorProfileId: string | undefined, + specialtyId: string | undefined, + earliestAcceptable: Date | undefined, + latestAcceptable: Date | undefined, + holdDurationMinutes: number | undefined, +) { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + + return prisma.$transaction(async (tx) => { + // Check that the patient profile belongs to the actor + const patientProfile = await tx.patientProfile.findUnique({ + where: { id: patientProfileId }, + }); + if (!patientProfile) { + throw new HttpError(404, 'Patient profile not found.'); + } + + // Check that the patient is not already on waitlist for this doctor/specialty combo + const where: any = { + patientProfileId, + status: 'pending', + }; + if (doctorProfileId) where.doctorProfileId = doctorProfileId; + if (specialtyId) where.specialtyId = specialtyId; + + const existingEntry = await tx.waitlistEntry.findFirst({ + where, + }); + + if (existingEntry) { + throw new HttpError(409, 'You are already on the waitlist for this doctor/specialty.'); + } + + // Create the waitlist entry + const entry = await tx.waitlistEntry.create({ + data: { + patientProfileId, + doctorProfileId, + specialtyId, + status: 'pending', + earliestAcceptable: earliestAcceptable ?? new Date(), + latestAcceptable: latestAcceptable ?? new Date(), + holdDurationMinutes: holdDurationMinutes ?? 30, // default 30 min hold + }, + }); + + return entry; + }); +} + +/** + * Patient leaves the waitlist + */ +export async function leaveWaitlist( + actor: auditService.AuditActor, + entryId: string, +) { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + + const entry = await prisma.waitlistEntry.findUnique({ + where: { id: entryId }, + }); + if (!entry) { + throw new HttpError(404, 'Waitlist entry not found.'); + } + + // Verify the entry belongs to this patient + if (entry.patientProfileId !== actor.userId) { + throw new HttpError(403, 'This waitlist entry does not belong to you.'); + } + + await prisma.waitlistEntry.update({ + where: { id: entryId }, + data: { status: 'expired' }, + }); + + return { cancelled: true }; +} + +/** + * List all waitlist entries for a patient + */ +export async function listWaitlistEntries( + patientProfileId: string, +) { + return prisma.waitlistEntry.findMany({ + where: { patientProfileId }, + orderBy: { createdAt: 'desc' }, + }); +} + +/** + * Accept a waitlist offer: converts the hold to a booking in one transaction + */ +export async function acceptWaitlistOffer( + actor: auditService.AuditActor, + entryId: string, +) { + if (!actor.userId) throw new HttpError(401, 'Authentication required.'); + + return prisma.$transaction(async (tx) => { + const entry = await prisma.waitlistEntry.findUnique({ + where: { id: entryId }, + }); + if (!entry) { + throw new HttpError(404, 'Waitlist entry not found.'); + } + if (entry.status !== 'pending') { + throw new HttpError(409, 'This waitlist entry is no longer active.'); + } + + // Get open slots for the doctor directly (no need to find first) + if (!entry.doctorProfileId) { + throw new HttpError(404, 'No doctor profile specified for waitlist entry.'); + } + + const openSlots = await availabilityService.listOpenSlots(entry.doctorProfileId); + if (!openSlots.length) { + throw new HttpError(404, 'No open slots available for this doctor.'); + } + + // Claim the first open slot + const claimed = await availabilityService.claimOpenSlot(tx, openSlots[0].id); + if (!claimed) { + throw new HttpError(409, 'The slot was taken before we could claim it.'); + } + + // Create the appointment + const patientProfile = await tx.patientProfile.findUnique({ + where: { id: entry.patientProfileId }, + }); + + if (!patientProfile) { + throw new HttpError(404, 'Patient profile not found.'); + } + + const appointment = await appointmentsRepo.createAppointment(tx, { + patientProfileId: patientProfile.id, + doctorProfileId: entry.doctorProfileId, + availabilitySlotId: openSlots[0].id, + }); + + // Mark the waitlist entry as accepted + await tx.waitlistEntry.update({ + where: { id: entryId }, + data: { status: 'accepted' }, + }); + + await auditService.record(tx, actor, { + entityType: 'Appointment', + entityId: appointment.id, + action: 'appointment.booked', + fromState: null, + toState: appointment.status, + metadata: { + waitlistEntryId: entry.id, + wasWaitlist: true, + doctorProfileId: entry.doctorProfileId, + }, + }); + + return appointment; + }); +} + /** * Cross-module entry point for the consultations module: return the * appointment a doctor may act on, or null if the appointment doesn't exist diff --git a/backend/src/modules/appointments/state-machine.ts b/backend/src/modules/appointments/state-machine.ts index a75d9029..0af1e5c6 100644 --- a/backend/src/modules/appointments/state-machine.ts +++ b/backend/src/modules/appointments/state-machine.ts @@ -1,9 +1,10 @@ import type { AppointmentStatus } from '@prisma/client'; const transitions: Record = { - scheduled: ['completed', 'cancelled', 'no_show'], + scheduled: ['completed', 'cancelled', 'late_cancelled', 'no_show'], completed: [], cancelled: [], + late_cancelled: [], no_show: [], }; diff --git a/backend/src/modules/appointments/validation.ts b/backend/src/modules/appointments/validation.ts index 8a730943..4e4a015d 100644 --- a/backend/src/modules/appointments/validation.ts +++ b/backend/src/modules/appointments/validation.ts @@ -13,6 +13,39 @@ export const listMineQuerySchema = z.object({ patientProfileId: z.string().min(1).optional(), }); +export const rescheduleSlotSchema = z.object({ + newSlotId: z.string().min(1), +}); + +// Schema for reschedule request body +export const rescheduleSlotBodySchema = z.object({ + newSlotId: z.string().min(1), +}); + +// Schema for cancellation request body +export const cancelAppointmentSchema = z.object({ + reason: z.enum(['patient', 'doctor']).optional(), +}); + +// Schema for joining the waitlist +export const waitlistJoinSchema = z.object({ + doctorProfileId: z.string().optional(), + specialtyId: z.string().optional(), + earliestAcceptable: z.string().datetime(), + latestAcceptable: z.string().datetime(), + holdDurationMinutes: z.number().int().positive().optional(), +}); + +// Schema for leaving the waitlist +export const leaveWaitlistSchema = z.object({ + entryId: z.string().optional(), +}); + +// Schema for accepting a waitlist offer +export const acceptWaitlistSchema = z.object({ + entryId: z.string(), +}); + export const updateStatusSchema = z.object({ status: z.enum(['completed', 'cancelled', 'no_show']), }); diff --git a/backend/src/modules/auth/repositories/index.ts b/backend/src/modules/auth/repositories/index.ts index d9770280..494a0aed 100644 --- a/backend/src/modules/auth/repositories/index.ts +++ b/backend/src/modules/auth/repositories/index.ts @@ -88,7 +88,7 @@ export function createSelfServeUser(input: CreateSelfServeUserInput) { }); } else { await tx.doctorProfile.create({ - data: { userId: user.id, fullName: input.fullName }, + data: { userId: user.id, fullName: input.fullName, timeZone: 'UTC' }, }); } diff --git a/backend/src/modules/availability/services/index.ts b/backend/src/modules/availability/services/index.ts index 9d79928a..e1fbe691 100644 --- a/backend/src/modules/availability/services/index.ts +++ b/backend/src/modules/availability/services/index.ts @@ -9,28 +9,41 @@ export function listOpenSlots(doctorProfileId: string) { export interface GenerateSlotsInput { doctorProfileId: string; - /** ISO date, e.g. "2026-09-20" — single day only, no recurrence + /** ISO date, e.g. "2026-10-19" — single day only, no recurrence * (ARCHITECTURE.md §6.3.6 overengineering guardrail: fixed-length slot * generation only, no recurrence/buffer engine). */ date: string; - startHour: number; // 0-23, UTC - endHour: number; // 0-23, UTC, exclusive + /** IANA time zone identifier, e.g. "Europe/London", "America/New_York" + * The doctor's local wall-clock hours are interpreted in this zone. + * If omitted, defaults to UTC (backward-compatible). */ + timeZone?: string; + startHour: number; // 0-23, local wall-clock + endHour: number; // 0-23, local wall-clock, exclusive slotLengthMinutes: number; } export async function generateSlots(input: GenerateSlotsInput) { - const { doctorProfileId, date, startHour, endHour } = input; + const { doctorProfileId } = input; + let proposedSlots: ReturnType; try { - buildSlotRanges(input); + proposedSlots = buildSlotRanges(input); } catch (error) { throw new HttpError(400, error instanceof Error ? error.message : 'Invalid slot-generation input.'); } - const dayStart = new Date(`${date}T00:00:00.000Z`); - const rangeStart = new Date(dayStart.getTime() + startHour * 60 * 60 * 1_000); - const rangeEnd = new Date(dayStart.getTime() + endHour * 60 * 60 * 1_000); - const existing = await availabilityRepo.findExistingSlotRanges(doctorProfileId, rangeStart, rangeEnd); + if (proposedSlots.length === 0) return { created: 0 }; + + const existing = await availabilityRepo.findExistingSlotRanges( + doctorProfileId, + proposedSlots[0].startTime, + proposedSlots[proposedSlots.length - 1].endTime, + ); const existingStarts = existing.map((slot) => slot.startTime.getTime()); - const rows = buildSlotRanges(input, existingStarts); + let rows: ReturnType; + try { + rows = buildSlotRanges(input, existingStarts); + } catch (error) { + throw new HttpError(400, error instanceof Error ? error.message : 'Invalid slot-generation input.'); + } if (rows.length === 0) return { created: 0 }; diff --git a/backend/src/modules/availability/slot-generation.ts b/backend/src/modules/availability/slot-generation.ts index 6ed26116..08a720a2 100644 --- a/backend/src/modules/availability/slot-generation.ts +++ b/backend/src/modules/availability/slot-generation.ts @@ -1,9 +1,12 @@ +import { fromZonedTime } from 'date-fns-tz'; + export interface SlotGenerationInput { doctorProfileId: string; date: string; startHour: number; endHour: number; slotLengthMinutes: number; + timeZone?: string; } export interface GeneratedSlotRange { @@ -17,7 +20,7 @@ export function buildSlotRanges( input: SlotGenerationInput, existingStartTimes: readonly number[] = [], ): GeneratedSlotRange[] { - const { doctorProfileId, date, startHour, endHour, slotLengthMinutes } = input; + const { doctorProfileId, date, startHour, endHour, slotLengthMinutes, timeZone = 'UTC' } = input; if (!Number.isInteger(slotLengthMinutes) || slotLengthMinutes <= 0) { throw new Error('slotLengthMinutes must be a positive integer.'); } @@ -26,13 +29,22 @@ export function buildSlotRanges( throw new Error('startHour must be before endHour, both within 0-24.'); } - const dayStart = new Date(`${date}T00:00:00.000Z`); - if (Number.isNaN(dayStart.getTime()) || dayStart.toISOString().slice(0, 10) !== date) { + const dateStart = new Date(`${date}T00:00:00.000Z`); + if (Number.isNaN(dateStart.getTime()) || dateStart.toISOString().slice(0, 10) !== date) { throw new Error('date must be a valid ISO date.'); } - const rangeStart = dayStart.getTime() + startHour * 60 * 60 * 1_000; - const rangeEnd = dayStart.getTime() + endHour * 60 * 60 * 1_000; + const localDateTime = (hour: number) => { + const localDate = new Date(dateStart.getTime() + hour * 60 * 60 * 1_000); + const datePart = localDate.toISOString().slice(0, 10); + const hourPart = String(localDate.getUTCHours()).padStart(2, '0'); + return `${datePart}T${hourPart}:00:00`; + }; + const rangeStart = fromZonedTime(localDateTime(startHour), timeZone).getTime(); + const rangeEnd = fromZonedTime(localDateTime(endHour), timeZone).getTime(); + if (!Number.isFinite(rangeStart) || !Number.isFinite(rangeEnd) || rangeStart >= rangeEnd) { + throw new Error('date and timeZone must produce a valid slot range.'); + } const existing = new Set(existingStartTimes); const stepMs = slotLengthMinutes * 60 * 1_000; const slots: GeneratedSlotRange[] = []; diff --git a/backend/tests/integration/facility-prices.test.mts b/backend/tests/integration/facility-prices.test.mts index 5e2f1f18..74be9f6d 100644 --- a/backend/tests/integration/facility-prices.test.mts +++ b/backend/tests/integration/facility-prices.test.mts @@ -102,7 +102,7 @@ describe('facility price lists and intent pricing (issue #127)', () => { }); doctorActor = { userId: doctorUser.id, role: 'doctor', requestId: null }; const doctor = await prisma.doctorProfile.create({ - data: { userId: doctorUser.id, fullName: 'Prices Test Doctor', verificationStatus: 'verified' }, + data: { userId: doctorUser.id, fullName: 'Prices Test Doctor', timeZone: 'UTC', verificationStatus: 'verified' }, }); doctorProfileId = doctor.id; diff --git a/backend/tests/integration/order-fixtures.mts b/backend/tests/integration/order-fixtures.mts index 44e7d8f1..cf828119 100644 --- a/backend/tests/integration/order-fixtures.mts +++ b/backend/tests/integration/order-fixtures.mts @@ -5,7 +5,7 @@ export async function createOrderFixture( input: { patientProfileId: string; facilityProfileId: string; kind: 'prescription' | 'lab_order'; amountStroops: bigint; tag: string }, ) { const doctorUser = await prisma.user.create({ data: { email: `order-doctor-${input.tag}@example.com`, passwordHash: 'unused', role: 'doctor' } }); - const doctor = await prisma.doctorProfile.create({ data: { userId: doctorUser.id, fullName: 'Order Test Doctor', verificationStatus: 'verified' } }); + const doctor = await prisma.doctorProfile.create({ data: { userId: doctorUser.id, fullName: 'Order Test Doctor', timeZone: 'UTC', verificationStatus: 'verified' } }); const startTime = new Date(Date.now() + 60 * 60 * 1000); const slot = await prisma.availabilitySlot.create({ data: { doctorProfileId: doctor.id, startTime, endTime: new Date(startTime.getTime() + 30 * 60 * 1000), diff --git a/backend/tests/unit/state-machines.property.test.ts b/backend/tests/unit/state-machines.property.test.ts index 87cf3540..ec40b4c1 100644 --- a/backend/tests/unit/state-machines.property.test.ts +++ b/backend/tests/unit/state-machines.property.test.ts @@ -10,17 +10,18 @@ const numRuns = Number(process.env.PROPERTY_TEST_RUNS ?? 300); describe('lifecycle state-machine properties', () => { it('never moves an appointment out of its terminal state', () => { fc.assert(fc.property( - fc.array(fc.constantFrom('scheduled', 'completed', 'cancelled', 'no_show'), { maxLength: 100 }), + fc.array(fc.constantFrom('scheduled', 'completed', 'cancelled', 'late_cancelled', 'no_show'), { maxLength: 100 }), (operations) => { let state: AppointmentStatus = 'scheduled'; for (const requested of operations) { const next = transitionAppointmentStatus(state, requested); if (next !== null) state = next; - expect(['scheduled', 'completed', 'cancelled', 'no_show']).toContain(state); + expect(['scheduled', 'completed', 'cancelled', 'late_cancelled', 'no_show']).toContain(state); if (state !== 'scheduled') { expect(transitionAppointmentStatus(state, 'scheduled')).toBeNull(); expect(transitionAppointmentStatus(state, 'completed')).toBeNull(); expect(transitionAppointmentStatus(state, 'cancelled')).toBeNull(); + expect(transitionAppointmentStatus(state, 'late_cancelled')).toBeNull(); expect(transitionAppointmentStatus(state, 'no_show')).toBeNull(); } }