feature: users with treatment edit permission should now have working hours defined. the appointment grid is now being drawn based on the doctor's working hours.
This commit is contained in:
44
backend/src/modules/staff/dto/upsert-working-hours.dto.ts
Normal file
44
backend/src/modules/staff/dto/upsert-working-hours.dto.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Max,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class WorkingHoursBlockDto {
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(6)
|
||||
dayOfWeek: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(1439)
|
||||
startMinute: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1440)
|
||||
endMinute: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export class UpsertWorkingHoursDto {
|
||||
@IsBoolean()
|
||||
autoRepeatWeekly: boolean;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMaxSize(42)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkingHoursBlockDto)
|
||||
blocks: WorkingHoursBlockDto[];
|
||||
}
|
||||
201
backend/src/modules/staff/staff-working-hours.service.ts
Normal file
201
backend/src/modules/staff/staff-working-hours.service.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
@@ -16,13 +17,18 @@ import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto';
|
||||
import { InviteStaffDto } from './dto/invite-staff.dto';
|
||||
import { PreviewStaffInviteDto } from './dto/preview-staff-invite.dto';
|
||||
import { UpdateStaffMemberDto } from './dto/update-staff-member.dto';
|
||||
import { UpsertWorkingHoursDto } from './dto/upsert-working-hours.dto';
|
||||
import { StaffService } from './staff.service';
|
||||
import { StaffWorkingHoursService } from './staff-working-hours.service';
|
||||
|
||||
@ApiTags('staff')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@Controller('staff')
|
||||
export class StaffController {
|
||||
constructor(private readonly staffService: StaffService) {}
|
||||
constructor(
|
||||
private readonly staffService: StaffService,
|
||||
private readonly staffWorkingHoursService: StaffWorkingHoursService,
|
||||
) {}
|
||||
|
||||
@Get('invitations/preview')
|
||||
@ApiOperation({ summary: 'Preview invite info by token (public)' })
|
||||
@@ -80,6 +86,38 @@ export class StaffController {
|
||||
return this.staffService.updateMember(req.user.id, organizationId, membershipId, dto);
|
||||
}
|
||||
|
||||
@Get('members/:membershipId/working-hours')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Get weekly working hours for a staff member' })
|
||||
getWorkingHours(
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
@Param('membershipId') membershipId: string,
|
||||
) {
|
||||
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
|
||||
return this.staffWorkingHoursService.getWorkingHours(
|
||||
req.user.id,
|
||||
organizationId,
|
||||
membershipId,
|
||||
);
|
||||
}
|
||||
|
||||
@Put('members/:membershipId/working-hours')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Save weekly working hours for a staff member' })
|
||||
upsertWorkingHours(
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
@Param('membershipId') membershipId: string,
|
||||
@Body() dto: UpsertWorkingHoursDto,
|
||||
) {
|
||||
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
|
||||
return this.staffWorkingHoursService.upsertWorkingHours(
|
||||
req.user.id,
|
||||
organizationId,
|
||||
membershipId,
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch('members/:membershipId/enable')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { StaffController } from './staff.controller';
|
||||
import { StaffService } from './staff.service';
|
||||
import { StaffWorkingHoursService } from './staff-working-hours.service';
|
||||
|
||||
@Module({
|
||||
controllers: [StaffController],
|
||||
providers: [StaffService, PrismaService],
|
||||
providers: [StaffService, StaffWorkingHoursService, PrismaService],
|
||||
exports: [StaffWorkingHoursService],
|
||||
})
|
||||
export class StaffModule {}
|
||||
|
||||
Reference in New Issue
Block a user