import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { appointmentWithinWorkingHours, blocksForDay, localDayOfWeekMondayZero, validateWorkingHoursBlocks, type WorkingHoursBlockInput, } from '../../common/working-hours'; import { UpsertWorkingHoursDto } from './dto/upsert-working-hours.dto'; @Injectable() export class StaffWorkingHoursService { constructor(private readonly prisma: PrismaService) {} async getWorkingHours(actorUserId: string, organizationId: string, membershipId: string) { await this.assertCanViewStaff(actorUserId, organizationId); const membership = await this.findMembership(membershipId, organizationId); const schedule = await this.prisma.staffWorkingHoursSchedule.findUnique({ where: { membershipId: membership.id }, include: { blocks: { orderBy: [{ dayOfWeek: 'asc' }, { sortOrder: 'asc' }, { startMinute: 'asc' }] }, }, }); if (!schedule) { return { success: true, data: { autoRepeatWeekly: true, blocks: [], hasWorkingHours: false, }, }; } return { success: true, data: { autoRepeatWeekly: schedule.autoRepeatWeekly, blocks: schedule.blocks.map((b) => ({ dayOfWeek: b.dayOfWeek, startMinute: b.startMinute, endMinute: b.endMinute, sortOrder: b.sortOrder, })), hasWorkingHours: schedule.blocks.length > 0, }, }; } async upsertWorkingHours( actorUserId: string, organizationId: string, membershipId: string, dto: UpsertWorkingHoursDto, ) { await this.assertCanEditStaff(actorUserId, organizationId); const membership = await this.findMembership(membershipId, organizationId); const validationError = validateWorkingHoursBlocks(dto.blocks); if (validationError) { throw new BadRequestException(validationError); } const normalizedBlocks = this.normalizeBlocks(dto.blocks); await this.assertNoConflictingAppointments( organizationId, membership.userId, normalizedBlocks, ); await this.prisma.$transaction(async (tx) => { const schedule = await tx.staffWorkingHoursSchedule.upsert({ where: { membershipId: membership.id }, create: { membershipId: membership.id, autoRepeatWeekly: dto.autoRepeatWeekly, }, update: { autoRepeatWeekly: dto.autoRepeatWeekly, }, }); await tx.staffWorkingHoursBlock.deleteMany({ where: { scheduleId: schedule.id } }); if (normalizedBlocks.length > 0) { await tx.staffWorkingHoursBlock.createMany({ data: normalizedBlocks.map((block, index) => ({ scheduleId: schedule.id, dayOfWeek: block.dayOfWeek, startMinute: block.startMinute, endMinute: block.endMinute, sortOrder: block.sortOrder ?? index, })), }); } }); return { success: true, message: 'Working hours saved', }; } async loadScheduleBlocksByMembershipIds(membershipIds: string[]) { if (membershipIds.length === 0) { return new Map(); } const schedules = await this.prisma.staffWorkingHoursSchedule.findMany({ where: { membershipId: { in: membershipIds } }, include: { blocks: { orderBy: [{ dayOfWeek: 'asc' }, { sortOrder: 'asc' }, { startMinute: 'asc' }] }, }, }); const map = new Map(); for (const schedule of schedules) { map.set( schedule.membershipId, schedule.blocks.map((b) => ({ dayOfWeek: b.dayOfWeek, startMinute: b.startMinute, endMinute: b.endMinute, sortOrder: b.sortOrder, })), ); } return map; } dayBlocksFromMembershipBlocks(blocks: WorkingHoursBlockInput[], dayOfWeekMondayZero: number) { return blocksForDay(blocks, dayOfWeekMondayZero); } private normalizeBlocks(blocks: UpsertWorkingHoursDto['blocks']): WorkingHoursBlockInput[] { return blocks.map((block, index) => ({ dayOfWeek: block.dayOfWeek, startMinute: block.startMinute, endMinute: block.endMinute, sortOrder: block.sortOrder ?? index, })); } private async assertNoConflictingAppointments( organizationId: string, providerUserId: string, blocks: WorkingHoursBlockInput[], ) { const now = new Date(); const appointments = await this.prisma.appointment.findMany({ where: { organizationId, providerUserId, endAt: { gt: now }, }, include: { patient: { select: { firstName: true, lastName: true } }, }, orderBy: { startAt: 'asc' }, }); const conflicts = appointments.filter((appointment) => { const startAt = new Date(appointment.startAt); const endAt = new Date(appointment.endAt); const dayOfWeek = localDayOfWeekMondayZero(startAt.getDay()); const dayBlocks = blocksForDay(blocks, dayOfWeek); if (dayBlocks.length === 0) { return true; } return !appointmentWithinWorkingHours(startAt, endAt, dayBlocks); }); if (conflicts.length === 0) { return; } const examples = conflicts.slice(0, 3).map((appointment) => { const startAt = new Date(appointment.startAt); const patientName = `${appointment.patient.firstName} ${appointment.patient.lastName}`; const when = startAt.toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', }); return `${patientName} (${when})`; }); const extra = conflicts.length > examples.length ? ` and ${conflicts.length - examples.length} more` : ''; throw new BadRequestException( `Cannot save working hours: ${conflicts.length} upcoming appointment${conflicts.length === 1 ? '' : 's'} fall outside the new schedule (${examples.join(', ')}${extra}). Reschedule or remove those appointments first.`, ); } private async findMembership(membershipId: string, organizationId: string) { const membership = await this.prisma.membership.findFirst({ where: { id: membershipId, organizationId }, select: { id: true, isOwner: true, userId: true }, }); if (!membership) { throw new NotFoundException('Member not found'); } if (membership.isOwner) { throw new BadRequestException('Working hours cannot be set for the organization owner'); } return membership; } private async assertCanViewStaff(userId: string, organizationId: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canViewStaff(actor)) { throw new ForbiddenException('You do not have access to staff management'); } } private async assertCanEditStaff(userId: string, organizationId: string) { const actor = await this.getActorMembership(userId, organizationId); if (!actor || !this.canEditStaff(actor)) { throw new ForbiddenException('You cannot manage staff working hours'); } } private async getActorMembership(userId: string, organizationId: string) { return this.prisma.membership.findFirst({ where: { userId, organizationId }, include: { permissions: { include: { permission: true } }, organization: { select: { planId: true } }, }, }); } private canViewStaff(m: { isOwner: boolean; permissions: { permission: { name: string } }[]; }): boolean { if (m.isOwner) return true; return m.permissions.some( (p) => p.permission.name === 'TAB_STAFF_READ' || p.permission.name === 'TAB_STAFF_EDIT', ); } private canEditStaff(m: { isOwner: boolean; organization?: { planId: string | null }; permissions: { permission: { name: string } }[]; }): boolean { if (m.isOwner) return Boolean(m.organization?.planId); return m.permissions.some((p) => p.permission.name === 'TAB_STAFF_EDIT'); } }