407 lines
13 KiB
TypeScript
407 lines
13 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
HttpStatus,
|
|
Injectable,
|
|
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';
|
|
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
|
import { hasEffectivePermission } from '../../common/membership-permissions';
|
|
import { AppException, ErrorCode } from '../../common/errors';
|
|
|
|
const MS_PER_DAY = 86_400_000;
|
|
|
|
@Injectable()
|
|
export class AppointmentsService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly staffWorkingHoursService: StaffWorkingHoursService,
|
|
private readonly treatmentCatalog: TreatmentCatalogService,
|
|
) {}
|
|
|
|
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
|
if (!user?.organizationId) {
|
|
throw new BadRequestException('Organization is not selected');
|
|
}
|
|
return user.organizationId;
|
|
}
|
|
|
|
async listColumnProviders(organizationId: string, actorUserId: string, date?: string) {
|
|
await this.assertCanViewAppointments(actorUserId, organizationId);
|
|
|
|
const members = await this.prisma.membership.findMany({
|
|
where: {
|
|
organizationId,
|
|
OR: [{ isOwner: true }, { isActive: true }],
|
|
permissions: {
|
|
some: {
|
|
permission: {
|
|
name: 'TAB_TREATMENT_EDIT',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
include: {
|
|
user: { select: { id: true, name: true } },
|
|
},
|
|
orderBy: [{ createdAt: 'asc' }],
|
|
});
|
|
|
|
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 };
|
|
}
|
|
|
|
async list(query: ListAppointmentsDto, organizationId: string, actorUserId: string) {
|
|
const { scopeToProvider } = await this.assertCanListAppointmentsForTreatment(
|
|
actorUserId,
|
|
organizationId,
|
|
);
|
|
|
|
const from = new Date(query.from);
|
|
const to = new Date(query.to);
|
|
|
|
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
|
|
throw new BadRequestException('Invalid date range');
|
|
}
|
|
|
|
if (to <= from) {
|
|
throw new BadRequestException('Range "to" must be after "from"');
|
|
}
|
|
|
|
const items = await this.prisma.appointment.findMany({
|
|
where: {
|
|
organizationId,
|
|
startAt: { lt: to },
|
|
endAt: { gt: from },
|
|
...(scopeToProvider ? { providerUserId: actorUserId } : {}),
|
|
},
|
|
include: {
|
|
patient: {
|
|
select: { id: true, firstName: true, lastName: true, mobile: true },
|
|
},
|
|
treatment: { select: { id: true } },
|
|
},
|
|
orderBy: [{ startAt: 'asc' }],
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
data: items.map(({ treatment, ...appointment }) => ({
|
|
...appointment,
|
|
hasTreatment: Boolean(treatment),
|
|
})),
|
|
};
|
|
}
|
|
|
|
async create(
|
|
dto: CreateAppointmentDto,
|
|
organizationId: string,
|
|
actorUserId: string,
|
|
) {
|
|
await this.assertCanEditAppointments(actorUserId, organizationId);
|
|
|
|
const startAt = new Date(dto.startAt);
|
|
const endAt = new Date(dto.endAt);
|
|
|
|
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) {
|
|
throw new BadRequestException('Invalid start or end time');
|
|
}
|
|
|
|
if (endAt <= startAt) {
|
|
throw new BadRequestException('End time must be after start time');
|
|
}
|
|
|
|
if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) {
|
|
throw new BadRequestException('Appointment cannot span more than 24 hours');
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (startAt.getTime() < now) {
|
|
throw new BadRequestException('Cannot schedule appointments in the past');
|
|
}
|
|
|
|
await this.ensurePatientInOrg(dto.patientId, organizationId);
|
|
await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId);
|
|
this.treatmentCatalog.assertKnownTreatmentType(dto.purpose);
|
|
await this.ensureAppointmentWithinProviderWorkingHours(
|
|
dto.providerUserId,
|
|
organizationId,
|
|
startAt,
|
|
endAt,
|
|
);
|
|
|
|
const appointment = await this.prisma.appointment.create({
|
|
data: {
|
|
organizationId,
|
|
patientId: dto.patientId,
|
|
providerUserId: dto.providerUserId,
|
|
startAt,
|
|
endAt,
|
|
purpose: dto.purpose,
|
|
},
|
|
include: {
|
|
patient: {
|
|
select: { id: true, firstName: true, lastName: true, mobile: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
return { success: true, data: appointment };
|
|
}
|
|
|
|
async update(
|
|
id: string,
|
|
dto: UpdateAppointmentDto,
|
|
organizationId: string,
|
|
actorUserId: string,
|
|
) {
|
|
await this.assertCanEditAppointments(actorUserId, organizationId);
|
|
|
|
const existing = await this.prisma.appointment.findFirst({
|
|
where: { id, organizationId },
|
|
});
|
|
|
|
if (!existing) {
|
|
throw new NotFoundException('Appointment not found');
|
|
}
|
|
|
|
const startAt = dto.startAt ? new Date(dto.startAt) : existing.startAt;
|
|
const endAt = dto.endAt ? new Date(dto.endAt) : existing.endAt;
|
|
|
|
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) {
|
|
throw new BadRequestException('Invalid start or end time');
|
|
}
|
|
|
|
if (endAt <= startAt) {
|
|
throw new BadRequestException('End time must be after start time');
|
|
}
|
|
|
|
if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) {
|
|
throw new BadRequestException('Appointment cannot span more than 24 hours');
|
|
}
|
|
|
|
const patientId = dto.patientId ?? existing.patientId;
|
|
const providerUserId = dto.providerUserId ?? existing.providerUserId;
|
|
const purpose = dto.purpose ?? existing.purpose;
|
|
|
|
if (dto.patientId && dto.patientId !== existing.patientId) {
|
|
const linkedTreatment = await this.prisma.treatment.findUnique({
|
|
where: { appointmentId: id },
|
|
select: { id: true },
|
|
});
|
|
if (linkedTreatment) {
|
|
throw new AppException(ErrorCode.APPOINTMENT_PATIENT_LOCKED, HttpStatus.CONFLICT);
|
|
}
|
|
}
|
|
|
|
this.treatmentCatalog.assertKnownTreatmentType(purpose);
|
|
|
|
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 },
|
|
data: {
|
|
patientId,
|
|
providerUserId,
|
|
startAt,
|
|
endAt,
|
|
purpose,
|
|
},
|
|
include: {
|
|
patient: {
|
|
select: { id: true, firstName: true, lastName: true, mobile: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
return { success: true, data: appointment };
|
|
}
|
|
|
|
async remove(id: string, organizationId: string, actorUserId: string) {
|
|
await this.assertCanEditAppointments(actorUserId, organizationId);
|
|
|
|
const existing = await this.prisma.appointment.findFirst({
|
|
where: { id, organizationId },
|
|
select: { id: true, treatment: { select: { id: true } } },
|
|
});
|
|
|
|
if (!existing) {
|
|
throw new NotFoundException('Appointment not found');
|
|
}
|
|
|
|
if (existing.treatment) {
|
|
throw new AppException(ErrorCode.APPOINTMENT_HAS_TREATMENT, HttpStatus.CONFLICT);
|
|
}
|
|
|
|
await this.prisma.appointment.delete({
|
|
where: { id },
|
|
});
|
|
|
|
return { success: true };
|
|
}
|
|
|
|
private async assertCanViewAppointments(userId: string, organizationId: string) {
|
|
const m = await this.getMembership(userId, organizationId);
|
|
if (!m) {
|
|
throw new ForbiddenException('You are not a member of this organization');
|
|
}
|
|
if (m.isOwner) {
|
|
return;
|
|
}
|
|
const names = m.permissions.map((p) => p.permission.name);
|
|
if (names.includes('TAB_APPOINTMENTS_READ') || names.includes('TAB_APPOINTMENTS_EDIT')) {
|
|
return;
|
|
}
|
|
throw new ForbiddenException('You do not have access to appointments');
|
|
}
|
|
|
|
private async assertCanListAppointmentsForTreatment(
|
|
userId: string,
|
|
organizationId: string,
|
|
) {
|
|
const m = await this.getMembership(userId, organizationId);
|
|
if (!m) {
|
|
throw new ForbiddenException('You are not a member of this organization');
|
|
}
|
|
if (m.isOwner) {
|
|
return { membership: m, scopeToProvider: false as const };
|
|
}
|
|
const names = m.permissions.map((p) => p.permission.name);
|
|
const canViewSchedule =
|
|
names.includes('TAB_APPOINTMENTS_READ') || names.includes('TAB_APPOINTMENTS_EDIT');
|
|
const canViewTreatment =
|
|
names.includes('TAB_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT');
|
|
if (!canViewSchedule && !canViewTreatment) {
|
|
throw new ForbiddenException('You do not have access to appointments');
|
|
}
|
|
return { membership: m, scopeToProvider: !canViewSchedule && canViewTreatment };
|
|
}
|
|
|
|
private async assertCanEditAppointments(userId: string, organizationId: string) {
|
|
const m = await this.getMembership(userId, organizationId);
|
|
if (!m) {
|
|
throw new ForbiddenException('You are not a member of this organization');
|
|
}
|
|
if (m.isOwner) {
|
|
return;
|
|
}
|
|
const names = m.permissions.map((p) => p.permission.name);
|
|
if (names.includes('TAB_APPOINTMENTS_EDIT')) {
|
|
return;
|
|
}
|
|
throw new ForbiddenException('You cannot create or modify appointments');
|
|
}
|
|
|
|
private async ensureProviderIsTreatmentEditor(providerUserId: string, organizationId: string) {
|
|
const m = await this.getMembership(providerUserId, organizationId);
|
|
if (!m) {
|
|
throw new BadRequestException('Provider is not a member of this organization');
|
|
}
|
|
if (!m.isOwner && !m.isActive) {
|
|
throw new BadRequestException('Provider is not an active staff member');
|
|
}
|
|
if (!hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')) {
|
|
throw new BadRequestException('Provider does not have treatment edit access');
|
|
}
|
|
}
|
|
|
|
private async ensurePatientInOrg(patientId: string, _organizationId: string) {
|
|
const patient = await this.prisma.patient.findUnique({
|
|
where: { id: patientId },
|
|
select: { id: true },
|
|
});
|
|
if (!patient) {
|
|
throw new NotFoundException('Patient not found');
|
|
}
|
|
}
|
|
|
|
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,
|
|
OR: [{ isOwner: true }, { isActive: true }],
|
|
},
|
|
include: {
|
|
permissions: { include: { permission: true } },
|
|
organization: { include: { type: true, plan: true } },
|
|
},
|
|
});
|
|
}
|
|
}
|