Files
dyolink/backend/src/modules/appointments/appointments.service.ts

419 lines
14 KiB
TypeScript
Raw Normal View History

import {
HttpStatus,
Injectable,
} from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import {
appointmentWithinWorkingHours,
blocksForDay,
localDayOfWeekMondayZero,
} from '../../common/working-hours';
import { civilDateJsWeekday, isValidIanaTimeZone, zonedWeekdayAndMinutes } from '../../common/zoned-civil-time';
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 AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
}
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 AppException(ErrorCode.APPOINTMENT_INVALID_RANGE, HttpStatus.BAD_REQUEST);
}
if (to <= from) {
throw new AppException(ErrorCode.APPOINTMENT_INVALID_RANGE_ORDER, HttpStatus.BAD_REQUEST);
}
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);
const timeZone = this.requireTimeZone(dto.timeZone);
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) {
throw new AppException(ErrorCode.APPOINTMENT_INVALID_TIME, HttpStatus.BAD_REQUEST);
}
if (endAt <= startAt) {
throw new AppException(ErrorCode.APPOINTMENT_END_BEFORE_START, HttpStatus.BAD_REQUEST);
}
if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) {
throw new AppException(ErrorCode.APPOINTMENT_TOO_LONG, HttpStatus.BAD_REQUEST);
}
const now = Date.now();
if (startAt.getTime() < now) {
throw new AppException(ErrorCode.APPOINTMENT_IN_PAST, HttpStatus.BAD_REQUEST);
}
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,
timeZone,
);
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 AppException(ErrorCode.APPOINTMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
const startAt = dto.startAt ? new Date(dto.startAt) : existing.startAt;
const endAt = dto.endAt ? new Date(dto.endAt) : existing.endAt;
const timeZone = this.requireTimeZone(dto.timeZone);
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) {
throw new AppException(ErrorCode.APPOINTMENT_INVALID_TIME, HttpStatus.BAD_REQUEST);
}
if (endAt <= startAt) {
throw new AppException(ErrorCode.APPOINTMENT_END_BEFORE_START, HttpStatus.BAD_REQUEST);
}
if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) {
throw new AppException(ErrorCode.APPOINTMENT_TOO_LONG, HttpStatus.BAD_REQUEST);
}
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,
timeZone,
);
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 AppException(ErrorCode.APPOINTMENT_NOT_FOUND, HttpStatus.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 AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
}
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 AppException(ErrorCode.PERMISSION_ACCESS_APPOINTMENTS, HttpStatus.FORBIDDEN);
}
private async assertCanListAppointmentsForTreatment(
userId: string,
organizationId: string,
) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
}
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 AppException(ErrorCode.PERMISSION_ACCESS_APPOINTMENTS, HttpStatus.FORBIDDEN);
}
return { membership: m, scopeToProvider: !canViewSchedule && canViewTreatment };
}
private async assertCanEditAppointments(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
}
if (m.isOwner) {
return;
}
const names = m.permissions.map((p) => p.permission.name);
if (names.includes('TAB_APPOINTMENTS_EDIT')) {
return;
}
throw new AppException(ErrorCode.PERMISSION_EDIT_APPOINTMENTS, HttpStatus.FORBIDDEN);
}
private async ensureProviderIsTreatmentEditor(providerUserId: string, organizationId: string) {
const m = await this.getMembership(providerUserId, organizationId);
if (!m) {
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NOT_MEMBER, HttpStatus.BAD_REQUEST);
}
if (!m.isOwner && !m.isActive) {
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_INACTIVE, HttpStatus.BAD_REQUEST);
}
if (!hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')) {
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT, HttpStatus.BAD_REQUEST);
}
}
private async ensurePatientInOrg(patientId: string, organizationId: string) {
const patient = await this.prisma.patient.findFirst({
where: {
id: patientId,
isWalkIn: false,
createdByOrganizationId: organizationId,
},
select: { id: true },
});
if (!patient) {
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
}
private requireTimeZone(timeZone: string | undefined): string {
if (!timeZone || !isValidIanaTimeZone(timeZone)) {
throw new AppException(ErrorCode.VALIDATION_TIMEZONE_INVALID, HttpStatus.BAD_REQUEST);
}
return timeZone;
}
private resolveDayOfWeekMondayZero(date?: string): number {
if (!date) {
return localDayOfWeekMondayZero(new Date().getUTCDay());
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new AppException(ErrorCode.APPOINTMENT_INVALID_DATE, HttpStatus.BAD_REQUEST);
}
return localDayOfWeekMondayZero(civilDateJsWeekday(date));
}
private async ensureAppointmentWithinProviderWorkingHours(
providerUserId: string,
organizationId: string,
startAt: Date,
endAt: Date,
timeZone: string,
) {
const membership = await this.getMembership(providerUserId, organizationId);
if (!membership) {
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NOT_MEMBER, HttpStatus.BAD_REQUEST);
}
const scheduleBlocksByMembership =
await this.staffWorkingHoursService.loadScheduleBlocksByMembershipIds([membership.id]);
const allBlocks = scheduleBlocksByMembership.get(membership.id) ?? [];
if (allBlocks.length === 0) {
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NO_WORKING_HOURS, HttpStatus.BAD_REQUEST);
}
const dayOfWeek = localDayOfWeekMondayZero(zonedWeekdayAndMinutes(startAt, timeZone).jsWeekday);
const dayBlocks = blocksForDay(allBlocks, dayOfWeek);
if (dayBlocks.length === 0) {
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NOT_WORKING_DAY, HttpStatus.BAD_REQUEST);
}
if (!appointmentWithinWorkingHours(startAt, endAt, dayBlocks, timeZone)) {
throw new AppException(ErrorCode.APPOINTMENT_OUTSIDE_WORKING_HOURS, HttpStatus.BAD_REQUEST);
}
}
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 } },
},
});
}
}