/opt/mawid/apps/api/src/agent
NameSizeModeActions
agent.e2e.spec.ts132960644editdlrm
agent.module.ts8320644editdlrm
agent.service.ts81630644editdlrm
availability.spec.ts84820644editdlrm
availability.ts51830644editdlrm
booking-tools.service.ts144400644editdlrm
openai.client.ts22470644editdlrm
system-prompt.ts18550644editdlrm
tool-definitions.ts36140644editdlrm
tz.ts28820644editdlrm
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, }; } }