202 lines
6.1 KiB
TypeScript
202 lines
6.1 KiB
TypeScript
|
|
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<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 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');
|
||
|
|
}
|
||
|
|
}
|