From 84bcaecb4649565eeb1ebb46d6eb02614bc4cf64 Mon Sep 17 00:00:00 2001 From: Oluwafemi Ajibade Date: Mon, 19 Oct 2026 18:21:16 +0000 Subject: [PATCH 1/3] Implement cancellation policy (issue #56), waitlist with time-boxed offers (issue #55), and reschedule (issue #54) - Add late_cancelled status to AppointmentStatus enum - Implement cancelAppointment with 2-hour cutoff logic - Implement rescheduleSlot atomically with new slot claiming and old slot release - Implement waitlist join/leave/list/accept functionality - Add WaitlistEntry and WindowTime models to Prisma schema - Add route handlers for all new endpoints - Add validation schemas for all new requests - Fix auth repository to include timeZone field --- backend/package-lock.json | 21 ++ backend/package.json | 1 + backend/prisma/schema.prisma | 46 ++- .../modules/appointments/controllers/index.ts | 55 ++- .../appointments/repositories/index.ts | 26 +- .../src/modules/appointments/routes/index.ts | 46 +++ .../modules/appointments/services/index.ts | 330 ++++++++++++++++++ .../src/modules/appointments/validation.ts | 33 ++ .../src/modules/auth/repositories/index.ts | 2 +- .../modules/availability/services/index.ts | 23 +- 10 files changed, 573 insertions(+), 10 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index ba02def9..de654db2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,6 +14,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", "jsonwebtoken": "^9.0.3", @@ -3020,6 +3021,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 0ebd6bc7..d487f8f9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,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", "jsonwebtoken": "^9.0.3", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 7cfb28b6..c2fcf630 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -52,6 +52,7 @@ enum AppointmentStatus { scheduled completed cancelled + late_cancelled no_show } @@ -88,6 +89,13 @@ enum FulfillmentStatus { rejected } +enum WaitlistStatus { + pending + on_hold + accepted + expired +} + enum Urgency { routine urgent @@ -184,6 +192,7 @@ model PatientProfile { appointments Appointment[] paymentIntents PaymentIntent[] dataExports PatientExport[] + waitlistEntries WaitlistEntry[] } // Phase 8 (issue #40): right-of-access export jobs. One row per request; @@ -258,6 +267,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 @@ -265,6 +275,7 @@ model DoctorProfile { specialties DoctorSpecialty[] availabilitySlots AvailabilitySlot[] appointments Appointment[] + waitlistEntries WaitlistEntry[] @@index([verificationStatus]) } @@ -274,7 +285,8 @@ model Specialty { name String @unique slug String @unique - doctors DoctorSpecialty[] + doctors DoctorSpecialty[] + waitlistEntries WaitlistEntry[] } model DoctorSpecialty { @@ -435,6 +447,38 @@ 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) + + @@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 f07f9f7c..58ec94db 100644 --- a/backend/src/modules/appointments/controllers/index.ts +++ b/backend/src/modules/appointments/controllers/index.ts @@ -1,6 +1,7 @@ import type { Request, Response } from 'express'; import * as appointmentsService from '../services'; -import { bookSlotSchema } from '../validation'; +import * as patientsService from '../../patients/services'; +import { bookSlotSchema, rescheduleSlotBodySchema, waitlistJoinSchema, leaveWaitlistSchema, acceptWaitlistSchema } from '../validation'; import { HttpError } from '../../../http-error'; import { auditActor } from '../../../middleware/request-id'; @@ -16,3 +17,55 @@ export async function listMine(req: Request, res: Response) { const appointments = await appointmentsService.listMyAppointments(req.user.id); 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); +} diff --git a/backend/src/modules/appointments/repositories/index.ts b/backend/src/modules/appointments/repositories/index.ts index 4f2be7f8..507d620f 100644 --- a/backend/src/modules/appointments/repositories/index.ts +++ b/backend/src/modules/appointments/repositories/index.ts @@ -1,5 +1,5 @@ import { prisma } from '../../../db/prisma-client'; -import type { Prisma } from '@prisma/client'; +import type { Prisma, AppointmentStatus } from '@prisma/client'; type DbClient = typeof prisma | Prisma.TransactionClient; @@ -10,6 +10,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. */ diff --git a/backend/src/modules/appointments/routes/index.ts b/backend/src/modules/appointments/routes/index.ts index 04ecd694..34305ebd 100644 --- a/backend/src/modules/appointments/routes/index.ts +++ b/backend/src/modules/appointments/routes/index.ts @@ -15,3 +15,49 @@ appointmentsRouter.post( appointmentsController.book, ); appointmentsRouter.get('/mine', authenticate, requireRole('patient'), appointmentsController.listMine); + +// 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, +); diff --git a/backend/src/modules/appointments/services/index.ts b/backend/src/modules/appointments/services/index.ts index 1b59c5ba..5d77e77c 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -68,6 +68,336 @@ export async function listMyAppointments(userId: string) { 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.'); + } + + // Check cutoff: no reschedule within cutoffHours of slot start + const slot = await availabilityService.getSlot(tx, existingAppointment.availabilitySlotId); + 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: existingAppointment.availabilitySlotId, 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: existingAppointment.availabilitySlotId, + 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.'); + } + + // Check cutoff: no cancellation within 2 hours of slot start + const slot = await availabilityService.getSlot(tx, existingAppointment.availabilitySlotId); + 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: existingAppointment.availabilitySlotId, 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: existingAppointment.availabilitySlotId, + }, + }); + + 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; + }); +} + /** This module's contribution to the patient data export (issue #40). * Slot times and the doctor's name are fetched per page through the * availability and doctors services — never by joining their tables. */ diff --git a/backend/src/modules/appointments/validation.ts b/backend/src/modules/appointments/validation.ts index 7293d8a9..ef172d6b 100644 --- a/backend/src/modules/appointments/validation.ts +++ b/backend/src/modules/appointments/validation.ts @@ -3,3 +3,36 @@ import { z } from 'zod'; export const bookSlotSchema = z.object({ availabilitySlotId: z.string().min(1), }); + +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(), +}); \ No newline at end of file diff --git a/backend/src/modules/auth/repositories/index.ts b/backend/src/modules/auth/repositories/index.ts index a4e10310..e42a4ffc 100644 --- a/backend/src/modules/auth/repositories/index.ts +++ b/backend/src/modules/auth/repositories/index.ts @@ -40,7 +40,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 c9843dfe..8ab19152 100644 --- a/backend/src/modules/availability/services/index.ts +++ b/backend/src/modules/availability/services/index.ts @@ -1,6 +1,7 @@ import { HttpError } from '../../../http-error'; import * as availabilityRepo from '../repositories'; import * as doctorsService from '../../doctors/services'; +import { fromZonedTime, toZonedTime } from 'date-fns-tz'; export function listOpenSlots(doctorProfileId: string) { return availabilityRepo.findOpenSlots(doctorProfileId); @@ -8,26 +9,34 @@ 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, slotLengthMinutes } = input; + const { doctorProfileId, date, startHour, endHour, slotLengthMinutes, timeZone } = input; if (slotLengthMinutes <= 0) throw new HttpError(400, 'slotLengthMinutes must be positive.'); if (startHour < 0 || endHour > 24 || startHour >= endHour) { throw new HttpError(400, 'startHour must be before endHour, both within 0-24.'); } - const dayStart = new Date(`${date}T00:00:00.000Z`); + const tz = timeZone ?? 'UTC'; + // Interpret the date as midnight in the doctor's timezone, then convert to UTC instants + const dayStartLocal = fromZonedTime(`${date}T00:00:00.000Z`, tz); + const dayStart = new Date(dayStartLocal.getTime()); if (Number.isNaN(dayStart.getTime())) throw new HttpError(400, 'date must be a valid ISO date.'); + // Convert local wall-clock hours to UTC instants in the doctor's timezone const rangeStart = new Date(dayStart.getTime() + startHour * 60 * 60 * 1000); const rangeEnd = new Date(dayStart.getTime() + endHour * 60 * 60 * 1000); @@ -38,7 +47,9 @@ export async function generateSlots(input: GenerateSlotsInput) { const stepMs = slotLengthMinutes * 60 * 1000; for (let start = rangeStart.getTime(); start + stepMs <= rangeEnd.getTime(); start += stepMs) { if (existingStarts.has(start)) continue; // idempotent: skip slots already generated - rows.push({ doctorProfileId, startTime: new Date(start), endTime: new Date(start + stepMs) }); + // Convert the local start instant back to UTC for storage + const utcStart = toZonedTime(new Date(start), tz); + rows.push({ doctorProfileId, startTime: utcStart, endTime: new Date(utcStart.getTime() + stepMs) }); } if (rows.length === 0) return { created: 0 }; From eef2d926a87e0951cc0f9c0ac82b1c0e7721bbdb Mon Sep 17 00:00:00 2001 From: Oluwafemi Ajibade Date: Mon, 19 Oct 2026 18:21:16 +0000 Subject: [PATCH 2/3] Implement cancellation policy (issue #56), waitlist with time-boxed offers (issue #55), and reschedule (issue #54) - Add late_cancelled status to AppointmentStatus enum - Implement cancelAppointment with 2-hour cutoff logic - Implement rescheduleSlot atomically with new slot claiming and old slot release - Implement waitlist join/leave/list/accept functionality - Add WaitlistEntry and WindowTime models to Prisma schema - Add route handlers for all new endpoints - Add validation schemas for all new requests - Fix auth repository to include timeZone field --- backend/package-lock.json | 21 ++ backend/package.json | 1 + backend/prisma/schema.prisma | 216 +++++++----- .../modules/appointments/controllers/index.ts | 55 ++- .../appointments/repositories/index.ts | 26 +- .../src/modules/appointments/routes/index.ts | 46 +++ .../modules/appointments/services/index.ts | 330 ++++++++++++++++++ .../src/modules/appointments/validation.ts | 33 ++ .../src/modules/auth/repositories/index.ts | 2 +- .../modules/availability/services/index.ts | 23 +- 10 files changed, 660 insertions(+), 93 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index b87a6dde..27d021e5 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", "jsonwebtoken": "^9.0.3", @@ -3021,6 +3022,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 9efd6b55..4ddf382f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,6 +27,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", "jsonwebtoken": "^9.0.3", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8b75713d..531b7fce 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -52,6 +52,7 @@ enum AppointmentStatus { scheduled completed cancelled + late_cancelled no_show } @@ -88,6 +89,13 @@ enum FulfillmentStatus { rejected } +enum WaitlistStatus { + pending + on_hold + accepted + expired +} + enum Urgency { routine urgent @@ -98,40 +106,40 @@ enum Urgency { // ========================================================================= model User { - id String @id @default(uuid()) - email String @unique - phone String? - passwordHash String - role Role + id String @id @default(uuid()) + email String @unique + phone String? + passwordHash String + role Role // Phase 8 (issue #34): null = never verified. Self-serve signup leaves // this null; existing rows were backfilled to `createdAt` in the // add_email_verification migration. Distinct from // DoctorProfile.verificationStatus, which is the separate, manual // admin verification of a doctor's credentials — do not conflate. - emailVerifiedAt DateTime? + emailVerifiedAt DateTime? // Phase 8 (issue #39): null = active. Deactivating never deletes anything // — no clinical or payment record is keyed to this flag, and none of the // deactivation paths remove rows. Login, refresh and access tokens are all // refused while this is set. - deactivatedAt DateTime? + deactivatedAt DateTime? // Phase 8 (issue #39): bumped when an account is deactivated/reactivated, // so access tokens minted before that moment stop working immediately // instead of living out their 15-minute TTL. There is still no general // 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 + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt patientProfile PatientProfile? doctorProfile DoctorProfile? facilityProfile FacilityProfile? emailVerificationTokens EmailVerificationToken[] - adminInvitesSent AdminInvite[] + adminInvitesSent AdminInvite[] // Phase 8 (issue #45): PHI read-access log rows this user wrote (as viewer). // Opposite of AccessLog.accessedByUser. - accessLogs AccessLog[] + accessLogs AccessLog[] @@index([role]) } @@ -197,27 +205,27 @@ model LoginAttempt { } model PatientProfile { - id String @id @default(uuid()) - userId String @unique - fullName String - dateOfBirth DateTime? + id String @id @default(uuid()) + userId String @unique + fullName String + dateOfBirth DateTime? // Nullable, no FK relation on purpose — the minor/dependent consent and // account-ownership model is an explicit open PRD question (§8.2.5), not // resolved here. Don't build on top of this field assuming it means // anything enforced yet. guardianUserId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - appointments Appointment[] - paymentIntents PaymentIntent[] - prescriptions Prescription[] - labOrders LabOrder[] - dataExports PatientExport[] - + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + appointments Appointment[] + paymentIntents PaymentIntent[] + prescriptions Prescription[] + labOrders LabOrder[] + dataExports PatientExport[] // Phase 8 (issue #45): who viewed this patient's records. - accessLogs AccessLog[] + accessLogs AccessLog[] + waitlistEntries WaitlistEntry[] } // Phase 8 (issue #40): right-of-access export jobs. One row per request; @@ -292,6 +300,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 @@ -301,6 +310,7 @@ model DoctorProfile { appointments Appointment[] prescriptions Prescription[] labOrders LabOrder[] + waitlistEntries WaitlistEntry[] @@index([verificationStatus]) } @@ -310,7 +320,8 @@ model Specialty { name String @unique slug String @unique - doctors DoctorSpecialty[] + doctors DoctorSpecialty[] + waitlistEntries WaitlistEntry[] } model DoctorSpecialty { @@ -342,12 +353,13 @@ 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, // no controlled vocabulary decided yet (out of Phase 1 scope). - detail String? + detail String? facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id], onDelete: Cascade) @@ -355,13 +367,14 @@ model FacilityCapability { } 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? @@ -436,23 +449,23 @@ model IntakeForm { } model Consultation { - id String @id @default(uuid()) - appointmentId String @unique + id String @id @default(uuid()) + appointmentId String @unique outcome ConsultationOutcome? notes String? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) - appointment Appointment @relation(fields: [appointmentId], references: [id]) + appointment Appointment @relation(fields: [appointmentId], references: [id]) prescriptions Prescription[] labOrders LabOrder[] } model Referral { - id String @id @default(uuid()) - status ReferralStatus @default(pending) - snapshotNotes String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + status ReferralStatus @default(pending) + snapshotNotes String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // TODO: sourceConsultationId, referringDoctorId, targetSpecialtyId, // targetDoctorId (nullable), snapshotIntakeId, resultingAppointmentId (nullable) // IMPORTANT: snapshotNotes / snapshotIntakeId must be copies, not live @@ -470,14 +483,14 @@ model Prescription { supersedesPrescriptionId String? createdAt DateTime @default(now()) - consultation Consultation @relation(fields: [consultationId], references: [id]) - patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) - prescribingDoctor DoctorProfile @relation(fields: [prescribingDoctorId], references: [id]) - facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id]) - supersedes Prescription? @relation("PrescriptionCorrections", fields: [supersedesPrescriptionId], references: [id]) - correctedBy Prescription? @relation("PrescriptionCorrections") - items PrescriptionItem[] - paymentIntent PaymentIntent? + consultation Consultation @relation(fields: [consultationId], references: [id]) + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) + prescribingDoctor DoctorProfile @relation(fields: [prescribingDoctorId], references: [id]) + facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id]) + supersedes Prescription? @relation("PrescriptionCorrections", fields: [supersedesPrescriptionId], references: [id]) + correctedBy Prescription? @relation("PrescriptionCorrections") + items PrescriptionItem[] + paymentIntent PaymentIntent? @@unique([supersedesPrescriptionId]) @@index([patientProfileId, createdAt]) @@ -498,27 +511,27 @@ model PrescriptionItem { } model LabOrder { - id String @id @default(uuid()) - consultationId String - patientProfileId String - orderingDoctorId String - facilityProfileId String - amountStroops BigInt - testType String - urgency Urgency @default(routine) - notes String? - status OrderStatus @default(issued) + id String @id @default(uuid()) + consultationId String + patientProfileId String + orderingDoctorId String + facilityProfileId String + amountStroops BigInt + testType String + urgency Urgency @default(routine) + notes String? + status OrderStatus @default(issued) supersedesLabOrderId String? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) - consultation Consultation @relation(fields: [consultationId], references: [id]) - patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) - orderingDoctor DoctorProfile @relation(fields: [orderingDoctorId], references: [id]) + consultation Consultation @relation(fields: [consultationId], references: [id]) + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) + orderingDoctor DoctorProfile @relation(fields: [orderingDoctorId], references: [id]) facilityProfile FacilityProfile @relation(fields: [facilityProfileId], references: [id]) - supersedes LabOrder? @relation("LabOrderCorrections", fields: [supersedesLabOrderId], references: [id]) - correctedBy LabOrder? @relation("LabOrderCorrections") - result LabResult? - paymentIntent PaymentIntent? + supersedes LabOrder? @relation("LabOrderCorrections", fields: [supersedesLabOrderId], references: [id]) + correctedBy LabOrder? @relation("LabOrderCorrections") + result LabResult? + paymentIntent PaymentIntent? @@unique([supersedesLabOrderId]) @@index([patientProfileId, createdAt]) @@ -550,12 +563,48 @@ 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()) + id String @id @default(uuid()) type String payload Json readAt DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) // TODO: userId } @@ -579,7 +628,6 @@ enum PaymentIntentStatus { failed } - model PaymentIntent { id String @id @default(uuid()) prescriptionId String? @unique @@ -590,7 +638,7 @@ model PaymentIntent { labOrder LabOrder? @relation(fields: [labOrderId], references: [id]) patientProfileId String - patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) + patientProfile PatientProfile @relation(fields: [patientProfileId], references: [id]) facilityId String facility FacilityProfile @relation(fields: [facilityId], references: [id]) @@ -652,18 +700,18 @@ model EscrowEventCursor { } model EscrowChainEvent { - id String @id @default(uuid()) - eventId String - contractId String - ledger Int - txHash String? - escrowId BigInt? - eventType String - state EscrowChainEventState @default(pending) - paymentIntentId String? - payload Json? - detectedAt DateTime @default(now()) - alertedAt DateTime? + id String @id @default(uuid()) + eventId String + contractId String + ledger Int + txHash String? + escrowId BigInt? + eventType String + state EscrowChainEventState @default(pending) + paymentIntentId String? + payload Json? + detectedAt DateTime @default(now()) + alertedAt DateTime? @@unique([contractId, eventId]) @@index([state, detectedAt]) diff --git a/backend/src/modules/appointments/controllers/index.ts b/backend/src/modules/appointments/controllers/index.ts index f07f9f7c..58ec94db 100644 --- a/backend/src/modules/appointments/controllers/index.ts +++ b/backend/src/modules/appointments/controllers/index.ts @@ -1,6 +1,7 @@ import type { Request, Response } from 'express'; import * as appointmentsService from '../services'; -import { bookSlotSchema } from '../validation'; +import * as patientsService from '../../patients/services'; +import { bookSlotSchema, rescheduleSlotBodySchema, waitlistJoinSchema, leaveWaitlistSchema, acceptWaitlistSchema } from '../validation'; import { HttpError } from '../../../http-error'; import { auditActor } from '../../../middleware/request-id'; @@ -16,3 +17,55 @@ export async function listMine(req: Request, res: Response) { const appointments = await appointmentsService.listMyAppointments(req.user.id); 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); +} diff --git a/backend/src/modules/appointments/repositories/index.ts b/backend/src/modules/appointments/repositories/index.ts index d5c327cb..7ff1d2bb 100644 --- a/backend/src/modules/appointments/repositories/index.ts +++ b/backend/src/modules/appointments/repositories/index.ts @@ -1,5 +1,5 @@ import { prisma } from '../../../db/prisma-client'; -import type { Prisma } from '@prisma/client'; +import type { Prisma, AppointmentStatus } from '@prisma/client'; type DbClient = typeof prisma | Prisma.TransactionClient; @@ -10,6 +10,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. */ diff --git a/backend/src/modules/appointments/routes/index.ts b/backend/src/modules/appointments/routes/index.ts index 04ecd694..34305ebd 100644 --- a/backend/src/modules/appointments/routes/index.ts +++ b/backend/src/modules/appointments/routes/index.ts @@ -15,3 +15,49 @@ appointmentsRouter.post( appointmentsController.book, ); appointmentsRouter.get('/mine', authenticate, requireRole('patient'), appointmentsController.listMine); + +// 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, +); diff --git a/backend/src/modules/appointments/services/index.ts b/backend/src/modules/appointments/services/index.ts index 2a0ba1b5..ccbda136 100644 --- a/backend/src/modules/appointments/services/index.ts +++ b/backend/src/modules/appointments/services/index.ts @@ -68,6 +68,336 @@ export async function listMyAppointments(userId: string) { 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.'); + } + + // Check cutoff: no reschedule within cutoffHours of slot start + const slot = await availabilityService.getSlot(tx, existingAppointment.availabilitySlotId); + 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: existingAppointment.availabilitySlotId, 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: existingAppointment.availabilitySlotId, + 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.'); + } + + // Check cutoff: no cancellation within 2 hours of slot start + const slot = await availabilityService.getSlot(tx, existingAppointment.availabilitySlotId); + 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: existingAppointment.availabilitySlotId, 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: existingAppointment.availabilitySlotId, + }, + }); + + 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; + }); +} + /** This module's contribution to the patient data export (issue #40). * Slot times and the doctor's name are fetched per page through the * availability and doctors services — never by joining their tables. */ diff --git a/backend/src/modules/appointments/validation.ts b/backend/src/modules/appointments/validation.ts index 7293d8a9..ef172d6b 100644 --- a/backend/src/modules/appointments/validation.ts +++ b/backend/src/modules/appointments/validation.ts @@ -3,3 +3,36 @@ import { z } from 'zod'; export const bookSlotSchema = z.object({ availabilitySlotId: z.string().min(1), }); + +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(), +}); \ No newline at end of file diff --git a/backend/src/modules/auth/repositories/index.ts b/backend/src/modules/auth/repositories/index.ts index 190ecbb4..c3088a5d 100644 --- a/backend/src/modules/auth/repositories/index.ts +++ b/backend/src/modules/auth/repositories/index.ts @@ -40,7 +40,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 c9843dfe..8ab19152 100644 --- a/backend/src/modules/availability/services/index.ts +++ b/backend/src/modules/availability/services/index.ts @@ -1,6 +1,7 @@ import { HttpError } from '../../../http-error'; import * as availabilityRepo from '../repositories'; import * as doctorsService from '../../doctors/services'; +import { fromZonedTime, toZonedTime } from 'date-fns-tz'; export function listOpenSlots(doctorProfileId: string) { return availabilityRepo.findOpenSlots(doctorProfileId); @@ -8,26 +9,34 @@ 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, slotLengthMinutes } = input; + const { doctorProfileId, date, startHour, endHour, slotLengthMinutes, timeZone } = input; if (slotLengthMinutes <= 0) throw new HttpError(400, 'slotLengthMinutes must be positive.'); if (startHour < 0 || endHour > 24 || startHour >= endHour) { throw new HttpError(400, 'startHour must be before endHour, both within 0-24.'); } - const dayStart = new Date(`${date}T00:00:00.000Z`); + const tz = timeZone ?? 'UTC'; + // Interpret the date as midnight in the doctor's timezone, then convert to UTC instants + const dayStartLocal = fromZonedTime(`${date}T00:00:00.000Z`, tz); + const dayStart = new Date(dayStartLocal.getTime()); if (Number.isNaN(dayStart.getTime())) throw new HttpError(400, 'date must be a valid ISO date.'); + // Convert local wall-clock hours to UTC instants in the doctor's timezone const rangeStart = new Date(dayStart.getTime() + startHour * 60 * 60 * 1000); const rangeEnd = new Date(dayStart.getTime() + endHour * 60 * 60 * 1000); @@ -38,7 +47,9 @@ export async function generateSlots(input: GenerateSlotsInput) { const stepMs = slotLengthMinutes * 60 * 1000; for (let start = rangeStart.getTime(); start + stepMs <= rangeEnd.getTime(); start += stepMs) { if (existingStarts.has(start)) continue; // idempotent: skip slots already generated - rows.push({ doctorProfileId, startTime: new Date(start), endTime: new Date(start + stepMs) }); + // Convert the local start instant back to UTC for storage + const utcStart = toZonedTime(new Date(start), tz); + rows.push({ doctorProfileId, startTime: utcStart, endTime: new Date(utcStart.getTime() + stepMs) }); } if (rows.length === 0) return { created: 0 }; From d2cd3c63ab9fdb71ed6d9e406620e7b7e2773b68 Mon Sep 17 00:00:00 2001 From: Oluwafemi Ajibade Date: Tue, 20 Oct 2026 15:01:57 +0000 Subject: [PATCH 3/3] test: set timezone in order fixture Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- backend/tests/integration/order-fixtures.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/integration/order-fixtures.mts b/backend/tests/integration/order-fixtures.mts index 42cf62d7..cee7e999 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),