import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { appointmentWithinWorkingHours, blocksForDay, localDayOfWeekMondayZero, } from '../../common/working-hours'; import { StaffWorkingHoursService } from '../staff/staff-working-hours.service'; import { CreateAppointmentDto } from './dto/create-appointment.dto'; import { ListAppointmentsDto } from './dto/list-appointments.dto'; import { UpdateAppointmentDto } from './dto/update-appointment.dto'; const MS_PER_DAY = 86_400_000; @Injectable() export class AppointmentsService { constructor( private readonly prisma: PrismaService, private readonly staffWorkingHoursService: StaffWorkingHoursService, ) {} getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { throw new BadRequestException('Organization is not selected'); } return user.organizationId; } async listColumnProviders(organizationId: string, actorUserId: string, date?: string) { await this.assertCanViewAppointments(actorUserId, organizationId); const members = await this.prisma.membership.findMany({ where: { organizationId, isOwner: false, isActive: true, permissions: { some: { permission: { name: 'TAB_TREATMENT_EDIT', }, }, }, }, include: { user: { select: { id: true, name: true } }, }, orderBy: [{ createdAt: 'asc' }], }); const scheduleBlocksByMembership = await this.staffWorkingHoursService.loadScheduleBlocksByMembershipIds( members.map((m) => m.id), ); const dayOfWeek = this.resolveDayOfWeekMondayZero(date); const data = members.map((m) => { const allBlocks = scheduleBlocksByMembership.get(m.id) ?? []; const dayBlocks = blocksForDay(allBlocks, dayOfWeek); return { userId: m.user.id, name: m.user.name, hasWorkingHours: allBlocks.length > 0, dayBlocks, }; }); return { success: true, data }; } async list(query: ListAppointmentsDto, organizationId: string, actorUserId: string) { await this.assertCanViewAppointments(actorUserId, organizationId); const from = new Date(query.from); const to = new Date(query.to); if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) { throw new BadRequestException('Invalid date range'); } if (to <= from) { throw new BadRequestException('Range "to" must be after "from"'); } const items = await this.prisma.appointment.findMany({ where: { organizationId, startAt: { lt: to }, endAt: { gt: from }, }, include: { patient: { select: { id: true, firstName: true, lastName: true, phone: true }, }, }, orderBy: [{ startAt: 'asc' }], }); return { success: true, data: items }; } async create( dto: CreateAppointmentDto, organizationId: string, actorUserId: string, ) { await this.assertCanEditAppointments(actorUserId, organizationId); const startAt = new Date(dto.startAt); const endAt = new Date(dto.endAt); if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) { throw new BadRequestException('Invalid start or end time'); } if (endAt <= startAt) { throw new BadRequestException('End time must be after start time'); } if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) { throw new BadRequestException('Appointment cannot span more than 24 hours'); } const now = Date.now(); if (startAt.getTime() < now) { throw new BadRequestException('Cannot schedule appointments in the past'); } await this.ensurePatientInOrg(dto.patientId, organizationId); await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId); await this.ensureAppointmentWithinProviderWorkingHours( dto.providerUserId, organizationId, startAt, endAt, ); const appointment = await this.prisma.appointment.create({ data: { organizationId, patientId: dto.patientId, providerUserId: dto.providerUserId, startAt, endAt, purpose: dto.purpose, }, include: { patient: { select: { id: true, firstName: true, lastName: true, phone: true }, }, }, }); return { success: true, data: appointment }; } async update( id: string, dto: UpdateAppointmentDto, organizationId: string, actorUserId: string, ) { await this.assertCanEditAppointments(actorUserId, organizationId); const existing = await this.prisma.appointment.findFirst({ where: { id, organizationId }, }); if (!existing) { throw new NotFoundException('Appointment not found'); } const startAt = dto.startAt ? new Date(dto.startAt) : existing.startAt; const endAt = dto.endAt ? new Date(dto.endAt) : existing.endAt; if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) { throw new BadRequestException('Invalid start or end time'); } if (endAt <= startAt) { throw new BadRequestException('End time must be after start time'); } if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) { throw new BadRequestException('Appointment cannot span more than 24 hours'); } const patientId = dto.patientId ?? existing.patientId; const providerUserId = dto.providerUserId ?? existing.providerUserId; const purpose = dto.purpose ?? existing.purpose; await this.ensurePatientInOrg(patientId, organizationId); await this.ensureProviderIsTreatmentEditor(providerUserId, organizationId); await this.ensureAppointmentWithinProviderWorkingHours( providerUserId, organizationId, startAt, endAt, ); const appointment = await this.prisma.appointment.update({ where: { id }, data: { patientId, providerUserId, startAt, endAt, purpose, }, include: { patient: { select: { id: true, firstName: true, lastName: true, phone: true }, }, }, }); return { success: true, data: appointment }; } async remove(id: string, organizationId: string, actorUserId: string) { await this.assertCanEditAppointments(actorUserId, organizationId); const existing = await this.prisma.appointment.findFirst({ where: { id, organizationId }, select: { id: true }, }); if (!existing) { throw new NotFoundException('Appointment not found'); } await this.prisma.appointment.delete({ where: { id }, }); return { success: true }; } private async assertCanViewAppointments(userId: string, organizationId: string) { const m = await this.getMembership(userId, organizationId); if (!m) { throw new ForbiddenException('You are not a member of this organization'); } if (m.isOwner) { return; } const names = m.permissions.map((p) => p.permission.name); if (names.includes('TAB_APPOINTMENTS_READ')) { return; } if (names.includes('TAB_TREATMENT_EDIT')) { return; } if (names.includes('TAB_TREATMENT_READ')) { return; } throw new ForbiddenException('You do not have access to appointments'); } private async assertCanEditAppointments(userId: string, organizationId: string) { const m = await this.getMembership(userId, organizationId); if (!m) { throw new ForbiddenException('You are not a member of this organization'); } if (m.isOwner) { return; } const names = m.permissions.map((p) => p.permission.name); if (names.includes('TAB_APPOINTMENTS_EDIT')) { return; } if (names.includes('TAB_TREATMENT_EDIT')) { return; } throw new ForbiddenException('You cannot create or modify appointments'); } private async ensureProviderIsTreatmentEditor(providerUserId: string, organizationId: string) { const m = await this.getMembership(providerUserId, organizationId); if (!m) { throw new BadRequestException('Provider is not a member of this organization'); } if (m.isOwner) { throw new BadRequestException( 'Appointments must be assigned to staff with treatment access, not the organization owner', ); } if (!m.isActive) { throw new BadRequestException('Provider is not an active staff member'); } const names = m.permissions.map((p) => p.permission.name); if (!names.includes('TAB_TREATMENT_EDIT')) { throw new BadRequestException('Provider does not have treatment edit access'); } } private async ensurePatientInOrg(patientId: string, organizationId: string) { const patient = await this.prisma.patient.findFirst({ where: { id: patientId, organizationId }, select: { id: true }, }); if (!patient) { throw new NotFoundException('Patient not found'); } } private resolveDayOfWeekMondayZero(date?: string): number { if (!date) { return localDayOfWeekMondayZero(new Date().getDay()); } const [y, m, d] = date.split('-').map(Number); const parsed = new Date(y, m - 1, d, 12, 0, 0, 0); if (Number.isNaN(parsed.getTime())) { throw new BadRequestException('Invalid date query parameter'); } return localDayOfWeekMondayZero(parsed.getDay()); } private async ensureAppointmentWithinProviderWorkingHours( providerUserId: string, organizationId: string, startAt: Date, endAt: Date, ) { const membership = await this.getMembership(providerUserId, organizationId); if (!membership) { throw new BadRequestException('Provider is not a member of this organization'); } const scheduleBlocksByMembership = await this.staffWorkingHoursService.loadScheduleBlocksByMembershipIds([membership.id]); const allBlocks = scheduleBlocksByMembership.get(membership.id) ?? []; if (allBlocks.length === 0) { throw new BadRequestException('Provider has no working hours configured'); } const dayOfWeek = localDayOfWeekMondayZero(startAt.getDay()); const dayBlocks = blocksForDay(allBlocks, dayOfWeek); if (dayBlocks.length === 0) { throw new BadRequestException('Provider is not working on this day'); } if (!appointmentWithinWorkingHours(startAt, endAt, dayBlocks)) { throw new BadRequestException('Appointment must fall within the provider working hours'); } } private async getMembership(userId: string, organizationId: string) { return this.prisma.membership.findFirst({ where: { userId, organizationId }, include: { permissions: { include: { permission: true } } }, }); } }