diff --git a/.cursor/rules/api-errors-i18n.mdc b/.cursor/rules/api-errors-i18n.mdc index 713e1f6..f5894ce 100644 --- a/.cursor/rules/api-errors-i18n.mdc +++ b/.cursor/rules/api-errors-i18n.mdc @@ -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. diff --git a/.cursor/rules/appointments.mdc b/.cursor/rules/appointments.mdc index ee9abdf..34ed6d0 100644 --- a/.cursor/rules/appointments.mdc +++ b/.cursor/rules/appointments.mdc @@ -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. diff --git a/.cursor/rules/dyolink-overview.mdc b/.cursor/rules/dyolink-overview.mdc index c484b78..bb11234 100644 --- a/.cursor/rules/dyolink-overview.mdc +++ b/.cursor/rules/dyolink-overview.mdc @@ -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`. diff --git a/.cursor/skills/api-errors/SKILL.md b/.cursor/skills/api-errors/SKILL.md index 2e95e6d..bb9dfce 100644 --- a/.cursor/skills/api-errors/SKILL.md +++ b/.cursor/skills/api-errors/SKILL.md @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 0b93f86..9119124 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 231de83..9eb88ad 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -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; diff --git a/backend/src/common/errors/http-exception.filter.ts b/backend/src/common/errors/http-exception.filter.ts index cc9d3d7..a522e73 100644 --- a/backend/src/common/errors/http-exception.filter.ts +++ b/backend/src/common/errors/http-exception.filter.ts @@ -28,58 +28,6 @@ const STATUS_FALLBACK_CODES: Partial> = { [HttpStatus.INTERNAL_SERVER_ERROR]: ErrorCode.INTERNAL_ERROR, }; -/** Maps legacy English messages to stable codes during migration. */ -const LEGACY_MESSAGE_CODES: Record = { - '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; } diff --git a/backend/src/common/errors/validation-exception.factory.ts b/backend/src/common/errors/validation-exception.factory.ts index 4d85008..3d032db 100644 --- a/backend/src/common/errors/validation-exception.factory.ts +++ b/backend/src/common/errors/validation-exception.factory.ts @@ -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; -} diff --git a/backend/src/common/working-hours.ts b/backend/src/common/working-hours.ts index 0cbdf60..1c5b5af 100644 --- a/backend/src/common/working-hours.ts +++ b/backend/src/common/working-hours.ts @@ -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); } diff --git a/backend/src/common/zoned-civil-time.spec.ts b/backend/src/common/zoned-civil-time.spec.ts new file mode 100644 index 0000000..99279ab --- /dev/null +++ b/backend/src/common/zoned-civil-time.spec.ts @@ -0,0 +1,29 @@ +import { appointmentWithinWorkingHours } from './working-hours'; +import { isValidIanaTimeZone, zonedWeekdayAndMinutes } from './zoned-civil-time'; + +describe('zoned civil time', () => { + it('accepts IANA zones and rejects garbage', () => { + expect(isValidIanaTimeZone('Asia/Tehran')).toBe(true); + expect(isValidIanaTimeZone('Europe/Amsterdam')).toBe(true); + expect(isValidIanaTimeZone('Not/AZone')).toBe(false); + }); + + it('maps a UTC instant to Tehran wall-clock (no DST)', () => { + // 2026-08-20 09:45 Asia/Tehran = 06:15 UTC (the production 400 case) + const instant = new Date('2026-08-20T06:15:00.000Z'); + const zoned = zonedWeekdayAndMinutes(instant, 'Asia/Tehran'); + expect(zoned.jsWeekday).toBe(4); // Thursday + expect(zoned.minuteOfDay).toBe(9 * 60 + 45); + }); + + it('treats morning Tehran slots as inside 08:00–17:00 hours', () => { + const start = new Date('2026-08-20T06:15:00.000Z'); + const end = new Date('2026-08-20T06:45:00.000Z'); + expect( + appointmentWithinWorkingHours(start, end, [{ startMinute: 8 * 60, endMinute: 17 * 60 }], 'Asia/Tehran'), + ).toBe(true); + expect( + appointmentWithinWorkingHours(start, end, [{ startMinute: 8 * 60, endMinute: 17 * 60 }], 'UTC'), + ).toBe(false); + }); +}); diff --git a/backend/src/common/zoned-civil-time.ts b/backend/src/common/zoned-civil-time.ts new file mode 100644 index 0000000..88679ef --- /dev/null +++ b/backend/src/common/zoned-civil-time.ts @@ -0,0 +1,55 @@ +/** Convert an absolute instant into weekday + minute-of-day in an IANA time zone. */ + +const JS_WEEKDAY: Record = { + 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(); +} diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index 0a4755f..6c6ff56 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -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); } } diff --git a/backend/src/modules/appointments/dto/column-providers-query.dto.ts b/backend/src/modules/appointments/dto/column-providers-query.dto.ts index 04ccc58..0e1607a 100644 --- a/backend/src/modules/appointments/dto/column-providers-query.dto.ts +++ b/backend/src/modules/appointments/dto/column-providers-query.dto.ts @@ -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; } diff --git a/backend/src/modules/appointments/dto/create-appointment.dto.ts b/backend/src/modules/appointments/dto/create-appointment.dto.ts index fb34434..3ca10af 100644 --- a/backend/src/modules/appointments/dto/create-appointment.dto.ts +++ b/backend/src/modules/appointments/dto/create-appointment.dto.ts @@ -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; } diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index 94976fc..63074dd 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -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) { diff --git a/backend/src/modules/cases/lab-case-access.service.ts b/backend/src/modules/cases/lab-case-access.service.ts index 50c18d1..6a8f1aa 100644 --- a/backend/src/modules/cases/lab-case-access.service.ts +++ b/backend/src/modules/cases/lab-case-access.service.ts @@ -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; diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts index 794f8e5..ee22a07 100644 --- a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts +++ b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts @@ -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 { diff --git a/backend/src/modules/notifications/lab-case-activity.service.ts b/backend/src/modules/notifications/lab-case-activity.service.ts index d19bd94..cdd6411 100644 --- a/backend/src/modules/notifications/lab-case-activity.service.ts +++ b/backend/src/modules/notifications/lab-case-activity.service.ts @@ -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); } } } diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts index c4dcd59..1d6de8a 100644 --- a/backend/src/modules/organization/organization.service.ts +++ b/backend/src/modules/organization/organization.service.ts @@ -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; } diff --git a/backend/src/modules/patients/patients.service.ts b/backend/src/modules/patients/patients.service.ts index a573acd..562e910 100644 --- a/backend/src/modules/patients/patients.service.ts +++ b/backend/src/modules/patients/patients.service.ts @@ -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); } } } diff --git a/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts index 7a0f0d2..f042165 100644 --- a/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts +++ b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts @@ -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); } } diff --git a/backend/src/modules/staff/dto/upsert-working-hours.dto.ts b/backend/src/modules/staff/dto/upsert-working-hours.dto.ts index 68c508a..1c85f30 100644 --- a/backend/src/modules/staff/dto/upsert-working-hours.dto.ts +++ b/backend/src/modules/staff/dto/upsert-working-hours.dto.ts @@ -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; } diff --git a/backend/src/modules/staff/staff-working-hours.service.ts b/backend/src/modules/staff/staff-working-hours.service.ts index 38284ff..7be5f13 100644 --- a/backend/src/modules/staff/staff-working-hours.service.ts +++ b/backend/src/modules/staff/staff-working-hours.service.ts @@ -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); } } diff --git a/backend/src/modules/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts index f7bb2e4..09e4399 100644 --- a/backend/src/modules/staff/staff.service.ts +++ b/backend/src/modules/staff/staff.service.ts @@ -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; } diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index 9a84300..994cbe7 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -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) { diff --git a/backend/src/modules/today/today.service.ts b/backend/src/modules/today/today.service.ts index 1410061..29e5e12 100644 --- a/backend/src/modules/today/today.service.ts +++ b/backend/src/modules/today/today.service.ts @@ -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); } } diff --git a/backend/src/modules/treatment-catalog/treatment-catalog.service.ts b/backend/src/modules/treatment-catalog/treatment-catalog.service.ts index 418bfb4..2b8a9e7 100644 --- a/backend/src/modules/treatment-catalog/treatment-catalog.service.ts +++ b/backend/src/modules/treatment-catalog/treatment-catalog.service.ts @@ -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; } diff --git a/backend/src/modules/treatments/lab-case-send.validation.spec.ts b/backend/src/modules/treatments/lab-case-send.validation.spec.ts index 3bab06e..3916269 100644 --- a/backend/src/modules/treatments/lab-case-send.validation.spec.ts +++ b/backend/src/modules/treatments/lab-case-send.validation.spec.ts @@ -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); }); }); diff --git a/backend/src/modules/treatments/lab-case-send.validation.ts b/backend/src/modules/treatments/lab-case-send.validation.ts index 4a28382..c923713 100644 --- a/backend/src/modules/treatments/lab-case-send.validation.ts +++ b/backend/src/modules/treatments/lab-case-send.validation.ts @@ -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); } } } diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index 4572ea4..47b30c8 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -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) { diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 26ab69e..3b34cec 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -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 provider’s 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 owner’s 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.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index c4cb5fa..1c8f582 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -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": "تا وقتی نوبت‌های آینده دارید نمی‌توانید مشارکت در درمان را متوقف کنید. ابتدا آن‌ها را لغو یا واگذار کنید.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index d58539e..4dbc56b 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -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.", diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx index 7ebd9e8..38e26f7 100644 --- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx +++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx @@ -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([]); 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'))); } } diff --git a/frontend/src/lib/api/account.ts b/frontend/src/lib/api/account.ts index 93c82ef..b60afcb 100644 --- a/frontend/src/lib/api/account.ts +++ b/frontend/src/lib/api/account.ts @@ -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; }, }; diff --git a/frontend/src/lib/api/appointments.ts b/frontend/src/lib/api/appointments.ts index d727b2e..d312c52 100644 --- a/frontend/src/lib/api/appointments.ts +++ b/frontend/src/lib/api/appointments.ts @@ -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; }, diff --git a/frontend/src/lib/api/staff.ts b/frontend/src/lib/api/staff.ts index 68772b2..760169c 100644 --- a/frontend/src/lib/api/staff.ts +++ b/frontend/src/lib/api/staff.ts @@ -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; }, }; diff --git a/frontend/src/lib/i18n/clientTimeZone.ts b/frontend/src/lib/i18n/clientTimeZone.ts new file mode 100644 index 0000000..bc7a475 --- /dev/null +++ b/frontend/src/lib/i18n/clientTimeZone.ts @@ -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'; + } +}