/opt/mawid/apps/api/src/appointments
NameSizeModeActions
appointments.controller.ts24370644editdlrm
appointments.module.ts5770644editdlrm
appointments.service.ts96980644editdlrm
Edit: /opt/mawid/apps/api/src/appointments/appointments.service.ts (9698B)
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import type { Prisma } from '@mawid/db'; import { t, type CreateAppointmentInput, type Language, type UpdateAppointmentInput, } from '@mawid/shared'; import { ReminderSenderService } from '../reminders/reminder-sender.service'; import { WaSenderService } from '../whatsapp/wa-sender.service'; import { computeAvailableSlots, type AvailabilityQuery, type WeekHours, } from '../agent/availability'; import { formatLocal, wallTimeToUtc } from '../agent/tz'; import { PrismaService } from '../prisma/prisma.service'; import { ReminderService } from '../reminders/reminder.service'; import { WaitlistService } from '../waitlist/waitlist.service'; const APPOINTMENT_INCLUDE = { patient: { select: { id: true, name: true, waPhone: true, language: true } }, staff: { select: { id: true, name: true } }, service: { select: { id: true, name: true, durationMinutes: true } }, } as const; const BLOCKING = ['pending', 'confirmed'] as const; @Injectable() export class AppointmentsService { private readonly logger = new Logger(AppointmentsService.name); constructor( private readonly prisma: PrismaService, private readonly reminders: ReminderService, private readonly waitlist: WaitlistService, private readonly reminderSender: ReminderSenderService, private readonly waSender: WaSenderService, ) {} list(clinicId: string, from: Date, to: Date) { return this.prisma.appointment.findMany({ where: { clinicId, startsAt: { gte: from, lt: to } }, include: APPOINTMENT_INCLUDE, orderBy: { startsAt: 'asc' }, }); } async availability(clinicId: string, serviceId: string, staffId: string | undefined, fromDate: string, toDate: string) { const clinic = await this.prisma.clinic.findUniqueOrThrow({ where: { id: clinicId } }); const service = await this.prisma.service.findFirst({ where: { id: serviceId, clinicId, active: true }, }); if (!service) throw new NotFoundException('Service not found'); const staff = await this.prisma.staff.findMany({ where: { clinicId, active: true, ...(staffId ? { id: staffId } : {}), services: { some: { id: serviceId } }, }, }); const [fy, fm, fd] = fromDate.split('-').map(Number); const [ty, tm, td] = toDate.split('-').map(Number); const now = new Date(); let from = wallTimeToUtc(clinic.timezone, fy, fm, fd, 0, 0); if (from < now) from = now; const to = wallTimeToUtc(clinic.timezone, ty, tm, td, 24, 0); const query = await this.buildQuery(clinic, service.durationMinutes, staff, from, to); return computeAvailableSlots(query) .slice(0, 200) .map((s) => ({ startsAtUtc: s.startsAt.toISOString(), local: formatLocal(clinic.timezone, s.startsAt), staffId: s.staffId, staffName: s.staffName, })); } async create(clinicId: string, input: CreateAppointmentInput) { const service = await this.prisma.service.findFirst({ where: { id: input.serviceId, clinicId }, }); if (!service) throw new NotFoundException('Service not found'); const staff = await this.prisma.staff.findFirst({ where: { id: input.staffId, clinicId }, }); if (!staff) throw new NotFoundException('Staff member not found'); const patient = await this.prisma.patient.findFirst({ where: { id: input.patientId, clinicId }, }); if (!patient) throw new NotFoundException('Patient not found'); const startsAt = new Date(input.startsAtUtc); const endsAt = new Date(startsAt.getTime() + service.durationMinutes * 60_000); const appointment = await this.bookWithLock(input.staffId, startsAt, endsAt, () => this.prisma.appointment.create({ data: { clinicId, patientId: input.patientId, staffId: input.staffId, serviceId: input.serviceId, startsAt, endsAt, status: 'confirmed', // owner-created bookings are pre-confirmed source: 'dashboard', }, include: APPOINTMENT_INCLUDE, }), ); await this.reminders.scheduleForAppointment(appointment.id); return appointment; } async update(clinicId: string, id: string, input: UpdateAppointmentInput) { const appointment = await this.get(clinicId, id); const data: Prisma.AppointmentUncheckedUpdateInput = {}; if (input.status) data.status = input.status; let rescheduled = false; if (input.startsAtUtc || input.staffId) { const staffId = input.staffId ?? appointment.staffId; const startsAt = input.startsAtUtc ? new Date(input.startsAtUtc) : appointment.startsAt; const service = await this.prisma.service.findUniqueOrThrow({ where: { id: appointment.serviceId }, }); const endsAt = new Date(startsAt.getTime() + service.durationMinutes * 60_000); if (input.staffId) { const staff = await this.prisma.staff.findFirst({ where: { id: input.staffId, clinicId } }); if (!staff) throw new NotFoundException('Staff member not found'); } await this.bookWithLock(staffId, startsAt, endsAt, async () => null, appointment.id); // A moved appointment needs the patient's re-confirmation. Object.assign(data, { staffId, startsAt, endsAt, unconfirmedRisk: false }); if (!input.status) data.status = 'pending'; rescheduled = true; } const updated = await this.prisma.appointment.update({ where: { id }, data, include: APPOINTMENT_INCLUDE, }); if (rescheduled) { await this.reminders.scheduleForAppointment(id); await this.notifyRescheduled(id); } if (input.status && ['completed', 'no_show'].includes(input.status)) { await this.reminders.cancelForAppointment(id); } return updated; } /** Owner moved the appointment — tell the patient and ask to re-confirm * (reuses the approved appointment_reminder template + buttons). */ private async notifyRescheduled(appointmentId: string): Promise { try { const appointment = await this.prisma.appointment.findUniqueOrThrow({ where: { id: appointmentId }, include: { clinic: true, patient: true, service: true }, }); await this.reminderSender.send( { appointment, clinic: appointment.clinic, patient: appointment.patient, service: appointment.service, }, 'first', ); } catch (err) { // Notification failures must never block the owner's action. this.logger.warn(`reschedule notification failed for ${appointmentId}: ${String(err)}`); } } async cancel(clinicId: string, id: string, reason?: string) { await this.get(clinicId, id); const cancelled = await this.prisma.appointment.update({ where: { id }, data: { status: 'cancelled', cancelReason: reason ?? 'dashboard_cancelled' }, include: APPOINTMENT_INCLUDE, }); await this.reminders.cancelForAppointment(id); try { const lang = (['ar', 'tr', 'en'] as const).includes(cancelled.patient.language as Language) ? (cancelled.patient.language as Language) : 'en'; await this.waSender.sendText(clinicId, cancelled.patient.id, t('cancelledOk', lang)); } catch (err) { // Outside the 24h window a free-form text is rejected — owner should // reach the patient another way; never block the cancellation itself. this.logger.warn(`cancel notification skipped for ${id}: ${String(err)}`); } await this.waitlist.onAppointmentCancelled(id); return cancelled; } private async get(clinicId: string, id: string) { const appointment = await this.prisma.appointment.findFirst({ where: { id, clinicId } }); if (!appointment) throw new NotFoundException('Appointment not found'); return appointment; } /** Runs `create` under the per-staff advisory lock after an overlap check. */ private async bookWithLock( staffId: string, startsAt: Date, endsAt: Date, create: (tx: Prisma.TransactionClient) => Promise, excludeId?: string, ): Promise { return this.prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${staffId}))`; const conflict = await tx.appointment.findFirst({ where: { staffId, status: { in: [...BLOCKING] }, startsAt: { lt: endsAt }, endsAt: { gt: startsAt }, ...(excludeId ? { id: { not: excludeId } } : {}), }, }); if (conflict) throw new BadRequestException('Slot conflicts with an existing appointment'); return create(tx); }); } private async buildQuery( clinic: { timezone: string; workingHours: unknown }, durationMinutes: number, staff: Array<{ id: string; name: string; workingHours: unknown }>, from: Date, to: Date, ): Promise { const busy = await this.prisma.appointment.findMany({ where: { staffId: { in: staff.map((s) => s.id) }, status: { in: [...BLOCKING] }, startsAt: { lt: to }, endsAt: { gt: from }, }, select: { staffId: true, startsAt: true, endsAt: true }, }); return { timezone: clinic.timezone, durationMinutes, staff: staff.map((s) => ({ id: s.id, name: s.name, workingHours: (s.workingHours ?? {}) as WeekHours, })), busy, from, to, granularityMinutes: 15, clinicHours: (clinic.workingHours ?? {}) as WeekHours, }; } }