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,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);
}
}

View File

@@ -1,9 +1,10 @@
import { IsOptional, IsString, Matches } from 'class-validator';
import { ErrorCode } from '../../../common/errors';
export class ColumnProvidersQueryDto {
/** Local calendar date (YYYY-MM-DD) used to resolve weekday working hours. */
@IsOptional()
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: ErrorCode.APPOINTMENT_INVALID_DATE })
date?: string;
}

View File

@@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDateString, IsString, IsUUID, MaxLength } from 'class-validator';
import { IsDateString, IsString, IsUUID, Matches, MaxLength } from 'class-validator';
import { ErrorCode } from '../../../common/errors';
export class CreateAppointmentDto {
@ApiProperty()
@@ -25,4 +26,14 @@ export class CreateAppointmentDto {
@IsString()
@MaxLength(64)
purpose: string;
@ApiProperty({
description:
'IANA time zone of the booking user (e.g. Asia/Tehran). Wall-clock hours are interpreted in this zone.',
example: 'Asia/Tehran',
})
@IsString()
@MaxLength(64)
@Matches(/^[A-Za-z0-9_+\-/]+$/, { message: ErrorCode.VALIDATION_TIMEZONE_INVALID })
timeZone: string;
}

View File

@@ -1,9 +1,8 @@
import {
BadRequestException,
ForbiddenException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import { createReadStream, existsSync } from 'fs';
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
@@ -103,7 +102,7 @@ export class CasesService {
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;
}
@@ -300,7 +299,7 @@ export class CasesService {
});
if (!labCase) {
throw new NotFoundException('Case not found');
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
return {
@@ -327,7 +326,7 @@ export class CasesService {
});
if (!labCase) {
throw new NotFoundException('Case not found');
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
@@ -356,11 +355,11 @@ export class CasesService {
});
if (!link?.attachment) {
throw new NotFoundException('Attachment not found');
throw new AppException(ErrorCode.CASE_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (!existsSync(link.attachment.storagePath)) {
throw new NotFoundException('Attachment file is missing on disk');
throw new AppException(ErrorCode.CASE_ATTACHMENT_MISSING_FILE, HttpStatus.NOT_FOUND);
}
return {
@@ -389,7 +388,7 @@ export class CasesService {
});
if (!existing) {
throw new NotFoundException('Case not found');
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
await this.prisma.labCase.update({
@@ -440,7 +439,7 @@ export class CasesService {
});
if (!existing) {
throw new NotFoundException('Case not found');
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
const externalCode =
@@ -510,7 +509,7 @@ export class CasesService {
});
if (!task) {
throw new NotFoundException('Task not found');
throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.NOT_FOUND);
}
const assigneeUserId = dto.assigneeUserId ?? null;
@@ -530,7 +529,7 @@ export class CasesService {
const canReceive = memberships.some((m) => hasEffectivePermission(m, 'TAB_TASKS_EDIT'));
if (!canReceive) {
throw new BadRequestException('Selected user cannot be assigned tasks');
throw new AppException(ErrorCode.TASK_ASSIGNEE_INVALID, HttpStatus.BAD_REQUEST);
}
}
@@ -577,7 +576,7 @@ export class CasesService {
if (query.sentFrom) {
const from = new Date(query.sentFrom);
if (Number.isNaN(from.getTime())) {
throw new BadRequestException('Invalid sentFrom date');
throw new AppException(ErrorCode.INVALID_SENT_FROM, HttpStatus.BAD_REQUEST);
}
sentAtFilter.gte = from;
}
@@ -585,7 +584,7 @@ export class CasesService {
if (query.sentTo) {
const to = new Date(query.sentTo);
if (Number.isNaN(to.getTime())) {
throw new BadRequestException('Invalid sentTo date');
throw new AppException(ErrorCode.INVALID_SENT_TO, HttpStatus.BAD_REQUEST);
}
to.setHours(23, 59, 59, 999);
sentAtFilter.lte = to;
@@ -874,27 +873,27 @@ export class CasesService {
private async assertCanReadCases(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;
const names = m.permissions.map((p) => p.permission.name);
if (names.includes('TAB_CASES_READ') || names.includes('TAB_CASES_EDIT')) {
return;
}
throw new ForbiddenException('You do not have access to cases');
throw new AppException(ErrorCode.PERMISSION_ACCESS_CASES, HttpStatus.FORBIDDEN);
}
private async assertCanEditCases(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;
const names = m.permissions.map((p) => p.permission.name);
if (names.includes('TAB_CASES_EDIT')) {
return;
}
throw new ForbiddenException('You cannot update cases');
throw new AppException(ErrorCode.PERMISSION_ACCESS_CASES, HttpStatus.FORBIDDEN);
}
private async getMembership(userId: string, organizationId: string) {

View File

@@ -1,7 +1,6 @@
import {
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CatalogEntityKind, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
@@ -230,7 +229,7 @@ export class LabCaseAccessService {
});
if (!labCase) {
throw new NotFoundException('Case not found');
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
return labCase;

View File

@@ -1,8 +1,8 @@
import {
ForbiddenException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import { LabCaseCommentSide, LabCaseActivityType, Prisma, UserNotificationType } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto';
@@ -108,11 +108,11 @@ export class LabCaseCommentsService {
select: { id: true, labCaseId: true, authorSide: true },
});
if (!comment) {
throw new NotFoundException('Comment not found');
throw new AppException(ErrorCode.COMMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
await this.assertLabCanComment(comment.labCaseId, labOrganizationId, actorUserId);
if (comment.authorSide !== LabCaseCommentSide.LAB) {
throw new ForbiddenException('Only lab comments can change visibility');
throw new AppException(ErrorCode.COMMENT_VISIBILITY_LAB_ONLY, HttpStatus.FORBIDDEN);
}
const updated = await this.prisma.labCaseComment.update({
where: { id: commentId },
@@ -278,7 +278,7 @@ export class LabCaseCommentsService {
await this.assertLabCanViewCase(caseId, labOrganizationId, actorUserId);
const membership = await this.getLabMembership(actorUserId, labOrganizationId);
if (!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')) {
throw new ForbiddenException('You do not have access to task comments');
throw new AppException(ErrorCode.PERMISSION_ACCESS_TASKS, HttpStatus.FORBIDDEN);
}
}
@@ -296,7 +296,7 @@ export class LabCaseCommentsService {
select: { id: true },
});
if (!labCase) {
throw new NotFoundException('Case not found');
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
const membership = await this.getLabMembership(actorUserId, labOrganizationId);
@@ -304,7 +304,7 @@ export class LabCaseCommentsService {
!hasEffectivePermission(membership, 'TAB_TASKS_READ') &&
!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')
) {
throw new ForbiddenException('You do not have access to tasks');
throw new AppException(ErrorCode.PERMISSION_ACCESS_TASKS, HttpStatus.FORBIDDEN);
}
}
@@ -321,7 +321,7 @@ export class LabCaseCommentsService {
},
});
if (!membership) {
throw new ForbiddenException('You are not a member of this organization');
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
}
return membership;
}
@@ -340,7 +340,7 @@ export class LabCaseCommentsService {
select: { id: true },
});
if (!labCase) {
throw new NotFoundException('Case not found');
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
}
@@ -360,7 +360,7 @@ export class LabCaseCommentsService {
select: { id: true },
});
if (!labCase) {
throw new NotFoundException('Case not found');
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
const membership = await this.prisma.membership.findFirst({
@@ -375,7 +375,7 @@ export class LabCaseCommentsService {
},
});
if (!membership) {
throw new ForbiddenException('You are not a member of this organization');
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
}
if (
hasEffectivePermission(membership, 'TAB_TREATMENT_READ') ||
@@ -383,7 +383,7 @@ export class LabCaseCommentsService {
) {
return;
}
throw new ForbiddenException('You do not have access to treatment cases');
throw new AppException(ErrorCode.PERMISSION_ACCESS_TREATMENTS, HttpStatus.FORBIDDEN);
}
private async labOrgIdForCase(caseId: string): Promise<string | null> {

View File

@@ -1,8 +1,8 @@
import {
ForbiddenException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import {
LabCaseActivityType,
LabCaseTabReadTarget,
@@ -49,7 +49,7 @@ export class LabCaseActivityService {
include: { type: true },
});
if (!org) {
throw new NotFoundException('Organization not found');
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
}
await this.assertMembership(userId, organizationId);
@@ -224,7 +224,7 @@ export class LabCaseActivityService {
include: { type: true },
});
if (!org) {
throw new NotFoundException('Organization not found');
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
}
await this.assertCanAccessCase(userId, organizationId, labCaseId);
@@ -454,7 +454,7 @@ export class LabCaseActivityService {
},
});
if (!membership) {
throw new ForbiddenException('You are not a member of this organization');
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
}
}
@@ -468,7 +468,7 @@ export class LabCaseActivityService {
include: { type: true },
});
if (!org) {
throw new NotFoundException('Organization not found');
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
}
const labCase = await this.prisma.labCase.findFirst({
@@ -491,7 +491,7 @@ export class LabCaseActivityService {
});
if (!labCase) {
throw new NotFoundException('Case not found');
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (org.type.name === 'LAB') {
@@ -513,7 +513,7 @@ export class LabCaseActivityService {
hasEffectivePermission(membership, 'TAB_TASKS_READ')
)
) {
throw new ForbiddenException('You do not have access to this case');
throw new AppException(ErrorCode.PERMISSION_ACCESS_CASES, HttpStatus.FORBIDDEN);
}
return;
}
@@ -536,7 +536,7 @@ export class LabCaseActivityService {
hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')
)
) {
throw new ForbiddenException('You do not have access to this case');
throw new AppException(ErrorCode.PERMISSION_ACCESS_CASES, HttpStatus.FORBIDDEN);
}
}
}

View File

@@ -1,10 +1,5 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { HttpStatus, Injectable } from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import { LinkStatus, UserNotificationType } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
@@ -42,7 +37,7 @@ export class OrganizationService {
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;
}
@@ -50,7 +45,7 @@ export class OrganizationService {
async searchCounterpartOrganizations(userId: string, organizationId: string, query: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
}
const targetType = this.getCounterpartType(actor.organization.type.name);
@@ -88,7 +83,7 @@ export class OrganizationService {
async list(userId: string, organizationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
}
// Open outbound invitations keyed by placeholder/real invited org id — lets UI show copy-invite
@@ -174,7 +169,7 @@ export class OrganizationService {
async countIncomingPendingConnections(userId: string, organizationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
}
const links = await this.prisma.organizationLink.findMany({
@@ -196,7 +191,7 @@ export class OrganizationService {
async listInvitationHistory(userId: string, organizationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
}
const invitations = await this.prisma.organizationInvitation.findMany({
@@ -227,10 +222,10 @@ export class OrganizationService {
) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
}
if (dto.targetOrganizationId === organizationId) {
throw new BadRequestException('You cannot link organization to itself');
throw new AppException(ErrorCode.ORG_CANNOT_LINK_SELF, HttpStatus.BAD_REQUEST);
}
const sourceType = actor.organization.type.name;
@@ -240,13 +235,13 @@ export class OrganizationService {
select: { id: true, type: true, planId: true },
});
if (!target) {
throw new NotFoundException('Organization not found');
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (target.type.name !== targetType) {
throw new BadRequestException(`You can only link to ${targetType} organizations`);
throw new AppException(ErrorCode.ORG_LINK_WRONG_TYPE, HttpStatus.BAD_REQUEST);
}
if (!target.planId) {
throw new BadRequestException('Target organization does not have an active subscription');
throw new AppException(ErrorCode.ORG_TARGET_NO_SUBSCRIPTION, HttpStatus.BAD_REQUEST);
}
const [aId, bId] =
@@ -258,7 +253,7 @@ export class OrganizationService {
where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } },
});
if (existing) {
throw new ConflictException('Link already exists for these organizations');
throw new AppException(ErrorCode.ORG_LINK_EXISTS, HttpStatus.CONFLICT);
}
const created = await this.prisma.organizationLink.create({
@@ -295,7 +290,7 @@ export class OrganizationService {
) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
}
const connection = await this.prisma.organizationLink.findFirst({
@@ -305,15 +300,15 @@ export class OrganizationService {
},
});
if (!connection) {
throw new NotFoundException('Connection request not found');
throw new AppException(ErrorCode.ORG_CONNECTION_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (connection.status !== LinkStatus.PENDING) {
throw new BadRequestException('Only pending connection requests can be responded to');
throw new AppException(ErrorCode.ORG_REQUEST_NOT_PENDING, HttpStatus.BAD_REQUEST);
}
const requesterOrgId = this.getRequesterOrganizationId(connection.sharedDataTypes);
if (requesterOrgId && requesterOrgId === organizationId) {
throw new ForbiddenException('You cannot respond to your own connection request');
throw new AppException(ErrorCode.ORG_CANNOT_RESPOND_OWN, HttpStatus.FORBIDDEN);
}
const nextStatus = dto.action === 'ACCEPT' ? LinkStatus.ACTIVE : LinkStatus.REJECTED;
@@ -335,7 +330,7 @@ export class OrganizationService {
async deleteConnection(userId: string, organizationId: string, connectionId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
}
const connection = await this.prisma.organizationLink.findFirst({
@@ -347,7 +342,7 @@ export class OrganizationService {
select: { id: true },
});
if (!connection) {
throw new NotFoundException('Connected organization not found');
throw new AppException(ErrorCode.ORG_CONNECTION_NOT_FOUND, HttpStatus.NOT_FOUND);
}
await this.prisma.organizationLink.delete({ where: { id: connection.id } });
@@ -367,7 +362,7 @@ export class OrganizationService {
) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
}
const { clinicOrganizationId, labOrganizationId, counterpart } =
@@ -397,7 +392,7 @@ export class OrganizationService {
) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
}
const { clinicOrganizationId, labOrganizationId, counterpart } =
@@ -459,11 +454,11 @@ export class OrganizationService {
) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
}
const parties = await this.resolveActiveConnectionParties(connectionId, organizationId, actor);
if (parties.clinicOrganizationId !== organizationId) {
throw new ForbiddenException('Only the clinic can comment on this case');
throw new AppException(ErrorCode.ORG_ONLY_CLINIC_COMMENT, HttpStatus.FORBIDDEN);
}
return parties;
}
@@ -472,7 +467,7 @@ export class OrganizationService {
async getInvitationLink(userId: string, organizationId: string, invitationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
}
const invitation = await this.prisma.organizationInvitation.findFirst({
@@ -487,13 +482,13 @@ export class OrganizationService {
},
});
if (!invitation) {
throw new NotFoundException('Invitation not found');
throw new AppException(ErrorCode.ORG_INVITE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (invitation.acceptedAt) {
throw new BadRequestException('This invitation has already been accepted');
throw new AppException(ErrorCode.ORG_INVITE_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST);
}
if (invitation.revokedAt) {
throw new BadRequestException('This invitation is no longer valid');
throw new AppException(ErrorCode.ORG_INVITE_INVALID, HttpStatus.BAD_REQUEST);
}
const plainToken = this.generateInviteToken();
@@ -522,7 +517,7 @@ export class OrganizationService {
async inviteOrganization(userId: string, organizationId: string, dto: InviteOrganizationDto) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
}
const ownerEmail = dto.ownerEmail.trim().toLowerCase();
@@ -539,9 +534,7 @@ export class OrganizationService {
select: { id: true },
});
if (existingOwnerWithPlan) {
throw new BadRequestException(
'This owner already has an organization with active subscription. Select that organization from search instead of sending invitation.',
);
throw new AppException(ErrorCode.ORG_INVITE_OWNER_HAS_ORG, HttpStatus.BAD_REQUEST);
}
const invitation = await this.prisma.$transaction(async (tx) => {
@@ -587,7 +580,7 @@ export class OrganizationService {
where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } },
});
if (existingLink?.status === LinkStatus.ACTIVE) {
throw new ConflictException('These organizations are already linked');
throw new AppException(ErrorCode.ORG_LINK_EXISTS, HttpStatus.CONFLICT);
}
// Pre-create connection so inviter sees one pending row; acceptInvite() flips to ACTIVE.
@@ -660,13 +653,11 @@ export class OrganizationService {
async acceptInvite(dto: AcceptOrganizationInviteDto) {
const invitation = await this.findValidInvitation(dto.token);
if (invitation.acceptedAt) {
throw new BadRequestException('This invitation has already been accepted');
throw new AppException(ErrorCode.ORG_INVITE_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST);
}
if (dto.organizationType !== invitation.invitedOrganizationType) {
throw new BadRequestException(
`Organization type must be ${invitation.invitedOrganizationType} for this invitation`,
);
throw new AppException(ErrorCode.ORG_INVITE_TYPE_MISMATCH, HttpStatus.BAD_REQUEST);
}
const organizationEmail = dto.organizationEmail.trim().toLowerCase();
@@ -788,7 +779,7 @@ export class OrganizationService {
});
if (!link) {
throw new NotFoundException('Connected organization not found');
throw new AppException(ErrorCode.ORG_CONNECTION_NOT_FOUND, HttpStatus.NOT_FOUND);
}
const counterpart =
@@ -797,7 +788,7 @@ export class OrganizationService {
const orgType = actor.organization.type.name;
if (orgType === 'CLINIC') {
if (counterpart.type.name !== 'LAB') {
throw new BadRequestException('Counterpart organization is not a lab');
throw new AppException(ErrorCode.ORG_COUNTERPART_NOT_LAB, HttpStatus.BAD_REQUEST);
}
return {
clinicOrganizationId: organizationId,
@@ -808,7 +799,7 @@ export class OrganizationService {
if (orgType === 'LAB') {
if (counterpart.type.name !== 'CLINIC') {
throw new BadRequestException('Counterpart organization is not a clinic');
throw new AppException(ErrorCode.ORG_COUNTERPART_NOT_CLINIC, HttpStatus.BAD_REQUEST);
}
return {
clinicOrganizationId: counterpart.id,
@@ -817,7 +808,7 @@ export class OrganizationService {
};
}
throw new BadRequestException('Unknown organization type');
throw new AppException(ErrorCode.ORG_UNKNOWN_TYPE, HttpStatus.BAD_REQUEST);
}
private async getActorMembership(userId: string, organizationId: string) {
@@ -846,7 +837,7 @@ export class OrganizationService {
private getCounterpartType(orgType: string): 'CLINIC' | 'LAB' {
if (orgType === 'CLINIC') return 'LAB';
if (orgType === 'LAB') return 'CLINIC';
throw new BadRequestException('Unknown organization type');
throw new AppException(ErrorCode.ORG_UNKNOWN_TYPE, HttpStatus.BAD_REQUEST);
}
private mapInvitationStatus(
@@ -898,13 +889,13 @@ export class OrganizationService {
},
});
if (!invitation) {
throw new NotFoundException('Invitation not found');
throw new AppException(ErrorCode.ORG_INVITE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (invitation.revokedAt) {
throw new BadRequestException('Invitation has been revoked');
throw new AppException(ErrorCode.ORG_INVITE_REVOKED, HttpStatus.BAD_REQUEST);
}
if (invitation.expiresAt.getTime() <= Date.now()) {
throw new BadRequestException('Invitation has expired');
throw new AppException(ErrorCode.ORG_INVITE_EXPIRED, HttpStatus.BAD_REQUEST);
}
return invitation;
}

View File

@@ -1,4 +1,4 @@
import { BadRequestException, HttpStatus, Injectable, NotFoundException } from '@nestjs/common';
import { HttpStatus, Injectable } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { isValidMobile, mobileSearchDigits, normalizeMobile } from '../../common/phone';
import { hasEffectivePermission } from '../../common/membership-permissions';
@@ -77,7 +77,7 @@ export class PatientsService {
});
if (!patient) {
throw new NotFoundException('Patient not found');
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
return { success: true, data: patient };
@@ -162,7 +162,7 @@ export class PatientsService {
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;
}
@@ -240,7 +240,7 @@ export class PatientsService {
});
if (!patient) {
throw new NotFoundException('Patient not found');
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
}
}

View File

@@ -1,4 +1,5 @@
import { Injectable, BadRequestException, OnModuleInit } from '@nestjs/common';
import { HttpStatus, Injectable, OnModuleInit } from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import { CatalogEntityKind } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import {
@@ -60,7 +61,7 @@ export class ProsthesisCatalogService implements OnModuleInit {
assertKnownProsthesisType(code: string): void {
this.ensureLoaded();
if (!this.byCode.has(code)) {
throw new BadRequestException(`Unknown prosthesis type: ${code}`);
throw new AppException(ErrorCode.CATALOG_UNKNOWN_PROSTHESIS_TYPE, HttpStatus.BAD_REQUEST);
}
}

View File

@@ -5,10 +5,14 @@ import {
IsBoolean,
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
import { ErrorCode } from '../../../common/errors';
export class WorkingHoursBlockDto {
@IsInt()
@@ -41,4 +45,9 @@ export class UpsertWorkingHoursDto {
@ValidateNested({ each: true })
@Type(() => WorkingHoursBlockDto)
blocks: WorkingHoursBlockDto[];
@IsString()
@MaxLength(64)
@Matches(/^[A-Za-z0-9_+\-/]+$/, { message: ErrorCode.VALIDATION_TIMEZONE_INVALID })
timeZone: string;
}

View File

@@ -1,9 +1,4 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { HttpStatus, Injectable } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import {
appointmentWithinWorkingHours,
@@ -12,10 +7,12 @@ import {
validateWorkingHoursBlocks,
type WorkingHoursBlockInput,
} from '../../common/working-hours';
import { isValidIanaTimeZone, zonedWeekdayAndMinutes } from '../../common/zoned-civil-time';
import { UpsertWorkingHoursDto } from './dto/upsert-working-hours.dto';
import {
hasEffectivePermission,
} from '../../common/membership-permissions';
import { AppException, ErrorCode } from '../../common/errors';
@Injectable()
export class StaffWorkingHoursService {
@@ -124,14 +121,16 @@ export class StaffWorkingHoursService {
) {
const validationError = validateWorkingHoursBlocks(dto.blocks);
if (validationError) {
throw new BadRequestException(validationError);
throw new AppException(ErrorCode.WORKING_HOURS_INVALID, HttpStatus.BAD_REQUEST);
}
const timeZone = this.requireTimeZone(dto.timeZone);
const normalizedBlocks = this.normalizeBlocks(dto.blocks);
await this.assertNoConflictingAppointments(
organizationId,
membership.userId,
normalizedBlocks,
timeZone,
);
await this.prisma.$transaction(async (tx) => {
@@ -207,10 +206,18 @@ export class StaffWorkingHoursService {
}));
}
private requireTimeZone(timeZone: string | undefined): string {
if (!timeZone || !isValidIanaTimeZone(timeZone)) {
throw new AppException(ErrorCode.VALIDATION_TIMEZONE_INVALID, HttpStatus.BAD_REQUEST);
}
return timeZone;
}
private async assertNoConflictingAppointments(
organizationId: string,
providerUserId: string,
blocks: WorkingHoursBlockInput[],
timeZone: string,
) {
const now = new Date();
const appointments = await this.prisma.appointment.findMany({
@@ -219,47 +226,30 @@ export class StaffWorkingHoursService {
providerUserId,
endAt: { gt: now },
},
include: {
patient: { select: { firstName: true, lastName: true } },
},
select: { startAt: true, endAt: true },
orderBy: { startAt: 'asc' },
});
const conflicts = appointments.filter((appointment) => {
const startAt = new Date(appointment.startAt);
const endAt = new Date(appointment.endAt);
const dayOfWeek = localDayOfWeekMondayZero(startAt.getDay());
const dayOfWeek = localDayOfWeekMondayZero(
zonedWeekdayAndMinutes(startAt, timeZone).jsWeekday,
);
const dayBlocks = blocksForDay(blocks, dayOfWeek);
if (dayBlocks.length === 0) {
return true;
}
return !appointmentWithinWorkingHours(startAt, endAt, dayBlocks);
return !appointmentWithinWorkingHours(startAt, endAt, dayBlocks, timeZone);
});
if (conflicts.length === 0) {
return;
}
const examples = conflicts.slice(0, 3).map((appointment) => {
const startAt = new Date(appointment.startAt);
const patientName = `${appointment.patient.firstName} ${appointment.patient.lastName}`;
const when = startAt.toLocaleString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
return `${patientName} (${when})`;
});
const extra =
conflicts.length > examples.length
? ` and ${conflicts.length - examples.length} more`
: '';
throw new BadRequestException(
`Cannot save working hours: ${conflicts.length} upcoming appointment${conflicts.length === 1 ? '' : 's'} fall outside the new schedule (${examples.join(', ')}${extra}). Reschedule or remove those appointments first.`,
throw new AppException(
ErrorCode.WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS,
HttpStatus.CONFLICT,
);
}
@@ -269,10 +259,10 @@ export class StaffWorkingHoursService {
select: { id: true, isOwner: true, userId: true },
});
if (!membership) {
throw new NotFoundException('Member not found');
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (membership.isOwner) {
throw new BadRequestException('Working hours cannot be set for the organization owner');
throw new AppException(ErrorCode.WORKING_HOURS_OWNER_NOT_ALLOWED, HttpStatus.BAD_REQUEST);
}
return membership;
}
@@ -286,7 +276,7 @@ export class StaffWorkingHoursService {
},
});
if (!membership) {
throw new ForbiddenException('Only organization owners can manage their working hours');
throw new AppException(ErrorCode.PERMISSION_OWNER_ONLY, HttpStatus.FORBIDDEN);
}
return membership;
}
@@ -297,23 +287,21 @@ export class StaffWorkingHoursService {
organization: { type: { name: string }; plan?: { name: string } | null; planId?: string | null };
}) {
if (!hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')) {
throw new ForbiddenException(
'Enable treatment participation before setting working hours',
);
throw new AppException(ErrorCode.PERMISSION_ENABLE_PARTICIPATION_FIRST, HttpStatus.FORBIDDEN);
}
}
private async assertCanViewStaff(userId: string, organizationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canViewStaff(actor)) {
throw new ForbiddenException('You do not have access to staff management');
throw new AppException(ErrorCode.PERMISSION_ACCESS_STAFF, HttpStatus.FORBIDDEN);
}
}
private async assertCanEditStaff(userId: string, organizationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot manage staff working hours');
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
}
}

View File

@@ -1,10 +1,8 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import { Prisma, UserNotificationType } from '@prisma/client';
@@ -28,7 +26,7 @@ export class StaffService {
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;
}
@@ -36,7 +34,7 @@ export class StaffService {
async list(userId: string, organizationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canViewStaff(actor)) {
throw new ForbiddenException('You do not have access to staff management');
throw new AppException(ErrorCode.PERMISSION_ACCESS_STAFF, HttpStatus.FORBIDDEN);
}
const org = await this.prisma.organization.findUnique({
@@ -44,7 +42,7 @@ export class StaffService {
include: { plan: true },
});
if (!org) {
throw new NotFoundException('Organization not found');
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
}
const [members, seatsUsed] = await Promise.all([
@@ -100,7 +98,7 @@ export class StaffService {
async invite(userId: string, organizationId: string, dto: InviteStaffDto) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot invite or manage staff');
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
}
const email = dto.email.trim().toLowerCase();
@@ -114,7 +112,7 @@ export class StaffService {
if (permissionRows.length !== normalizedPerms.length) {
const ok = new Set(permissionRows.map((p) => p.name));
const missing = normalizedPerms.filter((n) => !ok.has(n));
throw new BadRequestException(`Unknown or invalid permissions: ${missing.join(', ')}`);
throw new AppException(ErrorCode.STAFF_UNKNOWN_PERMISSIONS, HttpStatus.BAD_REQUEST);
}
const plainToken = this.generateInviteToken();
@@ -126,13 +124,11 @@ export class StaffService {
include: { plan: true },
});
if (!org) {
throw new NotFoundException('Organization not found');
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (!org.plan) {
throw new BadRequestException(
'This organization has no active subscription. Please choose a plan before inviting staff.',
);
throw new AppException(ErrorCode.STAFF_NO_SUBSCRIPTION, HttpStatus.BAD_REQUEST);
}
const maxUsers = org.plan.maxUsers;
@@ -143,9 +139,7 @@ export class StaffService {
},
});
if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) {
throw new BadRequestException(
`Your plan allows ${maxUsers} team members. Remove a member or upgrade to add more.`,
);
throw new AppException(ErrorCode.STAFF_SEAT_LIMIT, HttpStatus.BAD_REQUEST);
}
const existingUser = await tx.user.findUnique({ where: { email } });
@@ -153,7 +147,7 @@ export class StaffService {
if (existingUser) {
if (existingUser.id === org.ownerId) {
throw new BadRequestException('Organization owner is already a member');
throw new AppException(ErrorCode.STAFF_INVITE_OWNER_EMAIL, HttpStatus.BAD_REQUEST);
}
const dup = await tx.membership.findUnique({
where: {
@@ -164,7 +158,7 @@ export class StaffService {
},
});
if (dup) {
throw new ConflictException('This user is already a member of this organization');
throw new AppException(ErrorCode.STAFF_ALREADY_MEMBER, HttpStatus.CONFLICT);
}
targetUserId = existingUser.id;
} else {
@@ -250,7 +244,7 @@ export class StaffService {
async getInvitationLink(userId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot invite or manage staff');
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
}
const membership = await this.prisma.membership.findFirst({
@@ -262,24 +256,24 @@ export class StaffService {
});
if (!membership) {
throw new NotFoundException('Member not found');
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (membership.isOwner) {
throw new BadRequestException('Owner does not use an invitation link');
throw new AppException(ErrorCode.STAFF_OWNER_NO_INVITE_LINK, HttpStatus.BAD_REQUEST);
}
if (membership.isActive) {
throw new BadRequestException('This member has already accepted their invitation');
throw new AppException(ErrorCode.STAFF_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST);
}
const invitation = membership.invitations[0];
if (!invitation) {
throw new BadRequestException('No invitation found for this member');
throw new AppException(ErrorCode.STAFF_INVITE_MISSING, HttpStatus.BAD_REQUEST);
}
if (invitation.acceptedAt) {
throw new BadRequestException('This invitation has already been accepted');
throw new AppException(ErrorCode.STAFF_INVITE_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST);
}
if (invitation.revokedAt) {
throw new BadRequestException('This invitation is no longer valid');
throw new AppException(ErrorCode.STAFF_INVITE_INVALID, HttpStatus.BAD_REQUEST);
}
const plainToken = this.generateInviteToken();
@@ -324,7 +318,7 @@ export class StaffService {
const invitation = await this.findValidInvitation(dto.token);
if (invitation.acceptedAt) {
throw new BadRequestException('This invitation has already been accepted');
throw new AppException(ErrorCode.STAFF_INVITE_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST);
}
const passwordHash = await bcrypt.hash(dto.password, 10);
@@ -367,7 +361,7 @@ export class StaffService {
) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot edit staff');
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
}
const target = await this.prisma.membership.findFirst({
@@ -379,10 +373,10 @@ export class StaffService {
});
if (!target) {
throw new NotFoundException('Member not found');
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (target.isOwner) {
throw new ForbiddenException('Owner membership cannot be edited here');
throw new AppException(ErrorCode.STAFF_CANNOT_EDIT_OWNER, HttpStatus.FORBIDDEN);
}
if (dto.name !== undefined) {
@@ -402,7 +396,7 @@ export class StaffService {
if (permissionRows.length !== normalizedPerms.length) {
const ok = new Set(permissionRows.map((p) => p.name));
const missing = normalizedPerms.filter((n) => !ok.has(n));
throw new BadRequestException(`Unknown or invalid permissions: ${missing.join(', ')}`);
throw new AppException(ErrorCode.STAFF_UNKNOWN_PERMISSIONS, HttpStatus.BAD_REQUEST);
}
await this.prisma.$transaction([
@@ -426,7 +420,7 @@ export class StaffService {
async enableMember(actorUserId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot manage staff');
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
}
const target = await this.prisma.membership.findFirst({
@@ -437,20 +431,18 @@ export class StaffService {
});
if (!target) {
throw new NotFoundException('Member not found');
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (target.isOwner) {
throw new ForbiddenException('Cannot enable the organization owner');
throw new AppException(ErrorCode.STAFF_CANNOT_ENABLE_OWNER, HttpStatus.FORBIDDEN);
}
if (target.isActive) {
throw new BadRequestException('This member is already active');
throw new AppException(ErrorCode.STAFF_ALREADY_ACTIVE, HttpStatus.BAD_REQUEST);
}
const invitation = target.invitations[0];
if (invitation && !invitation.acceptedAt) {
throw new BadRequestException(
'This member has not completed their invitation yet. Share the invite link instead.',
);
throw new AppException(ErrorCode.STAFF_INVITE_MISSING, HttpStatus.BAD_REQUEST);
}
await this.prisma.$transaction(async (tx) => {
@@ -470,7 +462,7 @@ export class StaffService {
async disableMember(actorUserId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot manage staff');
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
}
const target = await this.prisma.membership.findFirst({
@@ -478,16 +470,16 @@ export class StaffService {
});
if (!target) {
throw new NotFoundException('Member not found');
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (target.isOwner) {
throw new ForbiddenException('Cannot disable the organization owner');
throw new AppException(ErrorCode.STAFF_CANNOT_DISABLE_OWNER, HttpStatus.FORBIDDEN);
}
if (actorUserId === target.userId) {
throw new BadRequestException('You cannot disable your own access');
throw new AppException(ErrorCode.STAFF_CANNOT_DISABLE_SELF, HttpStatus.BAD_REQUEST);
}
if (!target.isActive) {
throw new BadRequestException('This member is already disabled or pending activation');
throw new AppException(ErrorCode.STAFF_ALREADY_DISABLED, HttpStatus.BAD_REQUEST);
}
await this.prisma.membership.update({
@@ -508,7 +500,7 @@ export class StaffService {
async removeMember(actorUserId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot remove staff');
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
}
const target = await this.prisma.membership.findFirst({
@@ -516,10 +508,10 @@ export class StaffService {
});
if (!target) {
throw new NotFoundException('Member not found');
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (target.isOwner) {
throw new ForbiddenException('Cannot remove the organization owner');
throw new AppException(ErrorCode.STAFF_CANNOT_REMOVE_OWNER, HttpStatus.FORBIDDEN);
}
await this.prisma.membership.delete({ where: { id: membershipId } });
@@ -536,12 +528,10 @@ export class StaffService {
include: { plan: true },
});
if (!org) {
throw new NotFoundException('Organization not found');
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (!org.plan) {
throw new BadRequestException(
'This organization has no active subscription. Please choose a plan before adding staff.',
);
throw new AppException(ErrorCode.STAFF_NO_SUBSCRIPTION, HttpStatus.BAD_REQUEST);
}
const maxUsers = org.plan.maxUsers;
@@ -552,9 +542,7 @@ export class StaffService {
},
});
if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) {
throw new BadRequestException(
`Your plan allows ${maxUsers} team members. Free a seat by disabling another member or upgrade your plan.`,
);
throw new AppException(ErrorCode.STAFF_SEAT_LIMIT, HttpStatus.BAD_REQUEST);
}
}
@@ -615,13 +603,13 @@ export class StaffService {
});
if (!invitation) {
throw new NotFoundException('Invitation not found');
throw new AppException(ErrorCode.STAFF_INVITE_MISSING, HttpStatus.NOT_FOUND);
}
if (invitation.revokedAt) {
throw new BadRequestException('Invitation has been revoked');
throw new AppException(ErrorCode.STAFF_INVITE_REVOKED, HttpStatus.BAD_REQUEST);
}
if (invitation.expiresAt.getTime() <= Date.now()) {
throw new BadRequestException('Invitation has expired');
throw new AppException(ErrorCode.STAFF_INVITE_EXPIRED, HttpStatus.BAD_REQUEST);
}
return invitation;
}

View File

@@ -1,9 +1,8 @@
import {
BadRequestException,
ForbiddenException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone';
@@ -48,7 +47,7 @@ export class TasksService {
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;
}
@@ -118,7 +117,7 @@ export class TasksService {
});
if (!labCase) {
throw new NotFoundException('Case not found');
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
return this.listTasksForLabCaseId(labCaseId, localeInput);
@@ -176,7 +175,7 @@ export class TasksService {
});
if (!target?.labCase.sentAt) {
throw new NotFoundException('Task not found');
throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.NOT_FOUND);
}
const where = await this.buildListWhere(labOrganizationId, actorUserId, listQuery);
@@ -235,11 +234,11 @@ export class TasksService {
});
if (!task) {
throw new NotFoundException('Task not found');
throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (task.assigneeUserId && task.assigneeUserId !== actorUserId) {
throw new ForbiddenException('This task is assigned to another staff member');
throw new AppException(ErrorCode.TASK_ASSIGNED_TO_OTHER, HttpStatus.FORBIDDEN);
}
const updated = await this.prisma.$transaction(async (tx) => {
@@ -419,14 +418,14 @@ export class TasksService {
if (query.sentFrom) {
const from = new Date(query.sentFrom);
if (Number.isNaN(from.getTime())) {
throw new BadRequestException('Invalid sentFrom date');
throw new AppException(ErrorCode.INVALID_SENT_FROM, HttpStatus.BAD_REQUEST);
}
sentAtFilter.gte = from;
}
if (query.sentTo) {
const to = new Date(query.sentTo);
if (Number.isNaN(to.getTime())) {
throw new BadRequestException('Invalid sentTo date');
throw new AppException(ErrorCode.INVALID_SENT_TO, HttpStatus.BAD_REQUEST);
}
to.setHours(23, 59, 59, 999);
sentAtFilter.lte = to;
@@ -540,7 +539,7 @@ export class TasksService {
const dir = query.sortDir ?? 'desc';
if (sortBy !== 'date') {
throw new BadRequestException('Task page location is only supported for date sort');
throw new AppException(ErrorCode.TASK_PAGE_DATE_SORT_ONLY, HttpStatus.BAD_REQUEST);
}
const sentAt = target.sentAt;
@@ -732,7 +731,7 @@ export class TasksService {
private async assertCanReadTasks(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_TASKS_READ') ||
@@ -740,18 +739,18 @@ export class TasksService {
) {
return;
}
throw new ForbiddenException('You do not have access to tasks');
throw new AppException(ErrorCode.PERMISSION_ACCESS_TASKS, HttpStatus.FORBIDDEN);
}
private async assertCanEditTasks(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_TASKS_EDIT')) {
return;
}
throw new ForbiddenException('You cannot update tasks');
throw new AppException(ErrorCode.PERMISSION_EDIT_TASKS, HttpStatus.FORBIDDEN);
}
private async getMembership(userId: string, organizationId: string) {

View File

@@ -1,8 +1,8 @@
import {
BadRequestException,
ForbiddenException,
HttpStatus,
Injectable,
} from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import { LabTaskStatus, LinkStatus, CatalogEntityKind } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import {
@@ -107,7 +107,7 @@ export class TodayService {
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;
}
@@ -428,10 +428,10 @@ export class TodayService {
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');
throw new AppException(ErrorCode.TODAY_INVALID_RANGE, HttpStatus.BAD_REQUEST);
}
if (to <= from) {
throw new BadRequestException('Range "to" must be after "from"');
throw new AppException(ErrorCode.TODAY_INVALID_RANGE_ORDER, HttpStatus.BAD_REQUEST);
}
return { from, to };
}
@@ -1324,7 +1324,7 @@ export class TodayService {
});
if (!membership) {
throw new ForbiddenException('You are not a member of this organization');
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
}
return membership;
@@ -1345,7 +1345,7 @@ export class TodayService {
private assertCanViewToday(isOwner: boolean, permissionNames: string[]) {
if (isOwner) return;
if (!permissionNames.includes('TAB_TODAY_READ')) {
throw new ForbiddenException('You do not have access to Today');
throw new AppException(ErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN);
}
}

View File

@@ -1,5 +1,5 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { BadRequestException } from '@nestjs/common';
import { HttpStatus, Injectable, OnModuleInit } from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import { CatalogEntityKind } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import {
@@ -101,7 +101,7 @@ export class TreatmentCatalogService implements OnModuleInit {
assertKnownTreatmentType(code: string): TreatmentTypeCatalogEntry {
const entry = this.getByCode(code);
if (!entry) {
throw new BadRequestException(`Unknown treatment type: ${code}`);
throw new AppException(ErrorCode.CATALOG_UNKNOWN_TREATMENT_TYPE, HttpStatus.BAD_REQUEST);
}
return entry;
}
@@ -109,9 +109,7 @@ export class TreatmentCatalogService implements OnModuleInit {
assertLabDependentTreatmentType(code: string): TreatmentTypeCatalogEntry {
const entry = this.assertKnownTreatmentType(code);
if (!entry.labDependent) {
throw new BadRequestException(
`Treatment type "${code}" is completed in the clinic and cannot be sent to a lab`,
);
throw new AppException(ErrorCode.CATALOG_TREATMENT_NOT_LAB_DEPENDENT, HttpStatus.BAD_REQUEST);
}
return entry;
}

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) {