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:
2026-08-19 01:43:50 +03:30
parent d6958b2e48
commit 80167c622c
38 changed files with 833 additions and 392 deletions

View File

@@ -1,3 +1,4 @@
import { AppException } from '../../common/errors';
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
describe('assertCompleteToothProsthesisMap', () => {
@@ -47,6 +48,6 @@ describe('assertCompleteToothProsthesisMap', () => {
{ treatmentDetailId: prosthesisDetailId, tooth: '14', prosthesisTypeCode: 'pfm_crown' },
],
}),
).toThrow('missing tooth 15');
).toThrow(AppException);
});
});

View File

@@ -1,4 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { HttpStatus } from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import { normalizeTeeth } from './treatment.utils';
export type LabCaseProsthesisLink = {
@@ -28,9 +29,7 @@ export function assertCompleteToothProsthesisMap(labCase: {
for (const tooth of teeth) {
const key = `${link.treatmentDetailId}:${tooth}`;
if (!prosthesisByKey.has(key)) {
throw new BadRequestException(
`Each tooth must have a prosthesis type before sending (missing tooth ${tooth})`,
);
throw new AppException(ErrorCode.TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE, HttpStatus.BAD_REQUEST);
}
}
}

View File

@@ -1,9 +1,6 @@
import {
BadRequestException,
ForbiddenException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma, UserNotificationType } from '@prisma/client';
@@ -136,7 +133,7 @@ export class TreatmentsService {
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;
}
@@ -561,13 +558,13 @@ export class TreatmentsService {
});
if (!treatment) {
throw new NotFoundException('Save treatment details before creating lab cases');
throw new AppException(ErrorCode.TREATMENT_SAVE_DETAILS_BEFORE_LAB, HttpStatus.NOT_FOUND);
}
const detailIds = dto.labCases.map((lc) => lc.treatmentDetailId);
const uniqueDetailIds = new Set(detailIds);
if (uniqueDetailIds.size !== detailIds.length) {
throw new BadRequestException('Each treatment detail can belong to only one lab case');
throw new AppException(ErrorCode.TREATMENT_DETAIL_ONE_CASE, HttpStatus.BAD_REQUEST);
}
const details = await this.prisma.treatmentDetail.findMany({
@@ -575,7 +572,7 @@ export class TreatmentsService {
select: { id: true, treatmentType: true, teeth: true },
});
if (details.length !== uniqueDetailIds.size) {
throw new BadRequestException('One or more treatment details were not found');
throw new AppException(ErrorCode.TREATMENT_DETAILS_NOT_FOUND, HttpStatus.BAD_REQUEST);
}
for (const detail of details) {
@@ -587,22 +584,20 @@ export class TreatmentsService {
for (const lc of dto.labCases) {
if (lc.destinationOrganizationId && !linkedOrgIds.has(lc.destinationOrganizationId)) {
throw new BadRequestException('Destination organization is not an active linked counterpart');
throw new AppException(ErrorCode.TREATMENT_DEST_NOT_LINKED, HttpStatus.BAD_REQUEST);
}
for (const row of lc.toothProsthesis ?? []) {
if (lc.treatmentDetailId !== row.treatmentDetailId) {
throw new BadRequestException(
'Tooth prosthesis must reference the lab case treatment detail',
);
throw new AppException(ErrorCode.TREATMENT_TOOTH_UNKNOWN_DETAIL, HttpStatus.BAD_REQUEST);
}
const detail = detailById.get(row.treatmentDetailId);
if (!detail) {
throw new BadRequestException('Tooth prosthesis references an unknown treatment detail');
throw new AppException(ErrorCode.TREATMENT_TOOTH_UNKNOWN_DETAIL, HttpStatus.BAD_REQUEST);
}
const teeth = normalizeTeeth(detail.teeth);
if (!teeth.includes(row.tooth)) {
throw new BadRequestException(`Tooth ${row.tooth} is not on the selected treatment detail`);
throw new AppException(ErrorCode.TREATMENT_TOOTH_NOT_ON_DETAIL, HttpStatus.BAD_REQUEST);
}
this.prosthesisCatalog.assertKnownProsthesisType(row.prosthesisTypeCode);
}
@@ -641,8 +636,14 @@ export class TreatmentsService {
continue;
}
const dueDate =
lc.dueDate !== undefined ? parseDueDateInput(lc.dueDate) : undefined;
let dueDate: Date | null | undefined;
if (lc.dueDate !== undefined) {
try {
dueDate = parseDueDateInput(lc.dueDate);
} catch {
throw new AppException(ErrorCode.TREATMENT_INVALID_DUE_DATE, HttpStatus.BAD_REQUEST);
}
}
const row = lc.id
? await tx.labCase.update({
@@ -696,9 +697,7 @@ export class TreatmentsService {
select: { id: true },
});
if (validAttachments.length !== attachmentIds.length) {
throw new BadRequestException(
'One or more attachments are invalid for this lab case',
);
throw new AppException(ErrorCode.TREATMENT_CASE_INVALID_ATTACHMENTS, HttpStatus.BAD_REQUEST);
}
await tx.labCaseAttachment.createMany({
data: attachmentIds.map((attachmentId) => ({
@@ -749,37 +748,37 @@ export class TreatmentsService {
});
if (!labCase) {
throw new NotFoundException('Lab case not found');
throw new AppException(ErrorCode.LAB_CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (!labCase.destinationOrganizationId) {
throw new BadRequestException('Lab case has no destination organization');
throw new AppException(ErrorCode.TREATMENT_CASE_NO_DEST, HttpStatus.BAD_REQUEST);
}
if (labCase.details.length === 0) {
throw new BadRequestException('Lab case must include a treatment detail');
throw new AppException(ErrorCode.TREATMENT_CASE_NEEDS_DETAIL, HttpStatus.BAD_REQUEST);
}
if (labCase.details.length > 1) {
throw new BadRequestException('Lab case can include only one treatment detail');
throw new AppException(ErrorCode.TREATMENT_CASE_ONE_DETAIL, HttpStatus.BAD_REQUEST);
}
assertCompleteToothProsthesisMap(labCase);
if (!isActorTreatmentProvider(labCase.treatment, actorUserId)) {
throw new ForbiddenException('Only the treatment provider can send this lab case');
throw new AppException(ErrorCode.TREATMENT_ONLY_PROVIDER_SEND, HttpStatus.FORBIDDEN);
}
const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId);
if (!linkedOrgIds.has(labCase.destinationOrganizationId)) {
throw new BadRequestException('Destination organization is not an active linked counterpart');
throw new AppException(ErrorCode.TREATMENT_DEST_NOT_LINKED, HttpStatus.BAD_REQUEST);
}
const alreadySent = labCase.sends.some(
(s) => s.organizationId === labCase.destinationOrganizationId,
);
if (alreadySent) {
throw new BadRequestException('Lab case was already sent to the destination organization');
throw new AppException(ErrorCode.TREATMENT_CASE_ALREADY_SENT, HttpStatus.BAD_REQUEST);
}
const now = new Date();
@@ -922,18 +921,18 @@ export class TreatmentsService {
});
if (!labCase) {
throw new NotFoundException('Lab case not found');
throw new AppException(ErrorCode.LAB_CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (isLabCaseFullyCompleted(labCase.tasks)) {
throw new BadRequestException('Due date cannot be changed after all tasks are completed');
throw new AppException(ErrorCode.TREATMENT_DUE_DATE_LOCKED, HttpStatus.BAD_REQUEST);
}
let dueDate: Date | null;
try {
dueDate = parseDueDateInput(dueDateInput ?? null);
} catch {
throw new BadRequestException('Invalid due date');
throw new AppException(ErrorCode.TREATMENT_INVALID_DUE_DATE, HttpStatus.BAD_REQUEST);
}
await tx.labCase.update({
@@ -953,11 +952,11 @@ export class TreatmentsService {
await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId);
if (!detailClientKey?.trim()) {
throw new BadRequestException('detailClientKey is required');
throw new AppException(ErrorCode.TREATMENT_DETAIL_KEY_REQUIRED, HttpStatus.BAD_REQUEST);
}
if (!files?.length) {
throw new BadRequestException('At least one file is required');
throw new AppException(ErrorCode.TREATMENT_FILE_REQUIRED, HttpStatus.BAD_REQUEST);
}
const existingDetail = await this.prisma.treatmentDetail.findFirst({
@@ -1040,11 +1039,11 @@ export class TreatmentsService {
});
if (!attachment) {
throw new NotFoundException('Attachment not found');
throw new AppException(ErrorCode.TREATMENT_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (attachment.detail && attachment.detail.treatment.organizationId !== organizationId) {
throw new NotFoundException('Attachment not found');
throw new AppException(ErrorCode.TREATMENT_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (!attachment.detail && attachment.appointmentId) {
@@ -1057,12 +1056,12 @@ export class TreatmentsService {
select: { id: true },
});
if (!appointment) {
throw new NotFoundException('Attachment not found');
throw new AppException(ErrorCode.TREATMENT_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
}
if (!existsSync(attachment.storagePath)) {
throw new NotFoundException('File is no longer available');
throw new AppException(ErrorCode.TREATMENT_FILE_UNAVAILABLE, HttpStatus.NOT_FOUND);
}
return {
@@ -1316,7 +1315,7 @@ export class TreatmentsService {
select: { id: true },
});
if (!patient) {
throw new NotFoundException('Patient not found');
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
}
@@ -1336,11 +1335,11 @@ export class TreatmentsService {
});
if (!appointment) {
throw new NotFoundException('Appointment not found');
throw new AppException(ErrorCode.APPOINTMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (appointment.providerUserId !== actorUserId) {
throw new ForbiddenException('You are not the provider for this appointment');
throw new AppException(ErrorCode.APPOINTMENT_NOT_PROVIDER, HttpStatus.FORBIDDEN);
}
return appointment;
@@ -1349,7 +1348,7 @@ export class TreatmentsService {
private async assertCanReadTreatment(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 (
hasEffectivePermission(m, 'TAB_TREATMENT_READ') ||
@@ -1357,18 +1356,18 @@ export class TreatmentsService {
) {
return;
}
throw new ForbiddenException('You do not have access to treatments');
throw new AppException(ErrorCode.PERMISSION_ACCESS_TREATMENTS, HttpStatus.FORBIDDEN);
}
private async assertCanEditTreatment(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 (hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')) {
return;
}
throw new ForbiddenException('You cannot edit treatments');
throw new AppException(ErrorCode.PERMISSION_EDIT_TREATMENTS, HttpStatus.FORBIDDEN);
}
private async getMembership(userId: string, organizationId: string) {