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,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user