2026-06-10 19:44:09 +03:30
import {
BadRequestException ,
ForbiddenException ,
Injectable ,
NotFoundException ,
} from '@nestjs/common' ;
import { PrismaService } from '../../../prisma/prisma.service' ;
import {
2026-06-20 00:44:49 +03:30
appointmentWithinWorkingHours ,
2026-06-10 19:44:09 +03:30
blocksForDay ,
2026-06-20 00:44:49 +03:30
localDayOfWeekMondayZero ,
2026-06-10 19:44:09 +03:30
validateWorkingHoursBlocks ,
type WorkingHoursBlockInput ,
} from '../../common/working-hours' ;
import { UpsertWorkingHoursDto } from './dto/upsert-working-hours.dto' ;
2026-07-12 15:11:50 +03:30
import {
hasEffectivePermission ,
} from '../../common/membership-permissions' ;
2026-06-10 19:44:09 +03:30
@Injectable ( )
export class StaffWorkingHoursService {
constructor ( private readonly prisma : PrismaService ) { }
async getWorkingHours ( actorUserId : string , organizationId : string , membershipId : string ) {
await this . assertCanViewStaff ( actorUserId , organizationId ) ;
2026-07-12 15:11:50 +03:30
const membership = await this . findStaffMembership ( membershipId , organizationId ) ;
2026-06-10 19:44:09 +03:30
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 ) ;
2026-07-12 15:11:50 +03:30
const membership = await this . findStaffMembership ( membershipId , organizationId ) ;
return this . persistWorkingHours ( membership , organizationId , dto ) ;
}
async getMyWorkingHours ( userId : string , organizationId : string ) {
const membership = await this . findOwnerMembership ( userId , organizationId ) ;
await this . assertOwnerCanManageWorkingHours ( membership ) ;
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 upsertMyWorkingHours (
userId : string ,
organizationId : string ,
dto : UpsertWorkingHoursDto ,
) {
const membership = await this . findOwnerMembership ( userId , organizationId ) ;
await this . assertOwnerCanManageWorkingHours ( membership ) ;
return this . persistWorkingHours ( membership , organizationId , dto ) ;
}
private async persistWorkingHours (
membership : { id : string ; userId : string } ,
organizationId : string ,
dto : UpsertWorkingHoursDto ,
) {
2026-06-10 19:44:09 +03:30
const validationError = validateWorkingHoursBlocks ( dto . blocks ) ;
if ( validationError ) {
throw new BadRequestException ( validationError ) ;
}
const normalizedBlocks = this . normalizeBlocks ( dto . blocks ) ;
2026-06-20 00:44:49 +03:30
await this . assertNoConflictingAppointments (
organizationId ,
membership . userId ,
normalizedBlocks ,
) ;
2026-06-10 19:44:09 +03:30
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 ,
} ) ) ;
}
2026-06-20 00:44:49 +03:30
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. ` ,
) ;
}
2026-07-12 15:11:50 +03:30
private async findStaffMembership ( membershipId : string , organizationId : string ) {
2026-06-10 19:44:09 +03:30
const membership = await this . prisma . membership . findFirst ( {
where : { id : membershipId , organizationId } ,
2026-06-20 00:44:49 +03:30
select : { id : true , isOwner : true , userId : true } ,
2026-06-10 19:44:09 +03:30
} ) ;
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 ;
}
2026-07-12 15:11:50 +03:30
private async findOwnerMembership ( userId : string , organizationId : string ) {
const membership = await this . prisma . membership . findFirst ( {
where : { userId , organizationId , isOwner : true } ,
include : {
permissions : { include : { permission : true } } ,
organization : { include : { type : true , plan : true } } ,
} ,
} ) ;
if ( ! membership ) {
throw new ForbiddenException ( 'Only organization owners can manage their working hours' ) ;
}
return membership ;
}
private async assertOwnerCanManageWorkingHours ( membership : {
isOwner : boolean ;
permissions : { permission : { name : string } } [ ] ;
organization : { type : { name : string } ; plan ? : { name : string } | null ; planId? : string | null } ;
} ) {
if ( ! hasEffectivePermission ( membership , 'TAB_TREATMENT_EDIT' ) ) {
throw new ForbiddenException (
'Enable treatment participation before setting working hours' ,
) ;
}
}
2026-06-10 19:44:09 +03:30
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' ) ;
}
}