bugfix: appointment hours now use the client timezone on UTC servers.

Logical API errors throw stable codes so users see translated messages instead of a generic bad request.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-19 01:43:50 +03:30
parent d6958b2e48
commit 80167c622c
38 changed files with 833 additions and 392 deletions

View File

@@ -24,5 +24,6 @@ Frontend maps `details[].code` through the `errors` namespace.
## Do not
- Throw Nest `BadRequestException('English sentence')` for user-facing errors.
- Show raw `error.message` or stack traces to users.
- Add English-only strings inline in components.

View File

@@ -10,5 +10,6 @@ alwaysApply: false
- **Patient change** blocked while linked → `APPOINTMENT_PATIENT_LOCKED` (UI: `patientLockedHint`).
- **Delete** blocked while linked → `APPOINTMENT_HAS_TREATMENT` (hide delete + `deleteBlockedHint`). Empty appointments (no treatment yet) remain deletable.
- **Past days:** new bookings stay blocked. Existing appointments **without** treatment can be edited/deleted; with treatment → toast `infoEditBlockedHasTreatment` (no modal). Banner clicks are not gated by `canBook` (slots still are).
- **Working hours / create:** interpret wall-clock in the **client IANA time zone** (`timeZone` on create/update, e.g. `Asia/Tehran`). Do not use Node `Date#getHours()` / `getDay()` on the server (UTC Docker vs clinic local). Helper: `zoned-civil-time.ts`.
- **Disabled feedback:** use `title` + click `toast.showInfo` with `common.readOnlyAccess` (same key as Staff/Patients) or the domain reason (`infoPastViewOnly`, `infoEditBlockedHasTreatment`).
- Prisma: `Treatment.appointmentId` is `onDelete: SetNull` — deleting an appointment does **not** cascade-delete treatments/cases/tasks; do not rely on cascade for cleanup.

View File

@@ -38,6 +38,10 @@ CLINIC + LAB KPIs/charts in `modules/today/today.service.ts`. Deep links: `compo
QR + URL for **sent** cases; focus page `/lab-case/[token]`. Auth redirect via `postAuthRedirect.ts` + `useEnterAppWhenAuthenticated` (not inside `login()`). Skill: `.cursor/skills/lab-case-share-link/SKILL.md`.
## API errors
Logical failures: `AppException(ErrorCode.X)` → `errors.X` in en/fa/nl. UI: `getUserFacingError`. Skill: `.cursor/skills/api-errors/SKILL.md`. Appointments/working hours: client IANA `timeZone` (never Node local `getHours()`).
## Notifications (inbox + live tabs)
Header bell: `UserNotification` + Socket.IO. Same `notification.created` also drives sidebar tab badges and soft list refresh on **currently open** Cases/Tasks/Treatment/Orgs pages. Skills: `.cursor/skills/notifications-inbox/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`.

View File

@@ -7,16 +7,16 @@ description: Adds or migrates Dyolink API error codes with frontend translations
## Backend
1. Add to `ErrorCode` in `backend/src/common/errors/error-codes.ts`.
1. Add code to `ErrorCode` in `backend/src/common/errors/error-codes.ts`.
2. Throw with `AppException`:
```typescript
throw new AppException(ErrorCode.MY_CODE, HttpStatus.BAD_REQUEST, [
{ field: 'email', code: ErrorCode.VALIDATION_EMAIL_INVALID },
]);
throw new AppException(ErrorCode.MY_CODE, HttpStatus.BAD_REQUEST);
```
3. DTOs: `@IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID })`
3. DTOs: always `{ message: ErrorCode.X }` on class-validator decorators (do not rely on constraint-key fallbacks — e.g. `@Matches` is not always a mobile number).
4. Do **not** throw Nest `BadRequestException('English…')` — unmapped Nest exceptions fall back to HTTP status only (`BAD_REQUEST`, `AUTH_UNAUTHORIZED`, …).
5. Wall-clock rules (working hours, weekday): pass the client **IANA** `timeZone` and use `zoned-civil-time.ts`. Never `Date#getHours()` / `getDay()` on the UTC server.
## Frontend

View File

@@ -58,7 +58,7 @@ frontend/src/
- **Live lab rail**: `notification.created``notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state).
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`.
**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. See `.cursor/rules/appointments.mdc`.
**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. Working hours: client IANA `timeZone` on create/update — never `Date#getHours()`/`getDay()` on the UTC server. Logical API errors: `AppException` + `errors.*` (never Nest English throws). See `.cursor/rules/appointments.mdc`, `.cursor/skills/api-errors/SKILL.md`.
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

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

View 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:0017: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);
});
});

View 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();
}

View File

@@ -1,9 +1,6 @@
import {
BadRequestException,
ForbiddenException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import {
@@ -11,6 +8,7 @@ import {
blocksForDay,
localDayOfWeekMondayZero,
} from '../../common/working-hours';
import { civilDateJsWeekday, isValidIanaTimeZone, zonedWeekdayAndMinutes } from '../../common/zoned-civil-time';
import { StaffWorkingHoursService } from '../staff/staff-working-hours.service';
import { CreateAppointmentDto } from './dto/create-appointment.dto';
import { ListAppointmentsDto } from './dto/list-appointments.dto';
@@ -31,7 +29,7 @@ export class AppointmentsService {
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
throw new BadRequestException('Organization is not selected');
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
}
return user.organizationId;
}
@@ -88,11 +86,11 @@ export class AppointmentsService {
const to = new Date(query.to);
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
throw new BadRequestException('Invalid date range');
throw new AppException(ErrorCode.APPOINTMENT_INVALID_RANGE, HttpStatus.BAD_REQUEST);
}
if (to <= from) {
throw new BadRequestException('Range "to" must be after "from"');
throw new AppException(ErrorCode.APPOINTMENT_INVALID_RANGE_ORDER, HttpStatus.BAD_REQUEST);
}
const items = await this.prisma.appointment.findMany({
@@ -129,22 +127,23 @@ export class AppointmentsService {
const startAt = new Date(dto.startAt);
const endAt = new Date(dto.endAt);
const timeZone = this.requireTimeZone(dto.timeZone);
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) {
throw new BadRequestException('Invalid start or end time');
throw new AppException(ErrorCode.APPOINTMENT_INVALID_TIME, HttpStatus.BAD_REQUEST);
}
if (endAt <= startAt) {
throw new BadRequestException('End time must be after start time');
throw new AppException(ErrorCode.APPOINTMENT_END_BEFORE_START, HttpStatus.BAD_REQUEST);
}
if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) {
throw new BadRequestException('Appointment cannot span more than 24 hours');
throw new AppException(ErrorCode.APPOINTMENT_TOO_LONG, HttpStatus.BAD_REQUEST);
}
const now = Date.now();
if (startAt.getTime() < now) {
throw new BadRequestException('Cannot schedule appointments in the past');
throw new AppException(ErrorCode.APPOINTMENT_IN_PAST, HttpStatus.BAD_REQUEST);
}
await this.ensurePatientInOrg(dto.patientId, organizationId);
@@ -155,6 +154,7 @@ export class AppointmentsService {
organizationId,
startAt,
endAt,
timeZone,
);
const appointment = await this.prisma.appointment.create({
@@ -189,22 +189,23 @@ export class AppointmentsService {
});
if (!existing) {
throw new NotFoundException('Appointment not found');
throw new AppException(ErrorCode.APPOINTMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
const startAt = dto.startAt ? new Date(dto.startAt) : existing.startAt;
const endAt = dto.endAt ? new Date(dto.endAt) : existing.endAt;
const timeZone = this.requireTimeZone(dto.timeZone);
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) {
throw new BadRequestException('Invalid start or end time');
throw new AppException(ErrorCode.APPOINTMENT_INVALID_TIME, HttpStatus.BAD_REQUEST);
}
if (endAt <= startAt) {
throw new BadRequestException('End time must be after start time');
throw new AppException(ErrorCode.APPOINTMENT_END_BEFORE_START, HttpStatus.BAD_REQUEST);
}
if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) {
throw new BadRequestException('Appointment cannot span more than 24 hours');
throw new AppException(ErrorCode.APPOINTMENT_TOO_LONG, HttpStatus.BAD_REQUEST);
}
const patientId = dto.patientId ?? existing.patientId;
@@ -230,6 +231,7 @@ export class AppointmentsService {
organizationId,
startAt,
endAt,
timeZone,
);
const appointment = await this.prisma.appointment.update({
@@ -260,7 +262,7 @@ export class AppointmentsService {
});
if (!existing) {
throw new NotFoundException('Appointment not found');
throw new AppException(ErrorCode.APPOINTMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (existing.treatment) {
@@ -277,7 +279,7 @@ export class AppointmentsService {
private async assertCanViewAppointments(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
throw new ForbiddenException('You are not a member of this organization');
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
}
if (m.isOwner) {
return;
@@ -286,7 +288,7 @@ export class AppointmentsService {
if (names.includes('TAB_APPOINTMENTS_READ') || names.includes('TAB_APPOINTMENTS_EDIT')) {
return;
}
throw new ForbiddenException('You do not have access to appointments');
throw new AppException(ErrorCode.PERMISSION_ACCESS_APPOINTMENTS, HttpStatus.FORBIDDEN);
}
private async assertCanListAppointmentsForTreatment(
@@ -295,7 +297,7 @@ export class AppointmentsService {
) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
throw new ForbiddenException('You are not a member of this organization');
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
}
if (m.isOwner) {
return { membership: m, scopeToProvider: false as const };
@@ -306,7 +308,7 @@ export class AppointmentsService {
const canViewTreatment =
names.includes('TAB_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT');
if (!canViewSchedule && !canViewTreatment) {
throw new ForbiddenException('You do not have access to appointments');
throw new AppException(ErrorCode.PERMISSION_ACCESS_APPOINTMENTS, HttpStatus.FORBIDDEN);
}
return { membership: m, scopeToProvider: !canViewSchedule && canViewTreatment };
}
@@ -314,7 +316,7 @@ export class AppointmentsService {
private async assertCanEditAppointments(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
throw new ForbiddenException('You are not a member of this organization');
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
}
if (m.isOwner) {
return;
@@ -323,19 +325,19 @@ export class AppointmentsService {
if (names.includes('TAB_APPOINTMENTS_EDIT')) {
return;
}
throw new ForbiddenException('You cannot create or modify appointments');
throw new AppException(ErrorCode.PERMISSION_EDIT_APPOINTMENTS, HttpStatus.FORBIDDEN);
}
private async ensureProviderIsTreatmentEditor(providerUserId: string, organizationId: string) {
const m = await this.getMembership(providerUserId, organizationId);
if (!m) {
throw new BadRequestException('Provider is not a member of this organization');
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NOT_MEMBER, HttpStatus.BAD_REQUEST);
}
if (!m.isOwner && !m.isActive) {
throw new BadRequestException('Provider is not an active staff member');
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_INACTIVE, HttpStatus.BAD_REQUEST);
}
if (!hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')) {
throw new BadRequestException('Provider does not have treatment edit access');
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT, HttpStatus.BAD_REQUEST);
}
}
@@ -345,20 +347,25 @@ export class AppointmentsService {
select: { id: true },
});
if (!patient) {
throw new NotFoundException('Patient not found');
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
}
private requireTimeZone(timeZone: string | undefined): string {
if (!timeZone || !isValidIanaTimeZone(timeZone)) {
throw new AppException(ErrorCode.VALIDATION_TIMEZONE_INVALID, HttpStatus.BAD_REQUEST);
}
return timeZone;
}
private resolveDayOfWeekMondayZero(date?: string): number {
if (!date) {
return localDayOfWeekMondayZero(new Date().getDay());
return localDayOfWeekMondayZero(new Date().getUTCDay());
}
const [y, m, d] = date.split('-').map(Number);
const parsed = new Date(y, m - 1, d, 12, 0, 0, 0);
if (Number.isNaN(parsed.getTime())) {
throw new BadRequestException('Invalid date query parameter');
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new AppException(ErrorCode.APPOINTMENT_INVALID_DATE, HttpStatus.BAD_REQUEST);
}
return localDayOfWeekMondayZero(parsed.getDay());
return localDayOfWeekMondayZero(civilDateJsWeekday(date));
}
private async ensureAppointmentWithinProviderWorkingHours(
@@ -366,27 +373,28 @@ export class AppointmentsService {
organizationId: string,
startAt: Date,
endAt: Date,
timeZone: string,
) {
const membership = await this.getMembership(providerUserId, organizationId);
if (!membership) {
throw new BadRequestException('Provider is not a member of this organization');
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NOT_MEMBER, HttpStatus.BAD_REQUEST);
}
const scheduleBlocksByMembership =
await this.staffWorkingHoursService.loadScheduleBlocksByMembershipIds([membership.id]);
const allBlocks = scheduleBlocksByMembership.get(membership.id) ?? [];
if (allBlocks.length === 0) {
throw new BadRequestException('Provider has no working hours configured');
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NO_WORKING_HOURS, HttpStatus.BAD_REQUEST);
}
const dayOfWeek = localDayOfWeekMondayZero(startAt.getDay());
const dayOfWeek = localDayOfWeekMondayZero(zonedWeekdayAndMinutes(startAt, timeZone).jsWeekday);
const dayBlocks = blocksForDay(allBlocks, dayOfWeek);
if (dayBlocks.length === 0) {
throw new BadRequestException('Provider is not working on this day');
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NOT_WORKING_DAY, HttpStatus.BAD_REQUEST);
}
if (!appointmentWithinWorkingHours(startAt, endAt, dayBlocks)) {
throw new BadRequestException('Appointment must fall within the provider working hours');
if (!appointmentWithinWorkingHours(startAt, endAt, dayBlocks, timeZone)) {
throw new AppException(ErrorCode.APPOINTMENT_OUTSIDE_WORKING_HOURS, HttpStatus.BAD_REQUEST);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { BadRequestException, HttpStatus, Injectable, NotFoundException } from '@nestjs/common';
import { HttpStatus, Injectable } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { isValidMobile, mobileSearchDigits, normalizeMobile } from '../../common/phone';
import { hasEffectivePermission } from '../../common/membership-permissions';
@@ -77,7 +77,7 @@ export class PatientsService {
});
if (!patient) {
throw new NotFoundException('Patient not found');
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
return { success: true, data: patient };
@@ -162,7 +162,7 @@ export class PatientsService {
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
throw new BadRequestException('Organization is not selected');
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
}
return user.organizationId;
}
@@ -240,7 +240,7 @@ export class PatientsService {
});
if (!patient) {
throw new NotFoundException('Patient not found');
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1080,6 +1080,101 @@
"VALIDATION_FIELD_REQUIRED": "Please fill in all required fields.",
"VALIDATION_LANGUAGE_INVALID": "Please select a supported language.",
"VALIDATION_INVALID_REQUEST": "The request contains invalid data.",
"VALIDATION_TIMEZONE_INVALID": "Your timezone could not be determined. Refresh the page and try again.",
"APPOINTMENT_INVALID_TIME": "The appointment start or end time is invalid.",
"APPOINTMENT_END_BEFORE_START": "End time must be after start time.",
"APPOINTMENT_TOO_LONG": "An appointment cannot last more than 24 hours.",
"APPOINTMENT_IN_PAST": "You cannot book an appointment in the past.",
"APPOINTMENT_INVALID_RANGE": "The date range is invalid.",
"APPOINTMENT_INVALID_RANGE_ORDER": "The end of the range must be after the start.",
"APPOINTMENT_INVALID_DATE": "The selected date is invalid.",
"APPOINTMENT_PROVIDER_NOT_MEMBER": "This provider is not a member of the organization.",
"APPOINTMENT_PROVIDER_INACTIVE": "This provider is not an active staff member.",
"APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT": "This provider cannot take treatment appointments.",
"APPOINTMENT_PROVIDER_NO_WORKING_HOURS": "This provider has no working hours set.",
"APPOINTMENT_PROVIDER_NOT_WORKING_DAY": "This provider does not work on the selected day.",
"APPOINTMENT_OUTSIDE_WORKING_HOURS": "This time is outside the providers working hours.",
"APPOINTMENT_NOT_FOUND": "Appointment not found.",
"APPOINTMENT_NOT_PROVIDER": "You are not the provider for this appointment.",
"PATIENT_NOT_FOUND": "Patient not found.",
"WORKING_HOURS_INVALID": "Working hours are invalid. Check that shifts do not overlap.",
"WORKING_HOURS_OWNER_NOT_ALLOWED": "Set owner working hours from account settings.",
"WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "These hours conflict with upcoming appointments. Reschedule or remove those appointments first.",
"STAFF_UNKNOWN_PERMISSIONS": "One or more selected permissions are not valid.",
"STAFF_NO_SUBSCRIPTION": "Choose a plan before adding staff.",
"STAFF_SEAT_LIMIT": "Your plan has no free seats. Disable a member or upgrade to add more.",
"STAFF_INVITE_OWNER_EMAIL": "The organization owner is already a member.",
"STAFF_ALREADY_MEMBER": "This person is already a member.",
"STAFF_OWNER_NO_INVITE_LINK": "The owner does not use an invitation link.",
"STAFF_ALREADY_ACCEPTED": "This member has already accepted their invitation.",
"STAFF_INVITE_MISSING": "No invitation was found for this member.",
"STAFF_INVITE_ALREADY_ACCEPTED": "This invitation has already been accepted.",
"STAFF_INVITE_INVALID": "This invitation is no longer valid.",
"STAFF_INVITE_REVOKED": "This invitation has been revoked.",
"STAFF_INVITE_EXPIRED": "This invitation has expired.",
"STAFF_ALREADY_ACTIVE": "This member is already active.",
"STAFF_ALREADY_DISABLED": "This member is already disabled or pending.",
"STAFF_CANNOT_DISABLE_SELF": "You cannot disable your own access.",
"STAFF_MEMBER_NOT_FOUND": "Staff member not found.",
"STAFF_CANNOT_EDIT_OWNER": "The owners membership cannot be edited here.",
"STAFF_CANNOT_ENABLE_OWNER": "The organization owner cannot be enabled this way.",
"STAFF_CANNOT_DISABLE_OWNER": "The organization owner cannot be disabled.",
"STAFF_CANNOT_REMOVE_OWNER": "The organization owner cannot be removed.",
"ORG_CANNOT_LINK_SELF": "You cannot link an organization to itself.",
"ORG_LINK_WRONG_TYPE": "You can only link to the matching organization type (clinic or lab).",
"ORG_TARGET_NO_SUBSCRIPTION": "The other organization does not have an active subscription.",
"ORG_LINK_EXISTS": "These organizations are already linked.",
"ORG_REQUEST_NOT_PENDING": "Only pending connection requests can be answered.",
"ORG_CANNOT_RESPOND_OWN": "You cannot respond to your own connection request.",
"ORG_INVITE_ALREADY_ACCEPTED": "This invitation has already been accepted.",
"ORG_INVITE_INVALID": "This invitation is no longer valid.",
"ORG_INVITE_REVOKED": "This invitation has been revoked.",
"ORG_INVITE_EXPIRED": "This invitation has expired.",
"ORG_INVITE_TYPE_MISMATCH": "Organization type does not match this invitation.",
"ORG_INVITE_OWNER_HAS_ORG": "This owner already has an organization. Find it in search instead of sending an invitation.",
"ORG_UNKNOWN_TYPE": "Unknown organization type.",
"ORG_COUNTERPART_NOT_LAB": "The connected organization is not a lab.",
"ORG_COUNTERPART_NOT_CLINIC": "The connected organization is not a clinic.",
"ORG_CONNECTION_NOT_FOUND": "Connected organization not found.",
"ORG_INVITE_NOT_FOUND": "Invitation not found.",
"ORG_ONLY_CLINIC_COMMENT": "Only the clinic can comment on this case.",
"CATALOG_UNKNOWN_TREATMENT_TYPE": "Unknown treatment type.",
"CATALOG_UNKNOWN_PROSTHESIS_TYPE": "Unknown prosthesis type.",
"CATALOG_TREATMENT_NOT_LAB_DEPENDENT": "This treatment type is completed in the clinic and cannot be sent to a lab.",
"TREATMENT_SAVE_DETAILS_BEFORE_LAB": "Save treatment details before creating lab cases.",
"TREATMENT_DETAIL_ONE_CASE": "Each treatment detail can belong to only one lab case.",
"TREATMENT_DETAILS_NOT_FOUND": "One or more treatment details were not found.",
"TREATMENT_DEST_NOT_LINKED": "That organization is not an active linked lab.",
"TREATMENT_TEETH_REQUIRED_TO_SHIP": "Select teeth before sending this work to a lab.",
"TREATMENT_TOOTH_UNKNOWN_DETAIL": "A tooth is linked to an unknown treatment detail.",
"TREATMENT_TOOTH_NOT_ON_DETAIL": "A selected tooth is not on this treatment detail.",
"TREATMENT_CASE_NO_DEST": "This lab case has no destination organization.",
"TREATMENT_CASE_NEEDS_DETAIL": "A lab case must include a treatment detail.",
"TREATMENT_CASE_ONE_DETAIL": "A lab case can include only one treatment detail.",
"TREATMENT_ONLY_PROVIDER_SEND": "Only the treatment provider can send this lab case.",
"TREATMENT_CASE_ALREADY_SENT": "This case was already sent to that lab.",
"TREATMENT_DUE_DATE_LOCKED": "The due date cannot be changed after all tasks are completed.",
"TREATMENT_INVALID_DUE_DATE": "The due date is invalid.",
"TREATMENT_DETAIL_KEY_REQUIRED": "A treatment detail is required for this upload.",
"TREATMENT_FILE_REQUIRED": "At least one file is required.",
"TREATMENT_ATTACHMENT_NOT_FOUND": "Attachment not found.",
"TREATMENT_FILE_UNAVAILABLE": "This file is no longer available.",
"TREATMENT_CASE_INVALID_ATTACHMENTS": "One or more attachments are not valid for this lab case.",
"TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE": "Each tooth needs a prosthesis type before sending.",
"LAB_CASE_NOT_FOUND": "Lab case not found.",
"TASK_ASSIGNEE_INVALID": "The selected person cannot be assigned this task.",
"TASK_ASSIGNED_TO_OTHER": "This task is assigned to another staff member.",
"TASK_PAGE_DATE_SORT_ONLY": "Jumping to a task in the list is only available when sorted by date.",
"INVALID_SENT_FROM": "The start date of the range is invalid.",
"INVALID_SENT_TO": "The end date of the range is invalid.",
"CASE_NOT_FOUND": "Case not found.",
"CASE_ATTACHMENT_NOT_FOUND": "Attachment not found.",
"CASE_ATTACHMENT_MISSING_FILE": "The attachment file is missing.",
"CASE_TASK_NOT_FOUND": "Task not found.",
"COMMENT_NOT_FOUND": "Comment not found.",
"COMMENT_VISIBILITY_LAB_ONLY": "Only lab comments can change visibility.",
"TODAY_INVALID_RANGE": "The date range is invalid.",
"TODAY_INVALID_RANGE_ORDER": "The end of the range must be after the start.",
"NOT_FOUND": "The requested item was not found.",
"CONFLICT": "This action conflicts with existing data.",
"CONFLICT_FUTURE_APPOINTMENTS": "You cannot stop participating in treatments while you have future appointments. Reassign or cancel them first.",

View File

@@ -1081,6 +1081,101 @@
"VALIDATION_FIELD_REQUIRED": "لطفاً همه فیلدهای الزامی را پر کنید.",
"VALIDATION_LANGUAGE_INVALID": "زبان پشتیبانی‌شده انتخاب کنید.",
"VALIDATION_INVALID_REQUEST": "درخواست حاوی داده نامعتبر است.",
"VALIDATION_TIMEZONE_INVALID": "منطقه زمانی شما مشخص نشد. صفحه را تازه‌سازی کنید و دوباره تلاش کنید.",
"APPOINTMENT_INVALID_TIME": "زمان شروع یا پایان نوبت نامعتبر است.",
"APPOINTMENT_END_BEFORE_START": "زمان پایان باید بعد از زمان شروع باشد.",
"APPOINTMENT_TOO_LONG": "مدت نوبت نمی‌تواند بیش از ۲۴ ساعت باشد.",
"APPOINTMENT_IN_PAST": "نمی‌توانید نوبت در گذشته ثبت کنید.",
"APPOINTMENT_INVALID_RANGE": "بازه تاریخ نامعتبر است.",
"APPOINTMENT_INVALID_RANGE_ORDER": "پایان بازه باید بعد از شروع باشد.",
"APPOINTMENT_INVALID_DATE": "تاریخ انتخاب‌شده نامعتبر است.",
"APPOINTMENT_PROVIDER_NOT_MEMBER": "این ارائه‌دهنده عضو سازمان نیست.",
"APPOINTMENT_PROVIDER_INACTIVE": "این ارائه‌دهنده عضو فعال پرسنل نیست.",
"APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT": "این ارائه‌دهنده نمی‌تواند نوبت درمان بپذیرد.",
"APPOINTMENT_PROVIDER_NO_WORKING_HOURS": "برای این ارائه‌دهنده ساعات کاری تعریف نشده است.",
"APPOINTMENT_PROVIDER_NOT_WORKING_DAY": "این ارائه‌دهنده در روز انتخاب‌شده کار نمی‌کند.",
"APPOINTMENT_OUTSIDE_WORKING_HOURS": "این زمان خارج از ساعات کاری ارائه‌دهنده است.",
"APPOINTMENT_NOT_FOUND": "نوبت یافت نشد.",
"APPOINTMENT_NOT_PROVIDER": "شما ارائه‌دهنده این نوبت نیستید.",
"PATIENT_NOT_FOUND": "بیمار یافت نشد.",
"WORKING_HOURS_INVALID": "ساعات کاری نامعتبر است. هم‌پوشانی شیفت‌ها را بررسی کنید.",
"WORKING_HOURS_OWNER_NOT_ALLOWED": "ساعات کاری مالک را از تنظیمات حساب تنظیم کنید.",
"WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "این ساعات با نوبت‌های آینده تداخل دارد. ابتدا آن نوبت‌ها را تغییر دهید یا حذف کنید.",
"STAFF_UNKNOWN_PERMISSIONS": "یک یا چند دسترسی انتخاب‌شده نامعتبر است.",
"STAFF_NO_SUBSCRIPTION": "قبل از افزودن پرسنل یک طرح انتخاب کنید.",
"STAFF_SEAT_LIMIT": "در طرح شما صندلی خالی نیست. یک عضو را غیرفعال کنید یا طرح را ارتقا دهید.",
"STAFF_INVITE_OWNER_EMAIL": "مالک سازمان از قبل عضو است.",
"STAFF_ALREADY_MEMBER": "این فرد از قبل عضو است.",
"STAFF_OWNER_NO_INVITE_LINK": "مالک از لینک دعوت استفاده نمی‌کند.",
"STAFF_ALREADY_ACCEPTED": "این عضو دعوتنامه را پذیرفته است.",
"STAFF_INVITE_MISSING": "دعوتنامه‌ای برای این عضو یافت نشد.",
"STAFF_INVITE_ALREADY_ACCEPTED": "این دعوتنامه قبلاً پذیرفته شده است.",
"STAFF_INVITE_INVALID": "این دعوتنامه دیگر معتبر نیست.",
"STAFF_INVITE_REVOKED": "این دعوتنامه لغو شده است.",
"STAFF_INVITE_EXPIRED": "این دعوتنامه منقضی شده است.",
"STAFF_ALREADY_ACTIVE": "این عضو از قبل فعال است.",
"STAFF_ALREADY_DISABLED": "این عضو از قبل غیرفعال یا در انتظار است.",
"STAFF_CANNOT_DISABLE_SELF": "نمی‌توانید دسترسی خود را غیرفعال کنید.",
"STAFF_MEMBER_NOT_FOUND": "عضو پرسنل یافت نشد.",
"STAFF_CANNOT_EDIT_OWNER": "عضویت مالک را نمی‌توان از اینجا ویرایش کرد.",
"STAFF_CANNOT_ENABLE_OWNER": "مالک سازمان را نمی‌توان این‌گونه فعال کرد.",
"STAFF_CANNOT_DISABLE_OWNER": "مالک سازمان را نمی‌توان غیرفعال کرد.",
"STAFF_CANNOT_REMOVE_OWNER": "مالک سازمان را نمی‌توان حذف کرد.",
"ORG_CANNOT_LINK_SELF": "نمی‌توانید سازمان را به خودش متصل کنید.",
"ORG_LINK_WRONG_TYPE": "فقط می‌توانید به نوع سازمان متناظر (کلینیک یا لابراتوار) متصل شوید.",
"ORG_TARGET_NO_SUBSCRIPTION": "سازمان مقابل اشتراک فعال ندارد.",
"ORG_LINK_EXISTS": "این سازمان‌ها از قبل متصل هستند.",
"ORG_REQUEST_NOT_PENDING": "فقط درخواست‌های در انتظار را می‌توان پاسخ داد.",
"ORG_CANNOT_RESPOND_OWN": "نمی‌توانید به درخواست اتصال خودتان پاسخ دهید.",
"ORG_INVITE_ALREADY_ACCEPTED": "این دعوتنامه قبلاً پذیرفته شده است.",
"ORG_INVITE_INVALID": "این دعوتنامه دیگر معتبر نیست.",
"ORG_INVITE_REVOKED": "این دعوتنامه لغو شده است.",
"ORG_INVITE_EXPIRED": "این دعوتنامه منقضی شده است.",
"ORG_INVITE_TYPE_MISMATCH": "نوع سازمان با این دعوتنامه مطابقت ندارد.",
"ORG_INVITE_OWNER_HAS_ORG": "این مالک از قبل سازمان دارد. به‌جای ارسال دعوتنامه آن را در جستجو پیدا کنید.",
"ORG_UNKNOWN_TYPE": "نوع سازمان ناشناخته است.",
"ORG_COUNTERPART_NOT_LAB": "سازمان متصل‌شده لابراتوار نیست.",
"ORG_COUNTERPART_NOT_CLINIC": "سازمان متصل‌شده کلینیک نیست.",
"ORG_CONNECTION_NOT_FOUND": "سازمان متصل یافت نشد.",
"ORG_INVITE_NOT_FOUND": "دعوتنامه یافت نشد.",
"ORG_ONLY_CLINIC_COMMENT": "فقط کلینیک می‌تواند روی این کیس نظر بگذارد.",
"CATALOG_UNKNOWN_TREATMENT_TYPE": "نوع درمان ناشناخته است.",
"CATALOG_UNKNOWN_PROSTHESIS_TYPE": "نوع پروتز ناشناخته است.",
"CATALOG_TREATMENT_NOT_LAB_DEPENDENT": "این نوع درمان در کلینیک تکمیل می‌شود و قابل ارسال به لابراتوار نیست.",
"TREATMENT_SAVE_DETAILS_BEFORE_LAB": "قبل از ایجاد کیس لابراتوار، جزئیات درمان را ذخیره کنید.",
"TREATMENT_DETAIL_ONE_CASE": "هر جزء درمان فقط می‌تواند به یک کیس لابراتوار تعلق داشته باشد.",
"TREATMENT_DETAILS_NOT_FOUND": "یک یا چند جزء درمان یافت نشد.",
"TREATMENT_DEST_NOT_LINKED": "آن سازمان لابراتوار متصل فعال نیست.",
"TREATMENT_TEETH_REQUIRED_TO_SHIP": "قبل از ارسال به لابراتوار دندان‌ها را انتخاب کنید.",
"TREATMENT_TOOTH_UNKNOWN_DETAIL": "یک دندان به جزء درمان ناشناخته لینک شده است.",
"TREATMENT_TOOTH_NOT_ON_DETAIL": "دندان انتخاب‌شده روی این جزء درمان نیست.",
"TREATMENT_CASE_NO_DEST": "این کیس لابراتوار مقصد ندارد.",
"TREATMENT_CASE_NEEDS_DETAIL": "کیس لابراتوار باید شامل یک جزء درمان باشد.",
"TREATMENT_CASE_ONE_DETAIL": "کیس لابراتوار فقط می‌تواند یک جزء درمان داشته باشد.",
"TREATMENT_ONLY_PROVIDER_SEND": "فقط ارائه‌دهنده درمان می‌تواند این کیس را ارسال کند.",
"TREATMENT_CASE_ALREADY_SENT": "این کیس قبلاً به آن لابراتوار ارسال شده است.",
"TREATMENT_DUE_DATE_LOCKED": "پس از تکمیل همه کارها نمی‌توان موعد را تغییر داد.",
"TREATMENT_INVALID_DUE_DATE": "موعد نامعتبر است.",
"TREATMENT_DETAIL_KEY_REQUIRED": "برای این بارگذاری یک جزء درمان لازم است.",
"TREATMENT_FILE_REQUIRED": "حداقل یک فایل لازم است.",
"TREATMENT_ATTACHMENT_NOT_FOUND": "پیوست یافت نشد.",
"TREATMENT_FILE_UNAVAILABLE": "این فایل دیگر در دسترس نیست.",
"TREATMENT_CASE_INVALID_ATTACHMENTS": "یک یا چند پیوست برای این کیس معتبر نیست.",
"TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE": "قبل از ارسال، هر دندان باید نوع پروتز داشته باشد.",
"LAB_CASE_NOT_FOUND": "کیس لابراتوار یافت نشد.",
"TASK_ASSIGNEE_INVALID": "فرد انتخاب‌شده را نمی‌توان به این کار تخصیص داد.",
"TASK_ASSIGNED_TO_OTHER": "این کار به عضو دیگری تخصیص داده شده است.",
"TASK_PAGE_DATE_SORT_ONLY": "پرش به کار در فهرست فقط وقتی مرتب‌سازی بر اساس تاریخ است ممکن است.",
"INVALID_SENT_FROM": "تاریخ شروع بازه نامعتبر است.",
"INVALID_SENT_TO": "تاریخ پایان بازه نامعتبر است.",
"CASE_NOT_FOUND": "کیس یافت نشد.",
"CASE_ATTACHMENT_NOT_FOUND": "پیوست یافت نشد.",
"CASE_ATTACHMENT_MISSING_FILE": "فایل پیوست موجود نیست.",
"CASE_TASK_NOT_FOUND": "کار یافت نشد.",
"COMMENT_NOT_FOUND": "نظر یافت نشد.",
"COMMENT_VISIBILITY_LAB_ONLY": "فقط نظرات لابراتوار می‌توانند وضعیت نمایش را تغییر دهند.",
"TODAY_INVALID_RANGE": "بازه تاریخ نامعتبر است.",
"TODAY_INVALID_RANGE_ORDER": "پایان بازه باید بعد از شروع باشد.",
"NOT_FOUND": "مورد درخواستی یافت نشد.",
"CONFLICT": "این عمل با داده‌های موجود در تضاد است.",
"CONFLICT_FUTURE_APPOINTMENTS": "تا وقتی نوبت‌های آینده دارید نمی‌توانید مشارکت در درمان را متوقف کنید. ابتدا آن‌ها را لغو یا واگذار کنید.",

View File

@@ -1080,6 +1080,101 @@
"VALIDATION_FIELD_REQUIRED": "Vul alle verplichte velden in.",
"VALIDATION_LANGUAGE_INVALID": "Selecteer een ondersteunde taal.",
"VALIDATION_INVALID_REQUEST": "Het verzoek bevat ongeldige gegevens.",
"VALIDATION_TIMEZONE_INVALID": "Uw tijdzone kon niet worden bepaald. Vernieuw de pagina en probeer opnieuw.",
"APPOINTMENT_INVALID_TIME": "De start- of eindtijd van de afspraak is ongeldig.",
"APPOINTMENT_END_BEFORE_START": "De eindtijd moet na de starttijd liggen.",
"APPOINTMENT_TOO_LONG": "Een afspraak mag niet langer dan 24 uur duren.",
"APPOINTMENT_IN_PAST": "U kunt geen afspraak in het verleden boeken.",
"APPOINTMENT_INVALID_RANGE": "Het datumbereik is ongeldig.",
"APPOINTMENT_INVALID_RANGE_ORDER": "Het einde van het bereik moet na het begin liggen.",
"APPOINTMENT_INVALID_DATE": "De geselecteerde datum is ongeldig.",
"APPOINTMENT_PROVIDER_NOT_MEMBER": "Deze zorgverlener is geen lid van de organisatie.",
"APPOINTMENT_PROVIDER_INACTIVE": "Deze zorgverlener is geen actief teamlid.",
"APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT": "Deze zorgverlener kan geen behandelafspraken aannemen.",
"APPOINTMENT_PROVIDER_NO_WORKING_HOURS": "Voor deze zorgverlener zijn geen werktijden ingesteld.",
"APPOINTMENT_PROVIDER_NOT_WORKING_DAY": "Deze zorgverlener werkt niet op de geselecteerde dag.",
"APPOINTMENT_OUTSIDE_WORKING_HOURS": "Dit tijdstip valt buiten de werktijden van de zorgverlener.",
"APPOINTMENT_NOT_FOUND": "Afspraak niet gevonden.",
"APPOINTMENT_NOT_PROVIDER": "U bent niet de zorgverlener van deze afspraak.",
"PATIENT_NOT_FOUND": "Patiënt niet gevonden.",
"WORKING_HOURS_INVALID": "De werktijden zijn ongeldig. Controleer of diensten niet overlappen.",
"WORKING_HOURS_OWNER_NOT_ALLOWED": "Stel werktijden van de eigenaar in via accountinstellingen.",
"WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "Deze tijden conflicteren met aankomende afspraken. Plan die eerst om of verwijder ze.",
"STAFF_UNKNOWN_PERMISSIONS": "Een of meer geselecteerde rechten zijn ongeldig.",
"STAFF_NO_SUBSCRIPTION": "Kies een abonnement voordat u personeel toevoegt.",
"STAFF_SEAT_LIMIT": "Uw abonnement heeft geen vrije plaatsen. Schakel een lid uit of upgrade.",
"STAFF_INVITE_OWNER_EMAIL": "De eigenaar van de organisatie is al lid.",
"STAFF_ALREADY_MEMBER": "Deze persoon is al lid.",
"STAFF_OWNER_NO_INVITE_LINK": "De eigenaar gebruikt geen uitnodigingslink.",
"STAFF_ALREADY_ACCEPTED": "Dit lid heeft de uitnodiging al geaccepteerd.",
"STAFF_INVITE_MISSING": "Er is geen uitnodiging voor dit lid gevonden.",
"STAFF_INVITE_ALREADY_ACCEPTED": "Deze uitnodiging is al geaccepteerd.",
"STAFF_INVITE_INVALID": "Deze uitnodiging is niet meer geldig.",
"STAFF_INVITE_REVOKED": "Deze uitnodiging is ingetrokken.",
"STAFF_INVITE_EXPIRED": "Deze uitnodiging is verlopen.",
"STAFF_ALREADY_ACTIVE": "Dit lid is al actief.",
"STAFF_ALREADY_DISABLED": "Dit lid is al uitgeschakeld of in afwachting.",
"STAFF_CANNOT_DISABLE_SELF": "U kunt uw eigen toegang niet uitschakelen.",
"STAFF_MEMBER_NOT_FOUND": "Teamlid niet gevonden.",
"STAFF_CANNOT_EDIT_OWNER": "Het lidmaatschap van de eigenaar kan hier niet worden bewerkt.",
"STAFF_CANNOT_ENABLE_OWNER": "De eigenaar kan op deze manier niet worden ingeschakeld.",
"STAFF_CANNOT_DISABLE_OWNER": "De eigenaar kan niet worden uitgeschakeld.",
"STAFF_CANNOT_REMOVE_OWNER": "De eigenaar kan niet worden verwijderd.",
"ORG_CANNOT_LINK_SELF": "U kunt een organisatie niet aan zichzelf koppelen.",
"ORG_LINK_WRONG_TYPE": "U kunt alleen koppelen aan het bijbehorende type (kliniek of lab).",
"ORG_TARGET_NO_SUBSCRIPTION": "De andere organisatie heeft geen actief abonnement.",
"ORG_LINK_EXISTS": "Deze organisaties zijn al gekoppeld.",
"ORG_REQUEST_NOT_PENDING": "Alleen openstaande verbindingsverzoeken kunnen worden beantwoord.",
"ORG_CANNOT_RESPOND_OWN": "U kunt niet op uw eigen verbindingsverzoek reageren.",
"ORG_INVITE_ALREADY_ACCEPTED": "Deze uitnodiging is al geaccepteerd.",
"ORG_INVITE_INVALID": "Deze uitnodiging is niet meer geldig.",
"ORG_INVITE_REVOKED": "Deze uitnodiging is ingetrokken.",
"ORG_INVITE_EXPIRED": "Deze uitnodiging is verlopen.",
"ORG_INVITE_TYPE_MISMATCH": "Het organisatietype komt niet overeen met deze uitnodiging.",
"ORG_INVITE_OWNER_HAS_ORG": "Deze eigenaar heeft al een organisatie. Zoek die in plaats van een uitnodiging te sturen.",
"ORG_UNKNOWN_TYPE": "Onbekend organisatietype.",
"ORG_COUNTERPART_NOT_LAB": "De gekoppelde organisatie is geen lab.",
"ORG_COUNTERPART_NOT_CLINIC": "De gekoppelde organisatie is geen kliniek.",
"ORG_CONNECTION_NOT_FOUND": "Gekoppelde organisatie niet gevonden.",
"ORG_INVITE_NOT_FOUND": "Uitnodiging niet gevonden.",
"ORG_ONLY_CLINIC_COMMENT": "Alleen de kliniek kan reageren op deze case.",
"CATALOG_UNKNOWN_TREATMENT_TYPE": "Onbekend behandelingstype.",
"CATALOG_UNKNOWN_PROSTHESIS_TYPE": "Onbekend prothesetype.",
"CATALOG_TREATMENT_NOT_LAB_DEPENDENT": "Dit behandelingstype wordt in de kliniek afgerond en kan niet naar een lab worden gestuurd.",
"TREATMENT_SAVE_DETAILS_BEFORE_LAB": "Sla behandeldetails op voordat u labcases aanmaakt.",
"TREATMENT_DETAIL_ONE_CASE": "Elk behandeldetail kan tot slechts één labcase behoren.",
"TREATMENT_DETAILS_NOT_FOUND": "Een of meer behandeldetails zijn niet gevonden.",
"TREATMENT_DEST_NOT_LINKED": "Die organisatie is geen actief gekoppeld lab.",
"TREATMENT_TEETH_REQUIRED_TO_SHIP": "Selecteer tanden voordat u dit werk naar een lab stuurt.",
"TREATMENT_TOOTH_UNKNOWN_DETAIL": "Een tand is gekoppeld aan een onbekend behandeldetail.",
"TREATMENT_TOOTH_NOT_ON_DETAIL": "Een geselecteerde tand staat niet op dit behandeldetail.",
"TREATMENT_CASE_NO_DEST": "Deze labcase heeft geen bestemmingsorganisatie.",
"TREATMENT_CASE_NEEDS_DETAIL": "Een labcase moet een behandeldetail bevatten.",
"TREATMENT_CASE_ONE_DETAIL": "Een labcase mag slechts één behandeldetail bevatten.",
"TREATMENT_ONLY_PROVIDER_SEND": "Alleen de behandelaar kan deze labcase versturen.",
"TREATMENT_CASE_ALREADY_SENT": "Deze case is al naar dat lab gestuurd.",
"TREATMENT_DUE_DATE_LOCKED": "De deadline kan niet worden gewijzigd nadat alle taken zijn voltooid.",
"TREATMENT_INVALID_DUE_DATE": "De deadline is ongeldig.",
"TREATMENT_DETAIL_KEY_REQUIRED": "Voor deze upload is een behandeldetail vereist.",
"TREATMENT_FILE_REQUIRED": "Er is minstens één bestand vereist.",
"TREATMENT_ATTACHMENT_NOT_FOUND": "Bijlage niet gevonden.",
"TREATMENT_FILE_UNAVAILABLE": "Dit bestand is niet meer beschikbaar.",
"TREATMENT_CASE_INVALID_ATTACHMENTS": "Een of meer bijlagen zijn ongeldig voor deze labcase.",
"TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE": "Elke tand heeft een prothesetype nodig voordat u verstuurt.",
"LAB_CASE_NOT_FOUND": "Labcase niet gevonden.",
"TASK_ASSIGNEE_INVALID": "De geselecteerde persoon kan deze taak niet toegewezen krijgen.",
"TASK_ASSIGNED_TO_OTHER": "Deze taak is aan een ander teamlid toegewezen.",
"TASK_PAGE_DATE_SORT_ONLY": "Naar een taak springen kan alleen bij sorteren op datum.",
"INVALID_SENT_FROM": "De startdatum van het bereik is ongeldig.",
"INVALID_SENT_TO": "De einddatum van het bereik is ongeldig.",
"CASE_NOT_FOUND": "Case niet gevonden.",
"CASE_ATTACHMENT_NOT_FOUND": "Bijlage niet gevonden.",
"CASE_ATTACHMENT_MISSING_FILE": "Het bijlagebestand ontbreekt.",
"CASE_TASK_NOT_FOUND": "Taak niet gevonden.",
"COMMENT_NOT_FOUND": "Reactie niet gevonden.",
"COMMENT_VISIBILITY_LAB_ONLY": "Alleen labreacties kunnen de zichtbaarheid wijzigen.",
"TODAY_INVALID_RANGE": "Het datumbereik is ongeldig.",
"TODAY_INVALID_RANGE_ORDER": "Het einde van het bereik moet na het begin liggen.",
"NOT_FOUND": "Het gevraagde item is niet gevonden.",
"CONFLICT": "Deze actie conflicteert met bestaande gegevens.",
"CONFLICT_FUTURE_APPOINTMENTS": "U kunt niet stoppen met deelnemen aan behandelingen zolang u toekomstige afspraken hebt. Wijs ze eerst opnieuw toe of annuleer ze.",

View File

@@ -20,6 +20,7 @@ import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesi
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
import { getUserFacingError } from '@/components/shared/formatApiError';
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
interface LabCasesDispatchPanelProps {
@@ -124,6 +125,7 @@ export function LabCasesDispatchPanel({
onCommentError,
}: LabCasesDispatchPanelProps) {
const t = useTranslations('treatment');
const tErrors = useTranslations('errors');
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
const [pendingComment, setPendingComment] = useState('');
@@ -273,7 +275,7 @@ export function LabCasesDispatchPanel({
taskProgress: response.data.taskProgress ?? activeLabCase.taskProgress,
});
} catch (error) {
onCommentError?.(error instanceof Error ? error.message : t('dueDateUpdateError'));
onCommentError?.(getUserFacingError(error, tErrors, t('dueDateUpdateError')));
}
}

View File

@@ -1,4 +1,5 @@
import { apiClient } from './client';
import { getClientTimeZone } from '@/lib/i18n/clientTimeZone';
export type WorkingHoursPayload = {
autoRepeatWeekly: boolean;
@@ -42,7 +43,10 @@ export const accountApi = {
upsertMyWorkingHours: async (
payload: WorkingHoursPayload,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.put('/auth/profile/working-hours', payload);
const response = await apiClient.put('/auth/profile/working-hours', {
...payload,
timeZone: getClientTimeZone(),
});
return response.data;
},
};

View File

@@ -1,5 +1,6 @@
import { apiClient } from './client';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import { getClientTimeZone } from '@/lib/i18n/clientTimeZone';
import { toDateInputValue } from '@/components/appointments/appointmentTime';
export interface CreateAppointmentBody {
@@ -27,7 +28,10 @@ export const appointmentsApi = {
},
create: async (body: CreateAppointmentBody): Promise<{ success: boolean; data: AppointmentRecord }> => {
const response = await apiClient.post('/appointments', body);
const response = await apiClient.post('/appointments', {
...body,
timeZone: getClientTimeZone(),
});
return response.data;
},
@@ -35,7 +39,10 @@ export const appointmentsApi = {
id: string,
body: UpdateAppointmentBody,
): Promise<{ success: boolean; data: AppointmentRecord }> => {
const response = await apiClient.patch(`/appointments/${id}`, body);
const response = await apiClient.patch(`/appointments/${id}`, {
...body,
timeZone: getClientTimeZone(),
});
return response.data;
},

View File

@@ -1,4 +1,5 @@
import { apiClient } from './client';
import { getClientTimeZone } from '@/lib/i18n/clientTimeZone';
export interface StaffMemberDto {
id: string;
@@ -142,7 +143,10 @@ export const staffApi = {
blocks: { dayOfWeek: number; startMinute: number; endMinute: number; sortOrder?: number }[];
},
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.put(`/staff/members/${membershipId}/working-hours`, body);
const response = await apiClient.put(`/staff/members/${membershipId}/working-hours`, {
...body,
timeZone: getClientTimeZone(),
});
return response.data;
},
};

View File

@@ -0,0 +1,8 @@
/** Browser IANA zone (e.g. Asia/Tehran). Used so the UTC server can interpret wall-clock hours. */
export function getClientTimeZone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch {
return 'UTC';
}
}