/opt/mawid/apps/api/src/agent
Edit: /opt/mawid/apps/api/src/agent/booking-tools.service.ts (14440B)
import { Injectable } from '@nestjs/common';
import type { Prisma } from '@mawid/db';
import { PrismaService } from '../prisma/prisma.service';
import { ReminderService } from '../reminders/reminder.service';
import { WaitlistService } from '../waitlist/waitlist.service';
import { computeAvailableSlots, isSlotAvailable, type AvailabilityQuery, type WeekHours } from './availability';
import { formatLocal, wallTimeToUtc } from './tz';
export interface ToolContext {
clinicId: string;
patientId: string;
conversationId: string;
}
export interface ToolResult {
ok: boolean;
[key: string]: unknown;
}
const MAX_RANGE_DAYS = 14;
const MAX_SLOTS_RETURNED = 24;
const BLOCKING_STATUSES = ['pending', 'confirmed'] as const;
/**
* Implementations of the tools exposed to the agent. Every method is scoped by
* clinicId + patientId from the conversation context (never model-provided)
* and every call is written to AuditLog (PROJECT_PLAN Phase 3 guardrails).
*/
@Injectable()
export class BookingToolsService {
constructor(
private readonly prisma: PrismaService,
private readonly reminders: ReminderService,
private readonly waitlist: WaitlistService,
) {}
async execute(ctx: ToolContext, name: string, input: Record
): Promise {
let result: ToolResult;
try {
switch (name) {
case 'get_services':
result = await this.getServices(ctx);
break;
case 'get_availability':
result = await this.getAvailability(ctx, input);
break;
case 'create_appointment':
result = await this.createAppointment(ctx, input);
break;
case 'reschedule_appointment':
result = await this.rescheduleAppointment(ctx, input);
break;
case 'cancel_appointment':
result = await this.cancelAppointment(ctx, input);
break;
case 'add_to_waitlist':
result = await this.addToWaitlist(ctx, input);
break;
case 'get_patient_appointments':
result = await this.getPatientAppointments(ctx);
break;
case 'handoff_to_human':
result = await this.handoffToHuman(ctx, input);
break;
default:
result = { ok: false, error: `Unknown tool: ${name}` };
}
} catch (err) {
result = { ok: false, error: err instanceof Error ? err.message : String(err) };
}
await this.prisma.auditLog.create({
data: {
clinicId: ctx.clinicId,
actor: 'agent',
action: `tool:${name}`,
meta: {
conversationId: ctx.conversationId,
input: input as object,
ok: result.ok,
...(result.ok ? {} : { error: result.error }),
} as object,
},
});
return result;
}
private async getServices(ctx: ToolContext): Promise {
const services = await this.prisma.service.findMany({
where: { clinicId: ctx.clinicId, active: true },
select: { id: true, name: true, durationMinutes: true, price: true },
orderBy: { createdAt: 'asc' },
});
return {
ok: true,
services: services.map((s) => ({ ...s, price: s.price ? Number(s.price) : null })),
};
}
private async getAvailability(ctx: ToolContext, input: Record): Promise {
const serviceId = String(input.serviceId ?? '');
const staffId = input.staffId ? String(input.staffId) : undefined;
const clinic = await this.prisma.clinic.findUniqueOrThrow({ where: { id: ctx.clinicId } });
const service = await this.prisma.service.findFirst({
where: { id: serviceId, clinicId: ctx.clinicId, active: true },
});
if (!service) return { ok: false, error: 'Service not found' };
const staff = await this.prisma.staff.findMany({
where: {
clinicId: ctx.clinicId,
active: true,
...(staffId ? { id: staffId } : {}),
services: { some: { id: serviceId } },
},
});
if (staff.length === 0) return { ok: false, error: 'No staff offers this service' };
const now = new Date();
const from = input.fromDate
? this.localDateToUtc(clinic.timezone, String(input.fromDate), 0, 0)
: now;
let to = input.toDate
? this.localDateToUtc(clinic.timezone, String(input.toDate), 24, 0)
: new Date(from.getTime() + 7 * 24 * 3600_000);
const rangeCap = new Date(from.getTime() + MAX_RANGE_DAYS * 24 * 3600_000);
if (to > rangeCap) to = rangeCap;
const effectiveFrom = from < now ? now : from;
const query = await this.buildAvailabilityQuery(clinic, service.durationMinutes, staff, effectiveFrom, to);
const slots = computeAvailableSlots(query);
return {
ok: true,
timezone: clinic.timezone,
slots: slots.slice(0, MAX_SLOTS_RETURNED).map((s) => ({
startsAtUtc: s.startsAt.toISOString(),
local: formatLocal(clinic.timezone, s.startsAt),
staffId: s.staffId,
staffName: s.staffName,
})),
truncated: slots.length > MAX_SLOTS_RETURNED,
};
}
private async createAppointment(ctx: ToolContext, input: Record): Promise {
const serviceId = String(input.serviceId ?? '');
const staffId = String(input.staffId ?? '');
const startsAt = new Date(String(input.startsAtUtc ?? ''));
if (Number.isNaN(startsAt.getTime())) return { ok: false, error: 'Invalid startsAtUtc' };
const clinic = await this.prisma.clinic.findUniqueOrThrow({ where: { id: ctx.clinicId } });
const service = await this.prisma.service.findFirst({
where: { id: serviceId, clinicId: ctx.clinicId, active: true },
});
if (!service) return { ok: false, error: 'Service not found' };
const staff = await this.prisma.staff.findFirst({
where: { id: staffId, clinicId: ctx.clinicId, active: true, services: { some: { id: serviceId } } },
});
if (!staff) return { ok: false, error: 'Staff member not found or does not offer this service' };
const endsAt = new Date(startsAt.getTime() + service.durationMinutes * 60_000);
if (startsAt < new Date()) return { ok: false, error: 'Slot is in the past' };
// Never invent availability: the slot must be on the computed grid.
const dayQuery = await this.buildAvailabilityQuery(
clinic,
service.durationMinutes,
[staff],
startsAt,
endsAt,
);
if (!isSlotAvailable(dayQuery, staffId, startsAt, endsAt)) {
return { ok: false, error: 'Slot is not available' };
}
const appointment = await this.prisma.$transaction(async (tx) => {
// Per-staff advisory lock serializes concurrent bookings for the same staff.
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${staffId}))`;
const conflict = await tx.appointment.findFirst({
where: {
staffId,
status: { in: [...BLOCKING_STATUSES] },
startsAt: { lt: endsAt },
endsAt: { gt: startsAt },
},
});
if (conflict) return null;
return tx.appointment.create({
data: {
clinicId: ctx.clinicId,
patientId: ctx.patientId,
staffId,
serviceId,
startsAt,
endsAt,
status: 'pending',
source: 'whatsapp',
},
});
});
if (!appointment) return { ok: false, error: 'Slot was just taken — offer another one' };
await this.reminders.scheduleForAppointment(appointment.id);
return {
ok: true,
appointmentId: appointment.id,
local: formatLocal(clinic.timezone, startsAt),
staffName: staff.name,
status: appointment.status,
};
}
private async rescheduleAppointment(ctx: ToolContext, input: Record): Promise {
const appointmentId = String(input.appointmentId ?? '');
const newStartsAt = new Date(String(input.newStartsAtUtc ?? ''));
if (Number.isNaN(newStartsAt.getTime())) return { ok: false, error: 'Invalid newStartsAtUtc' };
if (newStartsAt < new Date()) return { ok: false, error: 'Slot is in the past' };
const appointment = await this.prisma.appointment.findFirst({
where: {
id: appointmentId,
clinicId: ctx.clinicId,
patientId: ctx.patientId,
status: { in: [...BLOCKING_STATUSES] },
},
include: { service: true, staff: true },
});
if (!appointment) return { ok: false, error: 'Appointment not found' };
const clinic = await this.prisma.clinic.findUniqueOrThrow({ where: { id: ctx.clinicId } });
const newEndsAt = new Date(newStartsAt.getTime() + appointment.service.durationMinutes * 60_000);
const dayQuery = await this.buildAvailabilityQuery(
clinic,
appointment.service.durationMinutes,
[appointment.staff],
newStartsAt,
newEndsAt,
appointment.id,
);
if (!isSlotAvailable(dayQuery, appointment.staffId, newStartsAt, newEndsAt)) {
return { ok: false, error: 'Slot is not available' };
}
const updated = await this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${appointment.staffId}))`;
const conflict = await tx.appointment.findFirst({
where: {
id: { not: appointment.id },
staffId: appointment.staffId,
status: { in: [...BLOCKING_STATUSES] },
startsAt: { lt: newEndsAt },
endsAt: { gt: newStartsAt },
},
});
if (conflict) return null;
return tx.appointment.update({
where: { id: appointment.id },
data: { startsAt: newStartsAt, endsAt: newEndsAt, status: 'pending' },
});
});
if (!updated) return { ok: false, error: 'Slot was just taken — offer another one' };
// Reminder jobs are tied to the start time — rebuild them (idempotent).
await this.reminders.scheduleForAppointment(updated.id);
return { ok: true, appointmentId: updated.id, local: formatLocal(clinic.timezone, newStartsAt) };
}
private async cancelAppointment(ctx: ToolContext, input: Record): Promise {
const appointmentId = String(input.appointmentId ?? '');
const appointment = await this.prisma.appointment.findFirst({
where: {
id: appointmentId,
clinicId: ctx.clinicId,
patientId: ctx.patientId,
status: { in: [...BLOCKING_STATUSES] },
},
});
if (!appointment) return { ok: false, error: 'Appointment not found' };
await this.prisma.appointment.update({
where: { id: appointment.id },
data: {
status: 'cancelled',
cancelReason: input.reason ? String(input.reason) : 'patient_cancelled',
},
});
await this.reminders.cancelForAppointment(appointment.id);
await this.waitlist.onAppointmentCancelled(appointment.id);
return { ok: true, appointmentId: appointment.id, status: 'cancelled' };
}
private async addToWaitlist(ctx: ToolContext, input: Record): Promise {
const serviceId = String(input.serviceId ?? '');
const service = await this.prisma.service.findFirst({
where: { id: serviceId, clinicId: ctx.clinicId, active: true },
});
if (!service) return { ok: false, error: 'Service not found' };
const entry = await this.prisma.waitlistEntry.create({
data: {
clinicId: ctx.clinicId,
patientId: ctx.patientId,
serviceId,
preferredWindow: (input.preferredWindow ?? {}) as object,
},
});
return { ok: true, waitlistEntryId: entry.id };
}
private async getPatientAppointments(ctx: ToolContext): Promise {
const clinic = await this.prisma.clinic.findUniqueOrThrow({ where: { id: ctx.clinicId } });
const appointments = await this.prisma.appointment.findMany({
where: {
clinicId: ctx.clinicId,
patientId: ctx.patientId,
status: { in: [...BLOCKING_STATUSES] },
startsAt: { gte: new Date() },
},
include: { service: true, staff: true },
orderBy: { startsAt: 'asc' },
});
return {
ok: true,
appointments: appointments.map((a) => ({
appointmentId: a.id,
local: formatLocal(clinic.timezone, a.startsAt),
startsAtUtc: a.startsAt.toISOString(),
service: a.service.name,
staffName: a.staff.name,
status: a.status,
})),
};
}
private async handoffToHuman(ctx: ToolContext, input: Record): Promise {
const conversation = await this.prisma.conversation.findFirst({
where: { id: ctx.conversationId, clinicId: ctx.clinicId },
});
if (!conversation) return { ok: false, error: 'Conversation not found' };
const state = (conversation.state ?? {}) as Record;
await this.prisma.conversation.update({
where: { id: conversation.id },
data: {
state: { ...state, handedOff: true, handoffReason: String(input.reason ?? '') } as object,
},
});
return { ok: true, handedOff: true };
}
private localDateToUtc(timezone: string, isoDate: string, hour: number, minute: number): Date {
const [y, m, d] = isoDate.split('-').map(Number);
return wallTimeToUtc(timezone, y, m, d, hour, minute);
}
private async buildAvailabilityQuery(
clinic: { id: string; timezone: string; workingHours: unknown },
durationMinutes: number,
staff: Array<{ id: string; name: string; workingHours: unknown }>,
from: Date,
to: Date,
excludeAppointmentId?: string,
): Promise {
const busyWhere: Prisma.AppointmentWhereInput = {
staffId: { in: staff.map((s) => s.id) },
status: { in: [...BLOCKING_STATUSES] },
startsAt: { lt: to },
endsAt: { gt: from },
...(excludeAppointmentId ? { id: { not: excludeAppointmentId } } : {}),
};
const busy = await this.prisma.appointment.findMany({
where: busyWhere,
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,
};
}
}