import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { blocksForDay, 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.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 findMembership(membershipId: string, organizationId: string) { const membership = await this.prisma.membership.findFirst({ where: { id: membershipId, organizationId }, select: { id: true, isOwner: 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'); } }