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:
@@ -57,14 +57,121 @@ export const ErrorCode = {
|
||||
VALIDATION_FIELD_REQUIRED: 'VALIDATION_FIELD_REQUIRED',
|
||||
VALIDATION_LANGUAGE_INVALID: 'VALIDATION_LANGUAGE_INVALID',
|
||||
VALIDATION_INVALID_REQUEST: 'VALIDATION_INVALID_REQUEST',
|
||||
VALIDATION_TIMEZONE_INVALID: 'VALIDATION_TIMEZONE_INVALID',
|
||||
|
||||
// Appointments
|
||||
APPOINTMENT_INVALID_TIME: 'APPOINTMENT_INVALID_TIME',
|
||||
APPOINTMENT_END_BEFORE_START: 'APPOINTMENT_END_BEFORE_START',
|
||||
APPOINTMENT_TOO_LONG: 'APPOINTMENT_TOO_LONG',
|
||||
APPOINTMENT_IN_PAST: 'APPOINTMENT_IN_PAST',
|
||||
APPOINTMENT_INVALID_RANGE: 'APPOINTMENT_INVALID_RANGE',
|
||||
APPOINTMENT_INVALID_RANGE_ORDER: 'APPOINTMENT_INVALID_RANGE_ORDER',
|
||||
APPOINTMENT_INVALID_DATE: 'APPOINTMENT_INVALID_DATE',
|
||||
APPOINTMENT_PROVIDER_NOT_MEMBER: 'APPOINTMENT_PROVIDER_NOT_MEMBER',
|
||||
APPOINTMENT_PROVIDER_INACTIVE: 'APPOINTMENT_PROVIDER_INACTIVE',
|
||||
APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT: 'APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT',
|
||||
APPOINTMENT_PROVIDER_NO_WORKING_HOURS: 'APPOINTMENT_PROVIDER_NO_WORKING_HOURS',
|
||||
APPOINTMENT_PROVIDER_NOT_WORKING_DAY: 'APPOINTMENT_PROVIDER_NOT_WORKING_DAY',
|
||||
APPOINTMENT_OUTSIDE_WORKING_HOURS: 'APPOINTMENT_OUTSIDE_WORKING_HOURS',
|
||||
APPOINTMENT_NOT_FOUND: 'APPOINTMENT_NOT_FOUND',
|
||||
APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED',
|
||||
APPOINTMENT_HAS_TREATMENT: 'APPOINTMENT_HAS_TREATMENT',
|
||||
APPOINTMENT_NOT_PROVIDER: 'APPOINTMENT_NOT_PROVIDER',
|
||||
|
||||
PATIENT_NOT_FOUND: 'PATIENT_NOT_FOUND',
|
||||
|
||||
WORKING_HOURS_INVALID: 'WORKING_HOURS_INVALID',
|
||||
WORKING_HOURS_OWNER_NOT_ALLOWED: 'WORKING_HOURS_OWNER_NOT_ALLOWED',
|
||||
WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS: 'WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS',
|
||||
|
||||
STAFF_UNKNOWN_PERMISSIONS: 'STAFF_UNKNOWN_PERMISSIONS',
|
||||
STAFF_NO_SUBSCRIPTION: 'STAFF_NO_SUBSCRIPTION',
|
||||
STAFF_SEAT_LIMIT: 'STAFF_SEAT_LIMIT',
|
||||
STAFF_INVITE_OWNER_EMAIL: 'STAFF_INVITE_OWNER_EMAIL',
|
||||
STAFF_ALREADY_MEMBER: 'STAFF_ALREADY_MEMBER',
|
||||
STAFF_OWNER_NO_INVITE_LINK: 'STAFF_OWNER_NO_INVITE_LINK',
|
||||
STAFF_ALREADY_ACCEPTED: 'STAFF_ALREADY_ACCEPTED',
|
||||
STAFF_INVITE_MISSING: 'STAFF_INVITE_MISSING',
|
||||
STAFF_INVITE_ALREADY_ACCEPTED: 'STAFF_INVITE_ALREADY_ACCEPTED',
|
||||
STAFF_INVITE_INVALID: 'STAFF_INVITE_INVALID',
|
||||
STAFF_INVITE_REVOKED: 'STAFF_INVITE_REVOKED',
|
||||
STAFF_INVITE_EXPIRED: 'STAFF_INVITE_EXPIRED',
|
||||
STAFF_ALREADY_ACTIVE: 'STAFF_ALREADY_ACTIVE',
|
||||
STAFF_ALREADY_DISABLED: 'STAFF_ALREADY_DISABLED',
|
||||
STAFF_CANNOT_DISABLE_SELF: 'STAFF_CANNOT_DISABLE_SELF',
|
||||
STAFF_MEMBER_NOT_FOUND: 'STAFF_MEMBER_NOT_FOUND',
|
||||
STAFF_CANNOT_EDIT_OWNER: 'STAFF_CANNOT_EDIT_OWNER',
|
||||
STAFF_CANNOT_ENABLE_OWNER: 'STAFF_CANNOT_ENABLE_OWNER',
|
||||
STAFF_CANNOT_DISABLE_OWNER: 'STAFF_CANNOT_DISABLE_OWNER',
|
||||
STAFF_CANNOT_REMOVE_OWNER: 'STAFF_CANNOT_REMOVE_OWNER',
|
||||
|
||||
ORG_CANNOT_LINK_SELF: 'ORG_CANNOT_LINK_SELF',
|
||||
ORG_LINK_WRONG_TYPE: 'ORG_LINK_WRONG_TYPE',
|
||||
ORG_TARGET_NO_SUBSCRIPTION: 'ORG_TARGET_NO_SUBSCRIPTION',
|
||||
ORG_LINK_EXISTS: 'ORG_LINK_EXISTS',
|
||||
ORG_REQUEST_NOT_PENDING: 'ORG_REQUEST_NOT_PENDING',
|
||||
ORG_CANNOT_RESPOND_OWN: 'ORG_CANNOT_RESPOND_OWN',
|
||||
ORG_INVITE_ALREADY_ACCEPTED: 'ORG_INVITE_ALREADY_ACCEPTED',
|
||||
ORG_INVITE_INVALID: 'ORG_INVITE_INVALID',
|
||||
ORG_INVITE_REVOKED: 'ORG_INVITE_REVOKED',
|
||||
ORG_INVITE_EXPIRED: 'ORG_INVITE_EXPIRED',
|
||||
ORG_INVITE_TYPE_MISMATCH: 'ORG_INVITE_TYPE_MISMATCH',
|
||||
ORG_INVITE_OWNER_HAS_ORG: 'ORG_INVITE_OWNER_HAS_ORG',
|
||||
ORG_UNKNOWN_TYPE: 'ORG_UNKNOWN_TYPE',
|
||||
ORG_COUNTERPART_NOT_LAB: 'ORG_COUNTERPART_NOT_LAB',
|
||||
ORG_COUNTERPART_NOT_CLINIC: 'ORG_COUNTERPART_NOT_CLINIC',
|
||||
ORG_CONNECTION_NOT_FOUND: 'ORG_CONNECTION_NOT_FOUND',
|
||||
ORG_INVITE_NOT_FOUND: 'ORG_INVITE_NOT_FOUND',
|
||||
ORG_ONLY_CLINIC_COMMENT: 'ORG_ONLY_CLINIC_COMMENT',
|
||||
|
||||
CATALOG_UNKNOWN_TREATMENT_TYPE: 'CATALOG_UNKNOWN_TREATMENT_TYPE',
|
||||
CATALOG_UNKNOWN_PROSTHESIS_TYPE: 'CATALOG_UNKNOWN_PROSTHESIS_TYPE',
|
||||
CATALOG_TREATMENT_NOT_LAB_DEPENDENT: 'CATALOG_TREATMENT_NOT_LAB_DEPENDENT',
|
||||
|
||||
TREATMENT_SAVE_DETAILS_BEFORE_LAB: 'TREATMENT_SAVE_DETAILS_BEFORE_LAB',
|
||||
TREATMENT_DETAIL_ONE_CASE: 'TREATMENT_DETAIL_ONE_CASE',
|
||||
TREATMENT_DETAILS_NOT_FOUND: 'TREATMENT_DETAILS_NOT_FOUND',
|
||||
TREATMENT_DEST_NOT_LINKED: 'TREATMENT_DEST_NOT_LINKED',
|
||||
TREATMENT_TEETH_REQUIRED_TO_SHIP: 'TREATMENT_TEETH_REQUIRED_TO_SHIP',
|
||||
TREATMENT_TOOTH_UNKNOWN_DETAIL: 'TREATMENT_TOOTH_UNKNOWN_DETAIL',
|
||||
TREATMENT_TOOTH_NOT_ON_DETAIL: 'TREATMENT_TOOTH_NOT_ON_DETAIL',
|
||||
TREATMENT_CASE_NO_DEST: 'TREATMENT_CASE_NO_DEST',
|
||||
TREATMENT_CASE_NEEDS_DETAIL: 'TREATMENT_CASE_NEEDS_DETAIL',
|
||||
TREATMENT_CASE_ONE_DETAIL: 'TREATMENT_CASE_ONE_DETAIL',
|
||||
TREATMENT_ONLY_PROVIDER_SEND: 'TREATMENT_ONLY_PROVIDER_SEND',
|
||||
TREATMENT_CASE_ALREADY_SENT: 'TREATMENT_CASE_ALREADY_SENT',
|
||||
TREATMENT_DUE_DATE_LOCKED: 'TREATMENT_DUE_DATE_LOCKED',
|
||||
TREATMENT_INVALID_DUE_DATE: 'TREATMENT_INVALID_DUE_DATE',
|
||||
TREATMENT_DETAIL_KEY_REQUIRED: 'TREATMENT_DETAIL_KEY_REQUIRED',
|
||||
TREATMENT_FILE_REQUIRED: 'TREATMENT_FILE_REQUIRED',
|
||||
TREATMENT_ATTACHMENT_NOT_FOUND: 'TREATMENT_ATTACHMENT_NOT_FOUND',
|
||||
TREATMENT_FILE_UNAVAILABLE: 'TREATMENT_FILE_UNAVAILABLE',
|
||||
TREATMENT_CASE_INVALID_ATTACHMENTS: 'TREATMENT_CASE_INVALID_ATTACHMENTS',
|
||||
TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE: 'TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE',
|
||||
TREATMENT_DETAIL_SENT: 'TREATMENT_DETAIL_SENT',
|
||||
LAB_CASE_NOT_FOUND: 'LAB_CASE_NOT_FOUND',
|
||||
|
||||
TASK_ASSIGNEE_INVALID: 'TASK_ASSIGNEE_INVALID',
|
||||
TASK_ASSIGNED_TO_OTHER: 'TASK_ASSIGNED_TO_OTHER',
|
||||
TASK_PAGE_DATE_SORT_ONLY: 'TASK_PAGE_DATE_SORT_ONLY',
|
||||
INVALID_SENT_FROM: 'INVALID_SENT_FROM',
|
||||
INVALID_SENT_TO: 'INVALID_SENT_TO',
|
||||
|
||||
CASE_NOT_FOUND: 'CASE_NOT_FOUND',
|
||||
CASE_ATTACHMENT_NOT_FOUND: 'CASE_ATTACHMENT_NOT_FOUND',
|
||||
CASE_ATTACHMENT_MISSING_FILE: 'CASE_ATTACHMENT_MISSING_FILE',
|
||||
CASE_TASK_NOT_FOUND: 'CASE_TASK_NOT_FOUND',
|
||||
|
||||
COMMENT_NOT_FOUND: 'COMMENT_NOT_FOUND',
|
||||
COMMENT_VISIBILITY_LAB_ONLY: 'COMMENT_VISIBILITY_LAB_ONLY',
|
||||
|
||||
TODAY_INVALID_RANGE: 'TODAY_INVALID_RANGE',
|
||||
TODAY_INVALID_RANGE_ORDER: 'TODAY_INVALID_RANGE_ORDER',
|
||||
|
||||
// Generic HTTP
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
CONFLICT: 'CONFLICT',
|
||||
CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS',
|
||||
APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED',
|
||||
APPOINTMENT_HAS_TREATMENT: 'APPOINTMENT_HAS_TREATMENT',
|
||||
TREATMENT_DETAIL_SENT: 'TREATMENT_DETAIL_SENT',
|
||||
BAD_REQUEST: 'BAD_REQUEST',
|
||||
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
||||
} as const;
|
||||
|
||||
@@ -28,58 +28,6 @@ const STATUS_FALLBACK_CODES: Partial<Record<number, ErrorCodeValue>> = {
|
||||
[HttpStatus.INTERNAL_SERVER_ERROR]: ErrorCode.INTERNAL_ERROR,
|
||||
};
|
||||
|
||||
/** Maps legacy English messages to stable codes during migration. */
|
||||
const LEGACY_MESSAGE_CODES: Record<string, ErrorCodeValue> = {
|
||||
'Invalid credentials': ErrorCode.AUTH_INVALID_CREDENTIALS,
|
||||
Unauthorized: ErrorCode.AUTH_UNAUTHORIZED,
|
||||
'Refresh token not found': ErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND,
|
||||
'Invalid or expired refresh token': ErrorCode.AUTH_REFRESH_TOKEN_INVALID,
|
||||
'Invalid refresh token': ErrorCode.AUTH_REFRESH_TOKEN_INVALID,
|
||||
'Refresh token failed': ErrorCode.AUTH_REFRESH_TOKEN_INVALID,
|
||||
'Invalid token type': ErrorCode.AUTH_SESSION_EXPIRED,
|
||||
'Invalid token': ErrorCode.AUTH_SESSION_EXPIRED,
|
||||
'Session not found or expired': ErrorCode.AUTH_SESSION_EXPIRED,
|
||||
'User not found': ErrorCode.AUTH_USER_NOT_FOUND,
|
||||
'Organization is not selected': ErrorCode.AUTH_ORG_NOT_SELECTED,
|
||||
'Access denied to this organization': ErrorCode.AUTH_ORG_ACCESS_DENIED,
|
||||
'Your invitation is still pending activation': ErrorCode.AUTH_INVITATION_PENDING,
|
||||
'Auto-login failed': ErrorCode.AUTH_REGISTRATION_FAILED,
|
||||
'User already exists. Please login and create a new organization from your account.':
|
||||
ErrorCode.AUTH_REGISTRATION_EMAIL_EXISTS,
|
||||
'This mobile number is already registered.': ErrorCode.AUTH_REGISTRATION_MOBILE_EXISTS,
|
||||
'Please enter a valid mobile number': ErrorCode.AUTH_REGISTRATION_MOBILE_INVALID,
|
||||
'Current password is incorrect': ErrorCode.AUTH_PASSWORD_INCORRECT,
|
||||
'Password reset verification expired. Please verify your mobile again.':
|
||||
ErrorCode.AUTH_PASSWORD_RESET_EXPIRED,
|
||||
'Invalid or expired verification code': ErrorCode.AUTH_VERIFICATION_CODE_INVALID,
|
||||
'Please wait before requesting another code': ErrorCode.AUTH_VERIFICATION_RATE_LIMIT,
|
||||
'Current password is required': ErrorCode.AUTH_PASSWORD_CURRENT_REQUIRED,
|
||||
'Select an organization before creating a new one.': ErrorCode.AUTH_SELECT_ORG_FIRST,
|
||||
'Only owners of the current organization can create new organizations.':
|
||||
ErrorCode.AUTH_CREATE_ORG_OWNER_ONLY,
|
||||
'This action is only available for clinic organizations': ErrorCode.PERMISSION_CLINIC_ONLY,
|
||||
'This action is only available for lab organizations': ErrorCode.PERMISSION_LAB_ONLY,
|
||||
'Organization not found': ErrorCode.PERMISSION_ORG_NOT_FOUND,
|
||||
'Unknown organization type': ErrorCode.PERMISSION_DENIED,
|
||||
'You do not have permission to manage organizations': ErrorCode.PERMISSION_ORG_MANAGE,
|
||||
'You are not a member of this organization': ErrorCode.PERMISSION_NOT_MEMBER,
|
||||
'Only organization owners can manage participation': ErrorCode.PERMISSION_OWNER_ONLY,
|
||||
'Only organization owners can manage their working hours': ErrorCode.PERMISSION_OWNER_ONLY,
|
||||
'An active subscription is required to participate in treatments or tasks':
|
||||
ErrorCode.PERMISSION_PARTICIPATION_SUBSCRIPTION,
|
||||
'Enable treatment participation before setting working hours':
|
||||
ErrorCode.PERMISSION_ENABLE_PARTICIPATION_FIRST,
|
||||
'Working hours are only available for clinic organizations':
|
||||
ErrorCode.PERMISSION_CLINIC_WORKING_HOURS,
|
||||
'You do not have access to appointments': ErrorCode.PERMISSION_ACCESS_APPOINTMENTS,
|
||||
'You cannot create or modify appointments': ErrorCode.PERMISSION_EDIT_APPOINTMENTS,
|
||||
'You do not have access to treatments': ErrorCode.PERMISSION_ACCESS_TREATMENTS,
|
||||
'You cannot edit treatments': ErrorCode.PERMISSION_EDIT_TREATMENTS,
|
||||
'You do not have access to tasks': ErrorCode.PERMISSION_ACCESS_TASKS,
|
||||
'You do not have access to staff management': ErrorCode.PERMISSION_ACCESS_STAFF,
|
||||
'You cannot manage staff working hours': ErrorCode.PERMISSION_EDIT_STAFF,
|
||||
};
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
@@ -121,7 +69,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
};
|
||||
}
|
||||
|
||||
const code = this.resolveLegacyCode(rawResponse, statusCode);
|
||||
const code = this.resolveFallbackCode(rawResponse, statusCode);
|
||||
return {
|
||||
statusCode,
|
||||
body: this.buildBody(code, statusCode),
|
||||
@@ -137,20 +85,12 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
);
|
||||
}
|
||||
|
||||
private resolveLegacyCode(rawResponse: string | object, statusCode: number): ErrorCodeValue {
|
||||
/** Nest/Passport leftovers: validation arrays, otherwise HTTP status. */
|
||||
private resolveFallbackCode(rawResponse: string | object, statusCode: number): ErrorCodeValue {
|
||||
const message = this.extractMessage(rawResponse);
|
||||
|
||||
if (typeof message === 'string') {
|
||||
const mapped = LEGACY_MESSAGE_CODES[message.trim()];
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(message)) {
|
||||
return ErrorCode.VALIDATION_FAILED;
|
||||
}
|
||||
|
||||
return STATUS_FALLBACK_CODES[statusCode] ?? ErrorCode.INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import type { ValidationError } from 'class-validator';
|
||||
import { AppException } from './app.exception';
|
||||
import { ErrorCode, type ErrorCodeValue, type ValidationErrorDetail } from './error-codes';
|
||||
@@ -22,7 +22,12 @@ function constraintToCode(constraintKey: string, message: string): ErrorCodeValu
|
||||
case 'isEnum':
|
||||
return ErrorCode.VALIDATION_ORGANIZATION_TYPE_INVALID;
|
||||
case 'matches':
|
||||
return ErrorCode.VALIDATION_MOBILE_INVALID;
|
||||
case 'isDateString':
|
||||
case 'isUuid':
|
||||
case 'isInt':
|
||||
case 'isBoolean':
|
||||
case 'isArray':
|
||||
return ErrorCode.VALIDATION_INVALID_REQUEST;
|
||||
case 'isIn':
|
||||
return ErrorCode.VALIDATION_LANGUAGE_INVALID;
|
||||
case 'whitelistValidation':
|
||||
@@ -66,8 +71,3 @@ export function validationExceptionFactory(errors: ValidationError[]): HttpExcep
|
||||
|
||||
return new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.BAD_REQUEST, details);
|
||||
}
|
||||
|
||||
/** Handles forbidNonWhitelisted errors from ValidationPipe. */
|
||||
export function isValidationPipeBadRequest(exception: unknown): exception is BadRequestException {
|
||||
return exception instanceof BadRequestException;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { zonedWeekdayAndMinutes } from './zoned-civil-time';
|
||||
|
||||
export const MINUTES_PER_DAY = 24 * 60;
|
||||
|
||||
export type WorkingHoursBlockInput = {
|
||||
@@ -82,17 +84,18 @@ export function isRangeWithinWorkingBlocks(
|
||||
return true;
|
||||
}
|
||||
|
||||
export function dateToLocalMinutes(date: Date): number {
|
||||
return date.getHours() * 60 + date.getMinutes();
|
||||
export function dateToZonedMinutes(date: Date, timeZone: string): number {
|
||||
return zonedWeekdayAndMinutes(date, timeZone).minuteOfDay;
|
||||
}
|
||||
|
||||
export function appointmentWithinWorkingHours(
|
||||
startAt: Date,
|
||||
endAt: Date,
|
||||
dayBlocks: WorkingHoursDayBlock[],
|
||||
timeZone: string,
|
||||
): boolean {
|
||||
const startMinute = dateToLocalMinutes(startAt);
|
||||
const endMinute = dateToLocalMinutes(endAt);
|
||||
const startMinute = dateToZonedMinutes(startAt, timeZone);
|
||||
const endMinute = dateToZonedMinutes(endAt, timeZone);
|
||||
return isRangeWithinWorkingBlocks(startMinute, endMinute, dayBlocks);
|
||||
}
|
||||
|
||||
|
||||
29
backend/src/common/zoned-civil-time.spec.ts
Normal file
29
backend/src/common/zoned-civil-time.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { appointmentWithinWorkingHours } from './working-hours';
|
||||
import { isValidIanaTimeZone, zonedWeekdayAndMinutes } from './zoned-civil-time';
|
||||
|
||||
describe('zoned civil time', () => {
|
||||
it('accepts IANA zones and rejects garbage', () => {
|
||||
expect(isValidIanaTimeZone('Asia/Tehran')).toBe(true);
|
||||
expect(isValidIanaTimeZone('Europe/Amsterdam')).toBe(true);
|
||||
expect(isValidIanaTimeZone('Not/AZone')).toBe(false);
|
||||
});
|
||||
|
||||
it('maps a UTC instant to Tehran wall-clock (no DST)', () => {
|
||||
// 2026-08-20 09:45 Asia/Tehran = 06:15 UTC (the production 400 case)
|
||||
const instant = new Date('2026-08-20T06:15:00.000Z');
|
||||
const zoned = zonedWeekdayAndMinutes(instant, 'Asia/Tehran');
|
||||
expect(zoned.jsWeekday).toBe(4); // Thursday
|
||||
expect(zoned.minuteOfDay).toBe(9 * 60 + 45);
|
||||
});
|
||||
|
||||
it('treats morning Tehran slots as inside 08:00–17:00 hours', () => {
|
||||
const start = new Date('2026-08-20T06:15:00.000Z');
|
||||
const end = new Date('2026-08-20T06:45:00.000Z');
|
||||
expect(
|
||||
appointmentWithinWorkingHours(start, end, [{ startMinute: 8 * 60, endMinute: 17 * 60 }], 'Asia/Tehran'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
appointmentWithinWorkingHours(start, end, [{ startMinute: 8 * 60, endMinute: 17 * 60 }], 'UTC'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
55
backend/src/common/zoned-civil-time.ts
Normal file
55
backend/src/common/zoned-civil-time.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/** Convert an absolute instant into weekday + minute-of-day in an IANA time zone. */
|
||||
|
||||
const JS_WEEKDAY: Record<string, number> = {
|
||||
Sun: 0,
|
||||
Mon: 1,
|
||||
Tue: 2,
|
||||
Wed: 3,
|
||||
Thu: 4,
|
||||
Fri: 5,
|
||||
Sat: 6,
|
||||
};
|
||||
|
||||
export function isValidIanaTimeZone(timeZone: string): boolean {
|
||||
if (!timeZone || timeZone.length > 64) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Intl.DateTimeFormat('en-US', { timeZone }).format(new Date(0));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function zonedWeekdayAndMinutes(
|
||||
date: Date,
|
||||
timeZone: string,
|
||||
): { jsWeekday: number; minuteOfDay: number } {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
weekday: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(date);
|
||||
|
||||
const weekdayToken = parts.find((p) => p.type === 'weekday')?.value ?? 'Sun';
|
||||
let hour = Number(parts.find((p) => p.type === 'hour')?.value ?? '0');
|
||||
const minute = Number(parts.find((p) => p.type === 'minute')?.value ?? '0');
|
||||
if (hour === 24) {
|
||||
hour = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
jsWeekday: JS_WEEKDAY[weekdayToken] ?? 0,
|
||||
minuteOfDay: hour * 60 + minute,
|
||||
};
|
||||
}
|
||||
|
||||
/** Weekday of a YYYY-MM-DD civil date (Gregorian; same worldwide). */
|
||||
export function civilDateJsWeekday(isoDate: string): number {
|
||||
const [y, m, d] = isoDate.split('-').map(Number);
|
||||
const utcNoon = new Date(Date.UTC(y, m - 1, d, 12, 0, 0, 0));
|
||||
return utcNoon.getUTCDay();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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