bugfix: appointment hours now use the client timezone on UTC servers.
Logical API errors throw stable codes so users see translated messages instead of a generic bad request. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import {
|
||||
@@ -11,6 +8,7 @@ import {
|
||||
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';
|
||||
@@ -31,7 +29,7 @@ export class AppointmentsService {
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return user.organizationId;
|
||||
}
|
||||
@@ -88,11 +86,11 @@ export class AppointmentsService {
|
||||
const to = new Date(query.to);
|
||||
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
|
||||
throw new BadRequestException('Invalid date range');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_INVALID_RANGE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (to <= from) {
|
||||
throw new BadRequestException('Range "to" must be after "from"');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_INVALID_RANGE_ORDER, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const items = await this.prisma.appointment.findMany({
|
||||
@@ -129,22 +127,23 @@ export class AppointmentsService {
|
||||
|
||||
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 BadRequestException('Invalid start or end time');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_INVALID_TIME, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (endAt <= startAt) {
|
||||
throw new BadRequestException('End time must be after start time');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_END_BEFORE_START, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) {
|
||||
throw new BadRequestException('Appointment cannot span more than 24 hours');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_TOO_LONG, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (startAt.getTime() < now) {
|
||||
throw new BadRequestException('Cannot schedule appointments in the past');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_IN_PAST, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
await this.ensurePatientInOrg(dto.patientId, organizationId);
|
||||
@@ -155,6 +154,7 @@ export class AppointmentsService {
|
||||
organizationId,
|
||||
startAt,
|
||||
endAt,
|
||||
timeZone,
|
||||
);
|
||||
|
||||
const appointment = await this.prisma.appointment.create({
|
||||
@@ -189,22 +189,23 @@ export class AppointmentsService {
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Appointment not found');
|
||||
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 BadRequestException('Invalid start or end time');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_INVALID_TIME, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (endAt <= startAt) {
|
||||
throw new BadRequestException('End time must be after start time');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_END_BEFORE_START, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) {
|
||||
throw new BadRequestException('Appointment cannot span more than 24 hours');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_TOO_LONG, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const patientId = dto.patientId ?? existing.patientId;
|
||||
@@ -230,6 +231,7 @@ export class AppointmentsService {
|
||||
organizationId,
|
||||
startAt,
|
||||
endAt,
|
||||
timeZone,
|
||||
);
|
||||
|
||||
const appointment = await this.prisma.appointment.update({
|
||||
@@ -260,7 +262,7 @@ export class AppointmentsService {
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Appointment not found');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (existing.treatment) {
|
||||
@@ -277,7 +279,7 @@ export class AppointmentsService {
|
||||
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');
|
||||
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (m.isOwner) {
|
||||
return;
|
||||
@@ -286,7 +288,7 @@ export class AppointmentsService {
|
||||
if (names.includes('TAB_APPOINTMENTS_READ') || names.includes('TAB_APPOINTMENTS_EDIT')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You do not have access to appointments');
|
||||
throw new AppException(ErrorCode.PERMISSION_ACCESS_APPOINTMENTS, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
private async assertCanListAppointmentsForTreatment(
|
||||
@@ -295,7 +297,7 @@ export class AppointmentsService {
|
||||
) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (m.isOwner) {
|
||||
return { membership: m, scopeToProvider: false as const };
|
||||
@@ -306,7 +308,7 @@ export class AppointmentsService {
|
||||
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');
|
||||
throw new AppException(ErrorCode.PERMISSION_ACCESS_APPOINTMENTS, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return { membership: m, scopeToProvider: !canViewSchedule && canViewTreatment };
|
||||
}
|
||||
@@ -314,7 +316,7 @@ export class AppointmentsService {
|
||||
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');
|
||||
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (m.isOwner) {
|
||||
return;
|
||||
@@ -323,19 +325,19 @@ export class AppointmentsService {
|
||||
if (names.includes('TAB_APPOINTMENTS_EDIT')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You cannot create or modify appointments');
|
||||
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 BadRequestException('Provider is not a member of this organization');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NOT_MEMBER, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (!m.isOwner && !m.isActive) {
|
||||
throw new BadRequestException('Provider is not an active staff member');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_INACTIVE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (!hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')) {
|
||||
throw new BadRequestException('Provider does not have treatment edit access');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,20 +347,25 @@ export class AppointmentsService {
|
||||
select: { id: true },
|
||||
});
|
||||
if (!patient) {
|
||||
throw new NotFoundException('Patient not found');
|
||||
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().getDay());
|
||||
return localDayOfWeekMondayZero(new Date().getUTCDay());
|
||||
}
|
||||
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');
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
throw new AppException(ErrorCode.APPOINTMENT_INVALID_DATE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return localDayOfWeekMondayZero(parsed.getDay());
|
||||
return localDayOfWeekMondayZero(civilDateJsWeekday(date));
|
||||
}
|
||||
|
||||
private async ensureAppointmentWithinProviderWorkingHours(
|
||||
@@ -366,27 +373,28 @@ export class AppointmentsService {
|
||||
organizationId: string,
|
||||
startAt: Date,
|
||||
endAt: Date,
|
||||
timeZone: string,
|
||||
) {
|
||||
const membership = await this.getMembership(providerUserId, organizationId);
|
||||
if (!membership) {
|
||||
throw new BadRequestException('Provider is not a member of this organization');
|
||||
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 BadRequestException('Provider has no working hours configured');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NO_WORKING_HOURS, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const dayOfWeek = localDayOfWeekMondayZero(startAt.getDay());
|
||||
const dayOfWeek = localDayOfWeekMondayZero(zonedWeekdayAndMinutes(startAt, timeZone).jsWeekday);
|
||||
const dayBlocks = blocksForDay(allBlocks, dayOfWeek);
|
||||
if (dayBlocks.length === 0) {
|
||||
throw new BadRequestException('Provider is not working on this day');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NOT_WORKING_DAY, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (!appointmentWithinWorkingHours(startAt, endAt, dayBlocks)) {
|
||||
throw new BadRequestException('Appointment must fall within the provider working hours');
|
||||
if (!appointmentWithinWorkingHours(startAt, endAt, dayBlocks, timeZone)) {
|
||||
throw new AppException(ErrorCode.APPOINTMENT_OUTSIDE_WORKING_HOURS, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user