/opt/mawid/apps/api/src/appointments
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,
};
}
}