Files
dyolink/backend/src/modules/staff/staff-working-hours.service.ts

349 lines
11 KiB
TypeScript

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';
import {
hasEffectivePermission,
} from '../../common/membership-permissions';
@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.findStaffMembership(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.findStaffMembership(membershipId, organizationId);
return this.persistWorkingHours(membership, organizationId, dto);
}
async getMyWorkingHours(userId: string, organizationId: string) {
const membership = await this.findOwnerMembership(userId, organizationId);
await this.assertOwnerCanManageWorkingHours(membership);
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 upsertMyWorkingHours(
userId: string,
organizationId: string,
dto: UpsertWorkingHoursDto,
) {
const membership = await this.findOwnerMembership(userId, organizationId);
await this.assertOwnerCanManageWorkingHours(membership);
return this.persistWorkingHours(membership, organizationId, dto);
}
private async persistWorkingHours(
membership: { id: string; userId: string },
organizationId: string,
dto: UpsertWorkingHoursDto,
) {
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<string, WorkingHoursBlockInput[]>();
}
const schedules = await this.prisma.staffWorkingHoursSchedule.findMany({
where: { membershipId: { in: membershipIds } },
include: {
blocks: { orderBy: [{ dayOfWeek: 'asc' }, { sortOrder: 'asc' }, { startMinute: 'asc' }] },
},
});
const map = new Map<string, WorkingHoursBlockInput[]>();
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 findStaffMembership(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 findOwnerMembership(userId: string, organizationId: string) {
const membership = await this.prisma.membership.findFirst({
where: { userId, organizationId, isOwner: true },
include: {
permissions: { include: { permission: true } },
organization: { include: { type: true, plan: true } },
},
});
if (!membership) {
throw new ForbiddenException('Only organization owners can manage their working hours');
}
return membership;
}
private async assertOwnerCanManageWorkingHours(membership: {
isOwner: boolean;
permissions: { permission: { name: string } }[];
organization: { type: { name: string }; plan?: { name: string } | null; planId?: string | null };
}) {
if (!hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')) {
throw new ForbiddenException(
'Enable treatment participation before setting working hours',
);
}
}
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');
}
}