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

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