import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.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) {} getOrganizationIdFromUser(user: { organizationId?: string }) { if (!user?.organizationId) { throw new BadRequestException('Organization is not selected'); } return user.organizationId; } async listColumnProviders(organizationId: string, actorUserId: 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 data = members.map((m) => ({ userId: m.user.id, name: m.user.name })); 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); 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); 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; } 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 async getMembership(userId: string, organizationId: string) { return this.prisma.membership.findFirst({ where: { userId, organizationId }, include: { permissions: { include: { permission: true } } }, }); } }