Merge branch 'master' into feature/tab-warning-flag
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_working_hours_schedules" (
|
||||
"id" TEXT NOT NULL,
|
||||
"membershipId" TEXT NOT NULL,
|
||||
"autoRepeatWeekly" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "staff_working_hours_schedules_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "staff_working_hours_blocks" (
|
||||
"id" TEXT NOT NULL,
|
||||
"scheduleId" TEXT NOT NULL,
|
||||
"dayOfWeek" INTEGER NOT NULL,
|
||||
"startMinute" INTEGER NOT NULL,
|
||||
"endMinute" INTEGER NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "staff_working_hours_blocks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "staff_working_hours_schedules_membershipId_key" ON "staff_working_hours_schedules"("membershipId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "staff_working_hours_blocks_scheduleId_dayOfWeek_sortOrder_idx" ON "staff_working_hours_blocks"("scheduleId", "dayOfWeek", "sortOrder");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_working_hours_schedules" ADD CONSTRAINT "staff_working_hours_schedules_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "memberships"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "staff_working_hours_blocks" ADD CONSTRAINT "staff_working_hours_blocks_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "staff_working_hours_schedules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "language" TEXT NOT NULL DEFAULT 'en';
|
||||
@@ -15,6 +15,7 @@ model User {
|
||||
googleId String? @unique
|
||||
facebookId String? @unique
|
||||
name String
|
||||
language String @default("en")
|
||||
trialUsedAt DateTime?
|
||||
|
||||
memberships Membership[]
|
||||
@@ -217,6 +218,7 @@ model Membership {
|
||||
|
||||
permissions MembershipPermission[]
|
||||
invitations StaffInvitation[]
|
||||
workingHoursSchedule StaffWorkingHoursSchedule?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -225,6 +227,34 @@ model Membership {
|
||||
@@map("memberships")
|
||||
}
|
||||
|
||||
model StaffWorkingHoursSchedule {
|
||||
id String @id @default(uuid())
|
||||
membershipId String @unique
|
||||
autoRepeatWeekly Boolean @default(true)
|
||||
|
||||
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
|
||||
blocks StaffWorkingHoursBlock[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("staff_working_hours_schedules")
|
||||
}
|
||||
|
||||
model StaffWorkingHoursBlock {
|
||||
id String @id @default(uuid())
|
||||
scheduleId String
|
||||
dayOfWeek Int
|
||||
startMinute Int
|
||||
endMinute Int
|
||||
sortOrder Int @default(0)
|
||||
|
||||
schedule StaffWorkingHoursSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([scheduleId, dayOfWeek, sortOrder])
|
||||
@@map("staff_working_hours_blocks")
|
||||
}
|
||||
|
||||
model StaffInvitation {
|
||||
id String @id @default(uuid())
|
||||
|
||||
|
||||
118
backend/src/common/working-hours.ts
Normal file
118
backend/src/common/working-hours.ts
Normal 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 };
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
Res,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Get
|
||||
Get,
|
||||
Patch,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ import { RegisterDto } from './dto/register.dto';
|
||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { LocalAuthGuard } from './guards/local-auth.guard';
|
||||
import { UpdateLanguageDto } from './dto/update-language.dto';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
@@ -149,6 +151,14 @@ export class AuthController {
|
||||
return this.authService.getProfile(req.user.id);
|
||||
}
|
||||
|
||||
@Patch('profile/language')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update user language preference' })
|
||||
async updateLanguage(@Req() req, @Body() dto: UpdateLanguageDto) {
|
||||
return this.authService.updateLanguage(req.user.id, dto);
|
||||
}
|
||||
|
||||
@Get('subscription-alert')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
|
||||
@@ -15,6 +15,10 @@ import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||
import {
|
||||
SUPPORTED_USER_LANGUAGES,
|
||||
UpdateLanguageDto,
|
||||
} from './dto/update-language.dto';
|
||||
import { JwtPayload } from './interfaces/jwt-payload.interface';
|
||||
|
||||
const ALL_PERMISSIONS = [
|
||||
@@ -179,11 +183,7 @@ export class AuthService {
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
user: this.toPublicUser(user),
|
||||
organizations,
|
||||
},
|
||||
};
|
||||
@@ -343,11 +343,6 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user profile with all memberships and permissions
|
||||
* @param userId - User ID from JWT token
|
||||
* @returns User profile with organizations and permissions
|
||||
*/
|
||||
async getProfile(userId: string) {
|
||||
try {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
@@ -516,11 +511,7 @@ export class AuthService {
|
||||
success: true,
|
||||
data: {
|
||||
accessToken: newAccessToken,
|
||||
user: {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
},
|
||||
user: this.toPublicUser(session.user),
|
||||
organizations,
|
||||
},
|
||||
};
|
||||
@@ -931,4 +922,44 @@ export class AuthService {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async updateLanguage(userId: string, dto: UpdateLanguageDto) {
|
||||
const language = dto.language;
|
||||
|
||||
if (!SUPPORTED_USER_LANGUAGES.includes(language)) {
|
||||
throw new BadRequestException('Language must be one of: en, fa, nl');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { language },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
language: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
user: this.toPublicUser(user),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private toPublicUser(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
language?: string | null;
|
||||
}) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
language: user.language ?? 'en',
|
||||
};
|
||||
}
|
||||
}
|
||||
14
backend/src/modules/auth/dto/update-language.dto.ts
Normal file
14
backend/src/modules/auth/dto/update-language.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsIn, IsString } from 'class-validator';
|
||||
|
||||
export const SUPPORTED_USER_LANGUAGES = ['en', 'fa', 'nl'] as const;
|
||||
export type SupportedUserLanguage = (typeof SUPPORTED_USER_LANGUAGES)[number];
|
||||
|
||||
export class UpdateLanguageDto {
|
||||
@ApiProperty({ enum: SUPPORTED_USER_LANGUAGES, example: 'en' })
|
||||
@IsString()
|
||||
@IsIn(SUPPORTED_USER_LANGUAGES, {
|
||||
message: 'Language must be one of: en, fa, nl',
|
||||
})
|
||||
language: SupportedUserLanguage;
|
||||
}
|
||||
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[];
|
||||
}
|
||||
264
backend/src/modules/staff/staff-working-hours.service.ts
Normal file
264
backend/src/modules/staff/staff-working-hours.service.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
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';
|
||||
|
||||
@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.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 findMembership(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 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