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:
2026-06-10 19:44:09 +03:30
parent 468572a50f
commit 48ecfd05e1
22 changed files with 1571 additions and 116 deletions

View File

@@ -0,0 +1,118 @@
export const MINUTES_PER_DAY = 24 * 60;
export type WorkingHoursBlockInput = {
dayOfWeek: number;
startMinute: number;
endMinute: number;
sortOrder?: number;
};
export type WorkingHoursDayBlock = {
startMinute: number;
endMinute: number;
};
export function localDayOfWeekMondayZero(dayOfWeekJs: number): number {
return dayOfWeekJs === 0 ? 6 : dayOfWeekJs - 1;
}
export function validateWorkingHoursBlocks(blocks: WorkingHoursBlockInput[]): string | null {
if (!Array.isArray(blocks)) {
return 'Working hours blocks must be an array';
}
const byDay = new Map<number, WorkingHoursBlockInput[]>();
for (const block of blocks) {
if (!Number.isInteger(block.dayOfWeek) || block.dayOfWeek < 0 || block.dayOfWeek > 6) {
return 'dayOfWeek must be an integer from 0 (Monday) to 6 (Sunday)';
}
if (!Number.isInteger(block.startMinute) || block.startMinute < 0 || block.startMinute >= MINUTES_PER_DAY) {
return 'startMinute must be between 0 and 1439';
}
if (!Number.isInteger(block.endMinute) || block.endMinute <= 0 || block.endMinute > MINUTES_PER_DAY) {
return 'endMinute must be between 1 and 1440';
}
if (block.endMinute <= block.startMinute) {
return 'Each shift end time must be after its start time';
}
const list = byDay.get(block.dayOfWeek) ?? [];
list.push(block);
byDay.set(block.dayOfWeek, list);
}
for (const [, dayBlocks] of byDay) {
const sorted = [...dayBlocks].sort((a, b) => a.startMinute - b.startMinute);
for (let i = 1; i < sorted.length; i += 1) {
if (sorted[i].startMinute < sorted[i - 1].endMinute) {
return 'Shifts on the same day cannot overlap';
}
}
}
return null;
}
export function blocksForDay(
blocks: WorkingHoursBlockInput[],
dayOfWeek: number,
): WorkingHoursDayBlock[] {
return blocks
.filter((b) => b.dayOfWeek === dayOfWeek)
.sort((a, b) => a.startMinute - b.startMinute || a.endMinute - b.endMinute)
.map((b) => ({ startMinute: b.startMinute, endMinute: b.endMinute }));
}
export function isMinuteWithinWorkingBlocks(minute: number, dayBlocks: WorkingHoursDayBlock[]): boolean {
return dayBlocks.some((b) => minute >= b.startMinute && minute < b.endMinute);
}
export function isRangeWithinWorkingBlocks(
startMinute: number,
endMinute: number,
dayBlocks: WorkingHoursDayBlock[],
): boolean {
if (endMinute <= startMinute) {
return false;
}
for (let m = startMinute; m < endMinute; m += 1) {
if (!isMinuteWithinWorkingBlocks(m, dayBlocks)) {
return false;
}
}
return true;
}
export function dateToLocalMinutes(date: Date): number {
return date.getHours() * 60 + date.getMinutes();
}
export function appointmentWithinWorkingHours(
startAt: Date,
endAt: Date,
dayBlocks: WorkingHoursDayBlock[],
): boolean {
const startMinute = dateToLocalMinutes(startAt);
const endMinute = dateToLocalMinutes(endAt);
return isRangeWithinWorkingBlocks(startMinute, endMinute, dayBlocks);
}
export function unionDayBlockRange(dayBlocksList: WorkingHoursDayBlock[][]): {
startMinute: number;
endMinute: number;
} | null {
let startMinute: number | null = null;
let endMinute: number | null = null;
for (const dayBlocks of dayBlocksList) {
for (const block of dayBlocks) {
startMinute = startMinute == null ? block.startMinute : Math.min(startMinute, block.startMinute);
endMinute = endMinute == null ? block.endMinute : Math.max(endMinute, block.endMinute);
}
}
if (startMinute == null || endMinute == null) {
return null;
}
return { startMinute, endMinute };
}

View File

@@ -13,6 +13,7 @@ import {
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AppointmentsService } from './appointments.service';
import { ColumnProvidersQueryDto } from './dto/column-providers-query.dto';
import { CreateAppointmentDto } from './dto/create-appointment.dto';
import { ListAppointmentsDto } from './dto/list-appointments.dto';
import { UpdateAppointmentDto } from './dto/update-appointment.dto';
@@ -29,9 +30,16 @@ export class AppointmentsController {
summary:
'Staff columns: active non-owner members with TAB_TREATMENT_EDIT. Owners are excluded. Requires TAB_APPOINTMENTS_READ or owner.',
})
columnProviders(@Req() req: { user: { id: string; organizationId?: string } }) {
columnProviders(
@Query() query: ColumnProvidersQueryDto,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.appointmentsService.getOrganizationIdFromUser(req.user);
return this.appointmentsService.listColumnProviders(organizationId, req.user.id);
return this.appointmentsService.listColumnProviders(
organizationId,
req.user.id,
query.date,
);
}
@Get()

View File

@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { StaffModule } from '../staff/staff.module';
import { AppointmentsController } from './appointments.controller';
import { AppointmentsService } from './appointments.service';
@Module({
imports: [StaffModule],
controllers: [AppointmentsController],
providers: [AppointmentsService, PrismaService],
})

View File

@@ -5,6 +5,12 @@ import {
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import {
appointmentWithinWorkingHours,
blocksForDay,
localDayOfWeekMondayZero,
} from '../../common/working-hours';
import { StaffWorkingHoursService } from '../staff/staff-working-hours.service';
import { CreateAppointmentDto } from './dto/create-appointment.dto';
import { ListAppointmentsDto } from './dto/list-appointments.dto';
import { UpdateAppointmentDto } from './dto/update-appointment.dto';
@@ -13,7 +19,10 @@ const MS_PER_DAY = 86_400_000;
@Injectable()
export class AppointmentsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly staffWorkingHoursService: StaffWorkingHoursService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
@@ -22,7 +31,7 @@ export class AppointmentsService {
return user.organizationId;
}
async listColumnProviders(organizationId: string, actorUserId: string) {
async listColumnProviders(organizationId: string, actorUserId: string, date?: string) {
await this.assertCanViewAppointments(actorUserId, organizationId);
const members = await this.prisma.membership.findMany({
@@ -44,7 +53,23 @@ export class AppointmentsService {
orderBy: [{ createdAt: 'asc' }],
});
const data = members.map((m) => ({ userId: m.user.id, name: m.user.name }));
const scheduleBlocksByMembership =
await this.staffWorkingHoursService.loadScheduleBlocksByMembershipIds(
members.map((m) => m.id),
);
const dayOfWeek = this.resolveDayOfWeekMondayZero(date);
const data = members.map((m) => {
const allBlocks = scheduleBlocksByMembership.get(m.id) ?? [];
const dayBlocks = blocksForDay(allBlocks, dayOfWeek);
return {
userId: m.user.id,
name: m.user.name,
hasWorkingHours: allBlocks.length > 0,
dayBlocks,
};
});
return { success: true, data };
}
@@ -109,6 +134,12 @@ export class AppointmentsService {
await this.ensurePatientInOrg(dto.patientId, organizationId);
await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId);
await this.ensureAppointmentWithinProviderWorkingHours(
dto.providerUserId,
organizationId,
startAt,
endAt,
);
const appointment = await this.prisma.appointment.create({
data: {
@@ -166,6 +197,12 @@ export class AppointmentsService {
await this.ensurePatientInOrg(patientId, organizationId);
await this.ensureProviderIsTreatmentEditor(providerUserId, organizationId);
await this.ensureAppointmentWithinProviderWorkingHours(
providerUserId,
organizationId,
startAt,
endAt,
);
const appointment = await this.prisma.appointment.update({
where: { id },
@@ -273,6 +310,47 @@ export class AppointmentsService {
}
}
private resolveDayOfWeekMondayZero(date?: string): number {
if (!date) {
return localDayOfWeekMondayZero(new Date().getDay());
}
const [y, m, d] = date.split('-').map(Number);
const parsed = new Date(y, m - 1, d, 12, 0, 0, 0);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException('Invalid date query parameter');
}
return localDayOfWeekMondayZero(parsed.getDay());
}
private async ensureAppointmentWithinProviderWorkingHours(
providerUserId: string,
organizationId: string,
startAt: Date,
endAt: Date,
) {
const membership = await this.getMembership(providerUserId, organizationId);
if (!membership) {
throw new BadRequestException('Provider is not a member of this organization');
}
const scheduleBlocksByMembership =
await this.staffWorkingHoursService.loadScheduleBlocksByMembershipIds([membership.id]);
const allBlocks = scheduleBlocksByMembership.get(membership.id) ?? [];
if (allBlocks.length === 0) {
throw new BadRequestException('Provider has no working hours configured');
}
const dayOfWeek = localDayOfWeekMondayZero(startAt.getDay());
const dayBlocks = blocksForDay(allBlocks, dayOfWeek);
if (dayBlocks.length === 0) {
throw new BadRequestException('Provider is not working on this day');
}
if (!appointmentWithinWorkingHours(startAt, endAt, dayBlocks)) {
throw new BadRequestException('Appointment must fall within the provider working hours');
}
}
private async getMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId },

View File

@@ -0,0 +1,9 @@
import { IsOptional, IsString, Matches } from 'class-validator';
export class ColumnProvidersQueryDto {
/** Local calendar date (YYYY-MM-DD) used to resolve weekday working hours. */
@IsOptional()
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
date?: string;
}

View 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[];
}

View 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');
}
}

View File

@@ -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({

View File

@@ -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 {}