bugfix: appointment hours now use the client timezone on UTC servers.
Logical API errors throw stable codes so users see translated messages instead of a generic bad request. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -57,14 +57,121 @@ export const ErrorCode = {
|
||||
VALIDATION_FIELD_REQUIRED: 'VALIDATION_FIELD_REQUIRED',
|
||||
VALIDATION_LANGUAGE_INVALID: 'VALIDATION_LANGUAGE_INVALID',
|
||||
VALIDATION_INVALID_REQUEST: 'VALIDATION_INVALID_REQUEST',
|
||||
VALIDATION_TIMEZONE_INVALID: 'VALIDATION_TIMEZONE_INVALID',
|
||||
|
||||
// Appointments
|
||||
APPOINTMENT_INVALID_TIME: 'APPOINTMENT_INVALID_TIME',
|
||||
APPOINTMENT_END_BEFORE_START: 'APPOINTMENT_END_BEFORE_START',
|
||||
APPOINTMENT_TOO_LONG: 'APPOINTMENT_TOO_LONG',
|
||||
APPOINTMENT_IN_PAST: 'APPOINTMENT_IN_PAST',
|
||||
APPOINTMENT_INVALID_RANGE: 'APPOINTMENT_INVALID_RANGE',
|
||||
APPOINTMENT_INVALID_RANGE_ORDER: 'APPOINTMENT_INVALID_RANGE_ORDER',
|
||||
APPOINTMENT_INVALID_DATE: 'APPOINTMENT_INVALID_DATE',
|
||||
APPOINTMENT_PROVIDER_NOT_MEMBER: 'APPOINTMENT_PROVIDER_NOT_MEMBER',
|
||||
APPOINTMENT_PROVIDER_INACTIVE: 'APPOINTMENT_PROVIDER_INACTIVE',
|
||||
APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT: 'APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT',
|
||||
APPOINTMENT_PROVIDER_NO_WORKING_HOURS: 'APPOINTMENT_PROVIDER_NO_WORKING_HOURS',
|
||||
APPOINTMENT_PROVIDER_NOT_WORKING_DAY: 'APPOINTMENT_PROVIDER_NOT_WORKING_DAY',
|
||||
APPOINTMENT_OUTSIDE_WORKING_HOURS: 'APPOINTMENT_OUTSIDE_WORKING_HOURS',
|
||||
APPOINTMENT_NOT_FOUND: 'APPOINTMENT_NOT_FOUND',
|
||||
APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED',
|
||||
APPOINTMENT_HAS_TREATMENT: 'APPOINTMENT_HAS_TREATMENT',
|
||||
APPOINTMENT_NOT_PROVIDER: 'APPOINTMENT_NOT_PROVIDER',
|
||||
|
||||
PATIENT_NOT_FOUND: 'PATIENT_NOT_FOUND',
|
||||
|
||||
WORKING_HOURS_INVALID: 'WORKING_HOURS_INVALID',
|
||||
WORKING_HOURS_OWNER_NOT_ALLOWED: 'WORKING_HOURS_OWNER_NOT_ALLOWED',
|
||||
WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS: 'WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS',
|
||||
|
||||
STAFF_UNKNOWN_PERMISSIONS: 'STAFF_UNKNOWN_PERMISSIONS',
|
||||
STAFF_NO_SUBSCRIPTION: 'STAFF_NO_SUBSCRIPTION',
|
||||
STAFF_SEAT_LIMIT: 'STAFF_SEAT_LIMIT',
|
||||
STAFF_INVITE_OWNER_EMAIL: 'STAFF_INVITE_OWNER_EMAIL',
|
||||
STAFF_ALREADY_MEMBER: 'STAFF_ALREADY_MEMBER',
|
||||
STAFF_OWNER_NO_INVITE_LINK: 'STAFF_OWNER_NO_INVITE_LINK',
|
||||
STAFF_ALREADY_ACCEPTED: 'STAFF_ALREADY_ACCEPTED',
|
||||
STAFF_INVITE_MISSING: 'STAFF_INVITE_MISSING',
|
||||
STAFF_INVITE_ALREADY_ACCEPTED: 'STAFF_INVITE_ALREADY_ACCEPTED',
|
||||
STAFF_INVITE_INVALID: 'STAFF_INVITE_INVALID',
|
||||
STAFF_INVITE_REVOKED: 'STAFF_INVITE_REVOKED',
|
||||
STAFF_INVITE_EXPIRED: 'STAFF_INVITE_EXPIRED',
|
||||
STAFF_ALREADY_ACTIVE: 'STAFF_ALREADY_ACTIVE',
|
||||
STAFF_ALREADY_DISABLED: 'STAFF_ALREADY_DISABLED',
|
||||
STAFF_CANNOT_DISABLE_SELF: 'STAFF_CANNOT_DISABLE_SELF',
|
||||
STAFF_MEMBER_NOT_FOUND: 'STAFF_MEMBER_NOT_FOUND',
|
||||
STAFF_CANNOT_EDIT_OWNER: 'STAFF_CANNOT_EDIT_OWNER',
|
||||
STAFF_CANNOT_ENABLE_OWNER: 'STAFF_CANNOT_ENABLE_OWNER',
|
||||
STAFF_CANNOT_DISABLE_OWNER: 'STAFF_CANNOT_DISABLE_OWNER',
|
||||
STAFF_CANNOT_REMOVE_OWNER: 'STAFF_CANNOT_REMOVE_OWNER',
|
||||
|
||||
ORG_CANNOT_LINK_SELF: 'ORG_CANNOT_LINK_SELF',
|
||||
ORG_LINK_WRONG_TYPE: 'ORG_LINK_WRONG_TYPE',
|
||||
ORG_TARGET_NO_SUBSCRIPTION: 'ORG_TARGET_NO_SUBSCRIPTION',
|
||||
ORG_LINK_EXISTS: 'ORG_LINK_EXISTS',
|
||||
ORG_REQUEST_NOT_PENDING: 'ORG_REQUEST_NOT_PENDING',
|
||||
ORG_CANNOT_RESPOND_OWN: 'ORG_CANNOT_RESPOND_OWN',
|
||||
ORG_INVITE_ALREADY_ACCEPTED: 'ORG_INVITE_ALREADY_ACCEPTED',
|
||||
ORG_INVITE_INVALID: 'ORG_INVITE_INVALID',
|
||||
ORG_INVITE_REVOKED: 'ORG_INVITE_REVOKED',
|
||||
ORG_INVITE_EXPIRED: 'ORG_INVITE_EXPIRED',
|
||||
ORG_INVITE_TYPE_MISMATCH: 'ORG_INVITE_TYPE_MISMATCH',
|
||||
ORG_INVITE_OWNER_HAS_ORG: 'ORG_INVITE_OWNER_HAS_ORG',
|
||||
ORG_UNKNOWN_TYPE: 'ORG_UNKNOWN_TYPE',
|
||||
ORG_COUNTERPART_NOT_LAB: 'ORG_COUNTERPART_NOT_LAB',
|
||||
ORG_COUNTERPART_NOT_CLINIC: 'ORG_COUNTERPART_NOT_CLINIC',
|
||||
ORG_CONNECTION_NOT_FOUND: 'ORG_CONNECTION_NOT_FOUND',
|
||||
ORG_INVITE_NOT_FOUND: 'ORG_INVITE_NOT_FOUND',
|
||||
ORG_ONLY_CLINIC_COMMENT: 'ORG_ONLY_CLINIC_COMMENT',
|
||||
|
||||
CATALOG_UNKNOWN_TREATMENT_TYPE: 'CATALOG_UNKNOWN_TREATMENT_TYPE',
|
||||
CATALOG_UNKNOWN_PROSTHESIS_TYPE: 'CATALOG_UNKNOWN_PROSTHESIS_TYPE',
|
||||
CATALOG_TREATMENT_NOT_LAB_DEPENDENT: 'CATALOG_TREATMENT_NOT_LAB_DEPENDENT',
|
||||
|
||||
TREATMENT_SAVE_DETAILS_BEFORE_LAB: 'TREATMENT_SAVE_DETAILS_BEFORE_LAB',
|
||||
TREATMENT_DETAIL_ONE_CASE: 'TREATMENT_DETAIL_ONE_CASE',
|
||||
TREATMENT_DETAILS_NOT_FOUND: 'TREATMENT_DETAILS_NOT_FOUND',
|
||||
TREATMENT_DEST_NOT_LINKED: 'TREATMENT_DEST_NOT_LINKED',
|
||||
TREATMENT_TEETH_REQUIRED_TO_SHIP: 'TREATMENT_TEETH_REQUIRED_TO_SHIP',
|
||||
TREATMENT_TOOTH_UNKNOWN_DETAIL: 'TREATMENT_TOOTH_UNKNOWN_DETAIL',
|
||||
TREATMENT_TOOTH_NOT_ON_DETAIL: 'TREATMENT_TOOTH_NOT_ON_DETAIL',
|
||||
TREATMENT_CASE_NO_DEST: 'TREATMENT_CASE_NO_DEST',
|
||||
TREATMENT_CASE_NEEDS_DETAIL: 'TREATMENT_CASE_NEEDS_DETAIL',
|
||||
TREATMENT_CASE_ONE_DETAIL: 'TREATMENT_CASE_ONE_DETAIL',
|
||||
TREATMENT_ONLY_PROVIDER_SEND: 'TREATMENT_ONLY_PROVIDER_SEND',
|
||||
TREATMENT_CASE_ALREADY_SENT: 'TREATMENT_CASE_ALREADY_SENT',
|
||||
TREATMENT_DUE_DATE_LOCKED: 'TREATMENT_DUE_DATE_LOCKED',
|
||||
TREATMENT_INVALID_DUE_DATE: 'TREATMENT_INVALID_DUE_DATE',
|
||||
TREATMENT_DETAIL_KEY_REQUIRED: 'TREATMENT_DETAIL_KEY_REQUIRED',
|
||||
TREATMENT_FILE_REQUIRED: 'TREATMENT_FILE_REQUIRED',
|
||||
TREATMENT_ATTACHMENT_NOT_FOUND: 'TREATMENT_ATTACHMENT_NOT_FOUND',
|
||||
TREATMENT_FILE_UNAVAILABLE: 'TREATMENT_FILE_UNAVAILABLE',
|
||||
TREATMENT_CASE_INVALID_ATTACHMENTS: 'TREATMENT_CASE_INVALID_ATTACHMENTS',
|
||||
TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE: 'TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE',
|
||||
TREATMENT_DETAIL_SENT: 'TREATMENT_DETAIL_SENT',
|
||||
LAB_CASE_NOT_FOUND: 'LAB_CASE_NOT_FOUND',
|
||||
|
||||
TASK_ASSIGNEE_INVALID: 'TASK_ASSIGNEE_INVALID',
|
||||
TASK_ASSIGNED_TO_OTHER: 'TASK_ASSIGNED_TO_OTHER',
|
||||
TASK_PAGE_DATE_SORT_ONLY: 'TASK_PAGE_DATE_SORT_ONLY',
|
||||
INVALID_SENT_FROM: 'INVALID_SENT_FROM',
|
||||
INVALID_SENT_TO: 'INVALID_SENT_TO',
|
||||
|
||||
CASE_NOT_FOUND: 'CASE_NOT_FOUND',
|
||||
CASE_ATTACHMENT_NOT_FOUND: 'CASE_ATTACHMENT_NOT_FOUND',
|
||||
CASE_ATTACHMENT_MISSING_FILE: 'CASE_ATTACHMENT_MISSING_FILE',
|
||||
CASE_TASK_NOT_FOUND: 'CASE_TASK_NOT_FOUND',
|
||||
|
||||
COMMENT_NOT_FOUND: 'COMMENT_NOT_FOUND',
|
||||
COMMENT_VISIBILITY_LAB_ONLY: 'COMMENT_VISIBILITY_LAB_ONLY',
|
||||
|
||||
TODAY_INVALID_RANGE: 'TODAY_INVALID_RANGE',
|
||||
TODAY_INVALID_RANGE_ORDER: 'TODAY_INVALID_RANGE_ORDER',
|
||||
|
||||
// Generic HTTP
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
CONFLICT: 'CONFLICT',
|
||||
CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS',
|
||||
APPOINTMENT_PATIENT_LOCKED: 'APPOINTMENT_PATIENT_LOCKED',
|
||||
APPOINTMENT_HAS_TREATMENT: 'APPOINTMENT_HAS_TREATMENT',
|
||||
TREATMENT_DETAIL_SENT: 'TREATMENT_DETAIL_SENT',
|
||||
BAD_REQUEST: 'BAD_REQUEST',
|
||||
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
||||
} as const;
|
||||
|
||||
@@ -28,58 +28,6 @@ const STATUS_FALLBACK_CODES: Partial<Record<number, ErrorCodeValue>> = {
|
||||
[HttpStatus.INTERNAL_SERVER_ERROR]: ErrorCode.INTERNAL_ERROR,
|
||||
};
|
||||
|
||||
/** Maps legacy English messages to stable codes during migration. */
|
||||
const LEGACY_MESSAGE_CODES: Record<string, ErrorCodeValue> = {
|
||||
'Invalid credentials': ErrorCode.AUTH_INVALID_CREDENTIALS,
|
||||
Unauthorized: ErrorCode.AUTH_UNAUTHORIZED,
|
||||
'Refresh token not found': ErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND,
|
||||
'Invalid or expired refresh token': ErrorCode.AUTH_REFRESH_TOKEN_INVALID,
|
||||
'Invalid refresh token': ErrorCode.AUTH_REFRESH_TOKEN_INVALID,
|
||||
'Refresh token failed': ErrorCode.AUTH_REFRESH_TOKEN_INVALID,
|
||||
'Invalid token type': ErrorCode.AUTH_SESSION_EXPIRED,
|
||||
'Invalid token': ErrorCode.AUTH_SESSION_EXPIRED,
|
||||
'Session not found or expired': ErrorCode.AUTH_SESSION_EXPIRED,
|
||||
'User not found': ErrorCode.AUTH_USER_NOT_FOUND,
|
||||
'Organization is not selected': ErrorCode.AUTH_ORG_NOT_SELECTED,
|
||||
'Access denied to this organization': ErrorCode.AUTH_ORG_ACCESS_DENIED,
|
||||
'Your invitation is still pending activation': ErrorCode.AUTH_INVITATION_PENDING,
|
||||
'Auto-login failed': ErrorCode.AUTH_REGISTRATION_FAILED,
|
||||
'User already exists. Please login and create a new organization from your account.':
|
||||
ErrorCode.AUTH_REGISTRATION_EMAIL_EXISTS,
|
||||
'This mobile number is already registered.': ErrorCode.AUTH_REGISTRATION_MOBILE_EXISTS,
|
||||
'Please enter a valid mobile number': ErrorCode.AUTH_REGISTRATION_MOBILE_INVALID,
|
||||
'Current password is incorrect': ErrorCode.AUTH_PASSWORD_INCORRECT,
|
||||
'Password reset verification expired. Please verify your mobile again.':
|
||||
ErrorCode.AUTH_PASSWORD_RESET_EXPIRED,
|
||||
'Invalid or expired verification code': ErrorCode.AUTH_VERIFICATION_CODE_INVALID,
|
||||
'Please wait before requesting another code': ErrorCode.AUTH_VERIFICATION_RATE_LIMIT,
|
||||
'Current password is required': ErrorCode.AUTH_PASSWORD_CURRENT_REQUIRED,
|
||||
'Select an organization before creating a new one.': ErrorCode.AUTH_SELECT_ORG_FIRST,
|
||||
'Only owners of the current organization can create new organizations.':
|
||||
ErrorCode.AUTH_CREATE_ORG_OWNER_ONLY,
|
||||
'This action is only available for clinic organizations': ErrorCode.PERMISSION_CLINIC_ONLY,
|
||||
'This action is only available for lab organizations': ErrorCode.PERMISSION_LAB_ONLY,
|
||||
'Organization not found': ErrorCode.PERMISSION_ORG_NOT_FOUND,
|
||||
'Unknown organization type': ErrorCode.PERMISSION_DENIED,
|
||||
'You do not have permission to manage organizations': ErrorCode.PERMISSION_ORG_MANAGE,
|
||||
'You are not a member of this organization': ErrorCode.PERMISSION_NOT_MEMBER,
|
||||
'Only organization owners can manage participation': ErrorCode.PERMISSION_OWNER_ONLY,
|
||||
'Only organization owners can manage their working hours': ErrorCode.PERMISSION_OWNER_ONLY,
|
||||
'An active subscription is required to participate in treatments or tasks':
|
||||
ErrorCode.PERMISSION_PARTICIPATION_SUBSCRIPTION,
|
||||
'Enable treatment participation before setting working hours':
|
||||
ErrorCode.PERMISSION_ENABLE_PARTICIPATION_FIRST,
|
||||
'Working hours are only available for clinic organizations':
|
||||
ErrorCode.PERMISSION_CLINIC_WORKING_HOURS,
|
||||
'You do not have access to appointments': ErrorCode.PERMISSION_ACCESS_APPOINTMENTS,
|
||||
'You cannot create or modify appointments': ErrorCode.PERMISSION_EDIT_APPOINTMENTS,
|
||||
'You do not have access to treatments': ErrorCode.PERMISSION_ACCESS_TREATMENTS,
|
||||
'You cannot edit treatments': ErrorCode.PERMISSION_EDIT_TREATMENTS,
|
||||
'You do not have access to tasks': ErrorCode.PERMISSION_ACCESS_TASKS,
|
||||
'You do not have access to staff management': ErrorCode.PERMISSION_ACCESS_STAFF,
|
||||
'You cannot manage staff working hours': ErrorCode.PERMISSION_EDIT_STAFF,
|
||||
};
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
@@ -121,7 +69,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
};
|
||||
}
|
||||
|
||||
const code = this.resolveLegacyCode(rawResponse, statusCode);
|
||||
const code = this.resolveFallbackCode(rawResponse, statusCode);
|
||||
return {
|
||||
statusCode,
|
||||
body: this.buildBody(code, statusCode),
|
||||
@@ -137,20 +85,12 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
);
|
||||
}
|
||||
|
||||
private resolveLegacyCode(rawResponse: string | object, statusCode: number): ErrorCodeValue {
|
||||
/** Nest/Passport leftovers: validation arrays, otherwise HTTP status. */
|
||||
private resolveFallbackCode(rawResponse: string | object, statusCode: number): ErrorCodeValue {
|
||||
const message = this.extractMessage(rawResponse);
|
||||
|
||||
if (typeof message === 'string') {
|
||||
const mapped = LEGACY_MESSAGE_CODES[message.trim()];
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(message)) {
|
||||
return ErrorCode.VALIDATION_FAILED;
|
||||
}
|
||||
|
||||
return STATUS_FALLBACK_CODES[statusCode] ?? ErrorCode.INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import type { ValidationError } from 'class-validator';
|
||||
import { AppException } from './app.exception';
|
||||
import { ErrorCode, type ErrorCodeValue, type ValidationErrorDetail } from './error-codes';
|
||||
@@ -22,7 +22,12 @@ function constraintToCode(constraintKey: string, message: string): ErrorCodeValu
|
||||
case 'isEnum':
|
||||
return ErrorCode.VALIDATION_ORGANIZATION_TYPE_INVALID;
|
||||
case 'matches':
|
||||
return ErrorCode.VALIDATION_MOBILE_INVALID;
|
||||
case 'isDateString':
|
||||
case 'isUuid':
|
||||
case 'isInt':
|
||||
case 'isBoolean':
|
||||
case 'isArray':
|
||||
return ErrorCode.VALIDATION_INVALID_REQUEST;
|
||||
case 'isIn':
|
||||
return ErrorCode.VALIDATION_LANGUAGE_INVALID;
|
||||
case 'whitelistValidation':
|
||||
@@ -66,8 +71,3 @@ export function validationExceptionFactory(errors: ValidationError[]): HttpExcep
|
||||
|
||||
return new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.BAD_REQUEST, details);
|
||||
}
|
||||
|
||||
/** Handles forbidNonWhitelisted errors from ValidationPipe. */
|
||||
export function isValidationPipeBadRequest(exception: unknown): exception is BadRequestException {
|
||||
return exception instanceof BadRequestException;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { zonedWeekdayAndMinutes } from './zoned-civil-time';
|
||||
|
||||
export const MINUTES_PER_DAY = 24 * 60;
|
||||
|
||||
export type WorkingHoursBlockInput = {
|
||||
@@ -82,17 +84,18 @@ export function isRangeWithinWorkingBlocks(
|
||||
return true;
|
||||
}
|
||||
|
||||
export function dateToLocalMinutes(date: Date): number {
|
||||
return date.getHours() * 60 + date.getMinutes();
|
||||
export function dateToZonedMinutes(date: Date, timeZone: string): number {
|
||||
return zonedWeekdayAndMinutes(date, timeZone).minuteOfDay;
|
||||
}
|
||||
|
||||
export function appointmentWithinWorkingHours(
|
||||
startAt: Date,
|
||||
endAt: Date,
|
||||
dayBlocks: WorkingHoursDayBlock[],
|
||||
timeZone: string,
|
||||
): boolean {
|
||||
const startMinute = dateToLocalMinutes(startAt);
|
||||
const endMinute = dateToLocalMinutes(endAt);
|
||||
const startMinute = dateToZonedMinutes(startAt, timeZone);
|
||||
const endMinute = dateToZonedMinutes(endAt, timeZone);
|
||||
return isRangeWithinWorkingBlocks(startMinute, endMinute, dayBlocks);
|
||||
}
|
||||
|
||||
|
||||
29
backend/src/common/zoned-civil-time.spec.ts
Normal file
29
backend/src/common/zoned-civil-time.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { appointmentWithinWorkingHours } from './working-hours';
|
||||
import { isValidIanaTimeZone, zonedWeekdayAndMinutes } from './zoned-civil-time';
|
||||
|
||||
describe('zoned civil time', () => {
|
||||
it('accepts IANA zones and rejects garbage', () => {
|
||||
expect(isValidIanaTimeZone('Asia/Tehran')).toBe(true);
|
||||
expect(isValidIanaTimeZone('Europe/Amsterdam')).toBe(true);
|
||||
expect(isValidIanaTimeZone('Not/AZone')).toBe(false);
|
||||
});
|
||||
|
||||
it('maps a UTC instant to Tehran wall-clock (no DST)', () => {
|
||||
// 2026-08-20 09:45 Asia/Tehran = 06:15 UTC (the production 400 case)
|
||||
const instant = new Date('2026-08-20T06:15:00.000Z');
|
||||
const zoned = zonedWeekdayAndMinutes(instant, 'Asia/Tehran');
|
||||
expect(zoned.jsWeekday).toBe(4); // Thursday
|
||||
expect(zoned.minuteOfDay).toBe(9 * 60 + 45);
|
||||
});
|
||||
|
||||
it('treats morning Tehran slots as inside 08:00–17:00 hours', () => {
|
||||
const start = new Date('2026-08-20T06:15:00.000Z');
|
||||
const end = new Date('2026-08-20T06:45:00.000Z');
|
||||
expect(
|
||||
appointmentWithinWorkingHours(start, end, [{ startMinute: 8 * 60, endMinute: 17 * 60 }], 'Asia/Tehran'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
appointmentWithinWorkingHours(start, end, [{ startMinute: 8 * 60, endMinute: 17 * 60 }], 'UTC'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
55
backend/src/common/zoned-civil-time.ts
Normal file
55
backend/src/common/zoned-civil-time.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/** Convert an absolute instant into weekday + minute-of-day in an IANA time zone. */
|
||||
|
||||
const JS_WEEKDAY: Record<string, number> = {
|
||||
Sun: 0,
|
||||
Mon: 1,
|
||||
Tue: 2,
|
||||
Wed: 3,
|
||||
Thu: 4,
|
||||
Fri: 5,
|
||||
Sat: 6,
|
||||
};
|
||||
|
||||
export function isValidIanaTimeZone(timeZone: string): boolean {
|
||||
if (!timeZone || timeZone.length > 64) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
Intl.DateTimeFormat('en-US', { timeZone }).format(new Date(0));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function zonedWeekdayAndMinutes(
|
||||
date: Date,
|
||||
timeZone: string,
|
||||
): { jsWeekday: number; minuteOfDay: number } {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
weekday: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(date);
|
||||
|
||||
const weekdayToken = parts.find((p) => p.type === 'weekday')?.value ?? 'Sun';
|
||||
let hour = Number(parts.find((p) => p.type === 'hour')?.value ?? '0');
|
||||
const minute = Number(parts.find((p) => p.type === 'minute')?.value ?? '0');
|
||||
if (hour === 24) {
|
||||
hour = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
jsWeekday: JS_WEEKDAY[weekdayToken] ?? 0,
|
||||
minuteOfDay: hour * 60 + minute,
|
||||
};
|
||||
}
|
||||
|
||||
/** Weekday of a YYYY-MM-DD civil date (Gregorian; same worldwide). */
|
||||
export function civilDateJsWeekday(isoDate: string): number {
|
||||
const [y, m, d] = isoDate.split('-').map(Number);
|
||||
const utcNoon = new Date(Date.UTC(y, m - 1, d, 12, 0, 0, 0));
|
||||
return utcNoon.getUTCDay();
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import {
|
||||
@@ -11,6 +8,7 @@ import {
|
||||
blocksForDay,
|
||||
localDayOfWeekMondayZero,
|
||||
} from '../../common/working-hours';
|
||||
import { civilDateJsWeekday, isValidIanaTimeZone, zonedWeekdayAndMinutes } from '../../common/zoned-civil-time';
|
||||
import { StaffWorkingHoursService } from '../staff/staff-working-hours.service';
|
||||
import { CreateAppointmentDto } from './dto/create-appointment.dto';
|
||||
import { ListAppointmentsDto } from './dto/list-appointments.dto';
|
||||
@@ -31,7 +29,7 @@ export class AppointmentsService {
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return user.organizationId;
|
||||
}
|
||||
@@ -88,11 +86,11 @@ export class AppointmentsService {
|
||||
const to = new Date(query.to);
|
||||
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
|
||||
throw new BadRequestException('Invalid date range');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_INVALID_RANGE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (to <= from) {
|
||||
throw new BadRequestException('Range "to" must be after "from"');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_INVALID_RANGE_ORDER, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const items = await this.prisma.appointment.findMany({
|
||||
@@ -129,22 +127,23 @@ export class AppointmentsService {
|
||||
|
||||
const startAt = new Date(dto.startAt);
|
||||
const endAt = new Date(dto.endAt);
|
||||
const timeZone = this.requireTimeZone(dto.timeZone);
|
||||
|
||||
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) {
|
||||
throw new BadRequestException('Invalid start or end time');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_INVALID_TIME, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (endAt <= startAt) {
|
||||
throw new BadRequestException('End time must be after start time');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_END_BEFORE_START, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) {
|
||||
throw new BadRequestException('Appointment cannot span more than 24 hours');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_TOO_LONG, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (startAt.getTime() < now) {
|
||||
throw new BadRequestException('Cannot schedule appointments in the past');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_IN_PAST, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
await this.ensurePatientInOrg(dto.patientId, organizationId);
|
||||
@@ -155,6 +154,7 @@ export class AppointmentsService {
|
||||
organizationId,
|
||||
startAt,
|
||||
endAt,
|
||||
timeZone,
|
||||
);
|
||||
|
||||
const appointment = await this.prisma.appointment.create({
|
||||
@@ -189,22 +189,23 @@ export class AppointmentsService {
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Appointment not found');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
const startAt = dto.startAt ? new Date(dto.startAt) : existing.startAt;
|
||||
const endAt = dto.endAt ? new Date(dto.endAt) : existing.endAt;
|
||||
const timeZone = this.requireTimeZone(dto.timeZone);
|
||||
|
||||
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) {
|
||||
throw new BadRequestException('Invalid start or end time');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_INVALID_TIME, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (endAt <= startAt) {
|
||||
throw new BadRequestException('End time must be after start time');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_END_BEFORE_START, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) {
|
||||
throw new BadRequestException('Appointment cannot span more than 24 hours');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_TOO_LONG, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const patientId = dto.patientId ?? existing.patientId;
|
||||
@@ -230,6 +231,7 @@ export class AppointmentsService {
|
||||
organizationId,
|
||||
startAt,
|
||||
endAt,
|
||||
timeZone,
|
||||
);
|
||||
|
||||
const appointment = await this.prisma.appointment.update({
|
||||
@@ -260,7 +262,7 @@ export class AppointmentsService {
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Appointment not found');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (existing.treatment) {
|
||||
@@ -277,7 +279,7 @@ export class AppointmentsService {
|
||||
private async assertCanViewAppointments(userId: string, organizationId: string) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (m.isOwner) {
|
||||
return;
|
||||
@@ -286,7 +288,7 @@ export class AppointmentsService {
|
||||
if (names.includes('TAB_APPOINTMENTS_READ') || names.includes('TAB_APPOINTMENTS_EDIT')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You do not have access to appointments');
|
||||
throw new AppException(ErrorCode.PERMISSION_ACCESS_APPOINTMENTS, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
private async assertCanListAppointmentsForTreatment(
|
||||
@@ -295,7 +297,7 @@ export class AppointmentsService {
|
||||
) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (m.isOwner) {
|
||||
return { membership: m, scopeToProvider: false as const };
|
||||
@@ -306,7 +308,7 @@ export class AppointmentsService {
|
||||
const canViewTreatment =
|
||||
names.includes('TAB_TREATMENT_READ') || names.includes('TAB_TREATMENT_EDIT');
|
||||
if (!canViewSchedule && !canViewTreatment) {
|
||||
throw new ForbiddenException('You do not have access to appointments');
|
||||
throw new AppException(ErrorCode.PERMISSION_ACCESS_APPOINTMENTS, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return { membership: m, scopeToProvider: !canViewSchedule && canViewTreatment };
|
||||
}
|
||||
@@ -314,7 +316,7 @@ export class AppointmentsService {
|
||||
private async assertCanEditAppointments(userId: string, organizationId: string) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (m.isOwner) {
|
||||
return;
|
||||
@@ -323,19 +325,19 @@ export class AppointmentsService {
|
||||
if (names.includes('TAB_APPOINTMENTS_EDIT')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You cannot create or modify appointments');
|
||||
throw new AppException(ErrorCode.PERMISSION_EDIT_APPOINTMENTS, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
private async ensureProviderIsTreatmentEditor(providerUserId: string, organizationId: string) {
|
||||
const m = await this.getMembership(providerUserId, organizationId);
|
||||
if (!m) {
|
||||
throw new BadRequestException('Provider is not a member of this organization');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NOT_MEMBER, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (!m.isOwner && !m.isActive) {
|
||||
throw new BadRequestException('Provider is not an active staff member');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_INACTIVE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (!hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')) {
|
||||
throw new BadRequestException('Provider does not have treatment edit access');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NO_TREATMENT_EDIT, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,20 +347,25 @@ export class AppointmentsService {
|
||||
select: { id: true },
|
||||
});
|
||||
if (!patient) {
|
||||
throw new NotFoundException('Patient not found');
|
||||
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
private requireTimeZone(timeZone: string | undefined): string {
|
||||
if (!timeZone || !isValidIanaTimeZone(timeZone)) {
|
||||
throw new AppException(ErrorCode.VALIDATION_TIMEZONE_INVALID, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return timeZone;
|
||||
}
|
||||
|
||||
private resolveDayOfWeekMondayZero(date?: string): number {
|
||||
if (!date) {
|
||||
return localDayOfWeekMondayZero(new Date().getDay());
|
||||
return localDayOfWeekMondayZero(new Date().getUTCDay());
|
||||
}
|
||||
const [y, m, d] = date.split('-').map(Number);
|
||||
const parsed = new Date(y, m - 1, d, 12, 0, 0, 0);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw new BadRequestException('Invalid date query parameter');
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
throw new AppException(ErrorCode.APPOINTMENT_INVALID_DATE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return localDayOfWeekMondayZero(parsed.getDay());
|
||||
return localDayOfWeekMondayZero(civilDateJsWeekday(date));
|
||||
}
|
||||
|
||||
private async ensureAppointmentWithinProviderWorkingHours(
|
||||
@@ -366,27 +373,28 @@ export class AppointmentsService {
|
||||
organizationId: string,
|
||||
startAt: Date,
|
||||
endAt: Date,
|
||||
timeZone: string,
|
||||
) {
|
||||
const membership = await this.getMembership(providerUserId, organizationId);
|
||||
if (!membership) {
|
||||
throw new BadRequestException('Provider is not a member of this organization');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NOT_MEMBER, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const scheduleBlocksByMembership =
|
||||
await this.staffWorkingHoursService.loadScheduleBlocksByMembershipIds([membership.id]);
|
||||
const allBlocks = scheduleBlocksByMembership.get(membership.id) ?? [];
|
||||
if (allBlocks.length === 0) {
|
||||
throw new BadRequestException('Provider has no working hours configured');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NO_WORKING_HOURS, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const dayOfWeek = localDayOfWeekMondayZero(startAt.getDay());
|
||||
const dayOfWeek = localDayOfWeekMondayZero(zonedWeekdayAndMinutes(startAt, timeZone).jsWeekday);
|
||||
const dayBlocks = blocksForDay(allBlocks, dayOfWeek);
|
||||
if (dayBlocks.length === 0) {
|
||||
throw new BadRequestException('Provider is not working on this day');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_PROVIDER_NOT_WORKING_DAY, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (!appointmentWithinWorkingHours(startAt, endAt, dayBlocks)) {
|
||||
throw new BadRequestException('Appointment must fall within the provider working hours');
|
||||
if (!appointmentWithinWorkingHours(startAt, endAt, dayBlocks, timeZone)) {
|
||||
throw new AppException(ErrorCode.APPOINTMENT_OUTSIDE_WORKING_HOURS, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { IsOptional, IsString, Matches } from 'class-validator';
|
||||
import { ErrorCode } from '../../../common/errors';
|
||||
|
||||
export class ColumnProvidersQueryDto {
|
||||
/** Local calendar date (YYYY-MM-DD) used to resolve weekday working hours. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: ErrorCode.APPOINTMENT_INVALID_DATE })
|
||||
date?: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsDateString, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||
import { IsDateString, IsString, IsUUID, Matches, MaxLength } from 'class-validator';
|
||||
import { ErrorCode } from '../../../common/errors';
|
||||
|
||||
export class CreateAppointmentDto {
|
||||
@ApiProperty()
|
||||
@@ -25,4 +26,14 @@ export class CreateAppointmentDto {
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
purpose: string;
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'IANA time zone of the booking user (e.g. Asia/Tehran). Wall-clock hours are interpreted in this zone.',
|
||||
example: 'Asia/Tehran',
|
||||
})
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
@Matches(/^[A-Za-z0-9_+\-/]+$/, { message: ErrorCode.VALIDATION_TIMEZONE_INVALID })
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
@@ -103,7 +102,7 @@ export class CasesService {
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return user.organizationId;
|
||||
}
|
||||
@@ -300,7 +299,7 @@ export class CasesService {
|
||||
});
|
||||
|
||||
if (!labCase) {
|
||||
throw new NotFoundException('Case not found');
|
||||
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -327,7 +326,7 @@ export class CasesService {
|
||||
});
|
||||
|
||||
if (!labCase) {
|
||||
throw new NotFoundException('Case not found');
|
||||
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
|
||||
@@ -356,11 +355,11 @@ export class CasesService {
|
||||
});
|
||||
|
||||
if (!link?.attachment) {
|
||||
throw new NotFoundException('Attachment not found');
|
||||
throw new AppException(ErrorCode.CASE_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (!existsSync(link.attachment.storagePath)) {
|
||||
throw new NotFoundException('Attachment file is missing on disk');
|
||||
throw new AppException(ErrorCode.CASE_ATTACHMENT_MISSING_FILE, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -389,7 +388,7 @@ export class CasesService {
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Case not found');
|
||||
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
await this.prisma.labCase.update({
|
||||
@@ -440,7 +439,7 @@ export class CasesService {
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Case not found');
|
||||
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
const externalCode =
|
||||
@@ -510,7 +509,7 @@ export class CasesService {
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException('Task not found');
|
||||
throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
const assigneeUserId = dto.assigneeUserId ?? null;
|
||||
@@ -530,7 +529,7 @@ export class CasesService {
|
||||
|
||||
const canReceive = memberships.some((m) => hasEffectivePermission(m, 'TAB_TASKS_EDIT'));
|
||||
if (!canReceive) {
|
||||
throw new BadRequestException('Selected user cannot be assigned tasks');
|
||||
throw new AppException(ErrorCode.TASK_ASSIGNEE_INVALID, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,7 +576,7 @@ export class CasesService {
|
||||
if (query.sentFrom) {
|
||||
const from = new Date(query.sentFrom);
|
||||
if (Number.isNaN(from.getTime())) {
|
||||
throw new BadRequestException('Invalid sentFrom date');
|
||||
throw new AppException(ErrorCode.INVALID_SENT_FROM, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
sentAtFilter.gte = from;
|
||||
}
|
||||
@@ -585,7 +584,7 @@ export class CasesService {
|
||||
if (query.sentTo) {
|
||||
const to = new Date(query.sentTo);
|
||||
if (Number.isNaN(to.getTime())) {
|
||||
throw new BadRequestException('Invalid sentTo date');
|
||||
throw new AppException(ErrorCode.INVALID_SENT_TO, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
to.setHours(23, 59, 59, 999);
|
||||
sentAtFilter.lte = to;
|
||||
@@ -874,27 +873,27 @@ export class CasesService {
|
||||
private async assertCanReadCases(userId: string, organizationId: string) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (m.isOwner) return;
|
||||
const names = m.permissions.map((p) => p.permission.name);
|
||||
if (names.includes('TAB_CASES_READ') || names.includes('TAB_CASES_EDIT')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You do not have access to cases');
|
||||
throw new AppException(ErrorCode.PERMISSION_ACCESS_CASES, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
private async assertCanEditCases(userId: string, organizationId: string) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (m.isOwner) return;
|
||||
const names = m.permissions.map((p) => p.permission.name);
|
||||
if (names.includes('TAB_CASES_EDIT')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You cannot update cases');
|
||||
throw new AppException(ErrorCode.PERMISSION_ACCESS_CASES, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
private async getMembership(userId: string, organizationId: string) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CatalogEntityKind, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
@@ -230,7 +229,7 @@ export class LabCaseAccessService {
|
||||
});
|
||||
|
||||
if (!labCase) {
|
||||
throw new NotFoundException('Case not found');
|
||||
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return labCase;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import { LabCaseCommentSide, LabCaseActivityType, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto';
|
||||
@@ -108,11 +108,11 @@ export class LabCaseCommentsService {
|
||||
select: { id: true, labCaseId: true, authorSide: true },
|
||||
});
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
throw new AppException(ErrorCode.COMMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
await this.assertLabCanComment(comment.labCaseId, labOrganizationId, actorUserId);
|
||||
if (comment.authorSide !== LabCaseCommentSide.LAB) {
|
||||
throw new ForbiddenException('Only lab comments can change visibility');
|
||||
throw new AppException(ErrorCode.COMMENT_VISIBILITY_LAB_ONLY, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
const updated = await this.prisma.labCaseComment.update({
|
||||
where: { id: commentId },
|
||||
@@ -278,7 +278,7 @@ export class LabCaseCommentsService {
|
||||
await this.assertLabCanViewCase(caseId, labOrganizationId, actorUserId);
|
||||
const membership = await this.getLabMembership(actorUserId, labOrganizationId);
|
||||
if (!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')) {
|
||||
throw new ForbiddenException('You do not have access to task comments');
|
||||
throw new AppException(ErrorCode.PERMISSION_ACCESS_TASKS, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@ export class LabCaseCommentsService {
|
||||
select: { id: true },
|
||||
});
|
||||
if (!labCase) {
|
||||
throw new NotFoundException('Case not found');
|
||||
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
const membership = await this.getLabMembership(actorUserId, labOrganizationId);
|
||||
@@ -304,7 +304,7 @@ export class LabCaseCommentsService {
|
||||
!hasEffectivePermission(membership, 'TAB_TASKS_READ') &&
|
||||
!hasEffectivePermission(membership, 'TAB_TASKS_EDIT')
|
||||
) {
|
||||
throw new ForbiddenException('You do not have access to tasks');
|
||||
throw new AppException(ErrorCode.PERMISSION_ACCESS_TASKS, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ export class LabCaseCommentsService {
|
||||
},
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return membership;
|
||||
}
|
||||
@@ -340,7 +340,7 @@ export class LabCaseCommentsService {
|
||||
select: { id: true },
|
||||
});
|
||||
if (!labCase) {
|
||||
throw new NotFoundException('Case not found');
|
||||
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ export class LabCaseCommentsService {
|
||||
select: { id: true },
|
||||
});
|
||||
if (!labCase) {
|
||||
throw new NotFoundException('Case not found');
|
||||
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
@@ -375,7 +375,7 @@ export class LabCaseCommentsService {
|
||||
},
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (
|
||||
hasEffectivePermission(membership, 'TAB_TREATMENT_READ') ||
|
||||
@@ -383,7 +383,7 @@ export class LabCaseCommentsService {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You do not have access to treatment cases');
|
||||
throw new AppException(ErrorCode.PERMISSION_ACCESS_TREATMENTS, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
private async labOrgIdForCase(caseId: string): Promise<string | null> {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import {
|
||||
LabCaseActivityType,
|
||||
LabCaseTabReadTarget,
|
||||
@@ -49,7 +49,7 @@ export class LabCaseActivityService {
|
||||
include: { type: true },
|
||||
});
|
||||
if (!org) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
await this.assertMembership(userId, organizationId);
|
||||
@@ -224,7 +224,7 @@ export class LabCaseActivityService {
|
||||
include: { type: true },
|
||||
});
|
||||
if (!org) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
await this.assertCanAccessCase(userId, organizationId, labCaseId);
|
||||
@@ -454,7 +454,7 @@ export class LabCaseActivityService {
|
||||
},
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,7 +468,7 @@ export class LabCaseActivityService {
|
||||
include: { type: true },
|
||||
});
|
||||
if (!org) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
const labCase = await this.prisma.labCase.findFirst({
|
||||
@@ -491,7 +491,7 @@ export class LabCaseActivityService {
|
||||
});
|
||||
|
||||
if (!labCase) {
|
||||
throw new NotFoundException('Case not found');
|
||||
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (org.type.name === 'LAB') {
|
||||
@@ -513,7 +513,7 @@ export class LabCaseActivityService {
|
||||
hasEffectivePermission(membership, 'TAB_TASKS_READ')
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException('You do not have access to this case');
|
||||
throw new AppException(ErrorCode.PERMISSION_ACCESS_CASES, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -536,7 +536,7 @@ export class LabCaseActivityService {
|
||||
hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException('You do not have access to this case');
|
||||
throw new AppException(ErrorCode.PERMISSION_ACCESS_CASES, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import { LinkStatus, UserNotificationType } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
@@ -42,7 +37,7 @@ export class OrganizationService {
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return user.organizationId;
|
||||
}
|
||||
@@ -50,7 +45,7 @@ export class OrganizationService {
|
||||
async searchCounterpartOrganizations(userId: string, organizationId: string, query: string) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
if (!actor || !this.canEditOrganizations(actor)) {
|
||||
throw new ForbiddenException('You do not have permission to manage organizations');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const targetType = this.getCounterpartType(actor.organization.type.name);
|
||||
@@ -88,7 +83,7 @@ export class OrganizationService {
|
||||
async list(userId: string, organizationId: string) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
if (!actor || !this.canEditOrganizations(actor)) {
|
||||
throw new ForbiddenException('You do not have permission to manage organizations');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
// Open outbound invitations keyed by placeholder/real invited org id — lets UI show copy-invite
|
||||
@@ -174,7 +169,7 @@ export class OrganizationService {
|
||||
async countIncomingPendingConnections(userId: string, organizationId: string) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
if (!actor || !this.canEditOrganizations(actor)) {
|
||||
throw new ForbiddenException('You do not have permission to manage organizations');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const links = await this.prisma.organizationLink.findMany({
|
||||
@@ -196,7 +191,7 @@ export class OrganizationService {
|
||||
async listInvitationHistory(userId: string, organizationId: string) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
if (!actor || !this.canEditOrganizations(actor)) {
|
||||
throw new ForbiddenException('You do not have permission to manage organizations');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const invitations = await this.prisma.organizationInvitation.findMany({
|
||||
@@ -227,10 +222,10 @@ export class OrganizationService {
|
||||
) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
if (!actor || !this.canEditOrganizations(actor)) {
|
||||
throw new ForbiddenException('You do not have permission to manage organizations');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (dto.targetOrganizationId === organizationId) {
|
||||
throw new BadRequestException('You cannot link organization to itself');
|
||||
throw new AppException(ErrorCode.ORG_CANNOT_LINK_SELF, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const sourceType = actor.organization.type.name;
|
||||
@@ -240,13 +235,13 @@ export class OrganizationService {
|
||||
select: { id: true, type: true, planId: true },
|
||||
});
|
||||
if (!target) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (target.type.name !== targetType) {
|
||||
throw new BadRequestException(`You can only link to ${targetType} organizations`);
|
||||
throw new AppException(ErrorCode.ORG_LINK_WRONG_TYPE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (!target.planId) {
|
||||
throw new BadRequestException('Target organization does not have an active subscription');
|
||||
throw new AppException(ErrorCode.ORG_TARGET_NO_SUBSCRIPTION, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const [aId, bId] =
|
||||
@@ -258,7 +253,7 @@ export class OrganizationService {
|
||||
where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('Link already exists for these organizations');
|
||||
throw new AppException(ErrorCode.ORG_LINK_EXISTS, HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
const created = await this.prisma.organizationLink.create({
|
||||
@@ -295,7 +290,7 @@ export class OrganizationService {
|
||||
) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
if (!actor || !this.canEditOrganizations(actor)) {
|
||||
throw new ForbiddenException('You do not have permission to manage organizations');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const connection = await this.prisma.organizationLink.findFirst({
|
||||
@@ -305,15 +300,15 @@ export class OrganizationService {
|
||||
},
|
||||
});
|
||||
if (!connection) {
|
||||
throw new NotFoundException('Connection request not found');
|
||||
throw new AppException(ErrorCode.ORG_CONNECTION_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (connection.status !== LinkStatus.PENDING) {
|
||||
throw new BadRequestException('Only pending connection requests can be responded to');
|
||||
throw new AppException(ErrorCode.ORG_REQUEST_NOT_PENDING, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const requesterOrgId = this.getRequesterOrganizationId(connection.sharedDataTypes);
|
||||
if (requesterOrgId && requesterOrgId === organizationId) {
|
||||
throw new ForbiddenException('You cannot respond to your own connection request');
|
||||
throw new AppException(ErrorCode.ORG_CANNOT_RESPOND_OWN, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const nextStatus = dto.action === 'ACCEPT' ? LinkStatus.ACTIVE : LinkStatus.REJECTED;
|
||||
@@ -335,7 +330,7 @@ export class OrganizationService {
|
||||
async deleteConnection(userId: string, organizationId: string, connectionId: string) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
if (!actor || !this.canEditOrganizations(actor)) {
|
||||
throw new ForbiddenException('You do not have permission to manage organizations');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const connection = await this.prisma.organizationLink.findFirst({
|
||||
@@ -347,7 +342,7 @@ export class OrganizationService {
|
||||
select: { id: true },
|
||||
});
|
||||
if (!connection) {
|
||||
throw new NotFoundException('Connected organization not found');
|
||||
throw new AppException(ErrorCode.ORG_CONNECTION_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
await this.prisma.organizationLink.delete({ where: { id: connection.id } });
|
||||
@@ -367,7 +362,7 @@ export class OrganizationService {
|
||||
) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
if (!actor || !this.canEditOrganizations(actor)) {
|
||||
throw new ForbiddenException('You do not have permission to manage organizations');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const { clinicOrganizationId, labOrganizationId, counterpart } =
|
||||
@@ -397,7 +392,7 @@ export class OrganizationService {
|
||||
) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
if (!actor || !this.canEditOrganizations(actor)) {
|
||||
throw new ForbiddenException('You do not have permission to manage organizations');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const { clinicOrganizationId, labOrganizationId, counterpart } =
|
||||
@@ -459,11 +454,11 @@ export class OrganizationService {
|
||||
) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
if (!actor || !this.canEditOrganizations(actor)) {
|
||||
throw new ForbiddenException('You do not have permission to manage organizations');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
const parties = await this.resolveActiveConnectionParties(connectionId, organizationId, actor);
|
||||
if (parties.clinicOrganizationId !== organizationId) {
|
||||
throw new ForbiddenException('Only the clinic can comment on this case');
|
||||
throw new AppException(ErrorCode.ORG_ONLY_CLINIC_COMMENT, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return parties;
|
||||
}
|
||||
@@ -472,7 +467,7 @@ export class OrganizationService {
|
||||
async getInvitationLink(userId: string, organizationId: string, invitationId: string) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
if (!actor || !this.canEditOrganizations(actor)) {
|
||||
throw new ForbiddenException('You do not have permission to manage organizations');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const invitation = await this.prisma.organizationInvitation.findFirst({
|
||||
@@ -487,13 +482,13 @@ export class OrganizationService {
|
||||
},
|
||||
});
|
||||
if (!invitation) {
|
||||
throw new NotFoundException('Invitation not found');
|
||||
throw new AppException(ErrorCode.ORG_INVITE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (invitation.acceptedAt) {
|
||||
throw new BadRequestException('This invitation has already been accepted');
|
||||
throw new AppException(ErrorCode.ORG_INVITE_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (invitation.revokedAt) {
|
||||
throw new BadRequestException('This invitation is no longer valid');
|
||||
throw new AppException(ErrorCode.ORG_INVITE_INVALID, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const plainToken = this.generateInviteToken();
|
||||
@@ -522,7 +517,7 @@ export class OrganizationService {
|
||||
async inviteOrganization(userId: string, organizationId: string, dto: InviteOrganizationDto) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
if (!actor || !this.canEditOrganizations(actor)) {
|
||||
throw new ForbiddenException('You do not have permission to manage organizations');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_MANAGE, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const ownerEmail = dto.ownerEmail.trim().toLowerCase();
|
||||
@@ -539,9 +534,7 @@ export class OrganizationService {
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingOwnerWithPlan) {
|
||||
throw new BadRequestException(
|
||||
'This owner already has an organization with active subscription. Select that organization from search instead of sending invitation.',
|
||||
);
|
||||
throw new AppException(ErrorCode.ORG_INVITE_OWNER_HAS_ORG, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const invitation = await this.prisma.$transaction(async (tx) => {
|
||||
@@ -587,7 +580,7 @@ export class OrganizationService {
|
||||
where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } },
|
||||
});
|
||||
if (existingLink?.status === LinkStatus.ACTIVE) {
|
||||
throw new ConflictException('These organizations are already linked');
|
||||
throw new AppException(ErrorCode.ORG_LINK_EXISTS, HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
// Pre-create connection so inviter sees one pending row; acceptInvite() flips to ACTIVE.
|
||||
@@ -660,13 +653,11 @@ export class OrganizationService {
|
||||
async acceptInvite(dto: AcceptOrganizationInviteDto) {
|
||||
const invitation = await this.findValidInvitation(dto.token);
|
||||
if (invitation.acceptedAt) {
|
||||
throw new BadRequestException('This invitation has already been accepted');
|
||||
throw new AppException(ErrorCode.ORG_INVITE_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (dto.organizationType !== invitation.invitedOrganizationType) {
|
||||
throw new BadRequestException(
|
||||
`Organization type must be ${invitation.invitedOrganizationType} for this invitation`,
|
||||
);
|
||||
throw new AppException(ErrorCode.ORG_INVITE_TYPE_MISMATCH, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const organizationEmail = dto.organizationEmail.trim().toLowerCase();
|
||||
@@ -788,7 +779,7 @@ export class OrganizationService {
|
||||
});
|
||||
|
||||
if (!link) {
|
||||
throw new NotFoundException('Connected organization not found');
|
||||
throw new AppException(ErrorCode.ORG_CONNECTION_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
const counterpart =
|
||||
@@ -797,7 +788,7 @@ export class OrganizationService {
|
||||
const orgType = actor.organization.type.name;
|
||||
if (orgType === 'CLINIC') {
|
||||
if (counterpart.type.name !== 'LAB') {
|
||||
throw new BadRequestException('Counterpart organization is not a lab');
|
||||
throw new AppException(ErrorCode.ORG_COUNTERPART_NOT_LAB, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return {
|
||||
clinicOrganizationId: organizationId,
|
||||
@@ -808,7 +799,7 @@ export class OrganizationService {
|
||||
|
||||
if (orgType === 'LAB') {
|
||||
if (counterpart.type.name !== 'CLINIC') {
|
||||
throw new BadRequestException('Counterpart organization is not a clinic');
|
||||
throw new AppException(ErrorCode.ORG_COUNTERPART_NOT_CLINIC, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return {
|
||||
clinicOrganizationId: counterpart.id,
|
||||
@@ -817,7 +808,7 @@ export class OrganizationService {
|
||||
};
|
||||
}
|
||||
|
||||
throw new BadRequestException('Unknown organization type');
|
||||
throw new AppException(ErrorCode.ORG_UNKNOWN_TYPE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
private async getActorMembership(userId: string, organizationId: string) {
|
||||
@@ -846,7 +837,7 @@ export class OrganizationService {
|
||||
private getCounterpartType(orgType: string): 'CLINIC' | 'LAB' {
|
||||
if (orgType === 'CLINIC') return 'LAB';
|
||||
if (orgType === 'LAB') return 'CLINIC';
|
||||
throw new BadRequestException('Unknown organization type');
|
||||
throw new AppException(ErrorCode.ORG_UNKNOWN_TYPE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
private mapInvitationStatus(
|
||||
@@ -898,13 +889,13 @@ export class OrganizationService {
|
||||
},
|
||||
});
|
||||
if (!invitation) {
|
||||
throw new NotFoundException('Invitation not found');
|
||||
throw new AppException(ErrorCode.ORG_INVITE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (invitation.revokedAt) {
|
||||
throw new BadRequestException('Invitation has been revoked');
|
||||
throw new AppException(ErrorCode.ORG_INVITE_REVOKED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (invitation.expiresAt.getTime() <= Date.now()) {
|
||||
throw new BadRequestException('Invitation has expired');
|
||||
throw new AppException(ErrorCode.ORG_INVITE_EXPIRED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return invitation;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, HttpStatus, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { isValidMobile, mobileSearchDigits, normalizeMobile } from '../../common/phone';
|
||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
@@ -77,7 +77,7 @@ export class PatientsService {
|
||||
});
|
||||
|
||||
if (!patient) {
|
||||
throw new NotFoundException('Patient not found');
|
||||
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return { success: true, data: patient };
|
||||
@@ -162,7 +162,7 @@ export class PatientsService {
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return user.organizationId;
|
||||
}
|
||||
@@ -240,7 +240,7 @@ export class PatientsService {
|
||||
});
|
||||
|
||||
if (!patient) {
|
||||
throw new NotFoundException('Patient not found');
|
||||
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, BadRequestException, OnModuleInit } from '@nestjs/common';
|
||||
import { HttpStatus, Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import { CatalogEntityKind } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import {
|
||||
@@ -60,7 +61,7 @@ export class ProsthesisCatalogService implements OnModuleInit {
|
||||
assertKnownProsthesisType(code: string): void {
|
||||
this.ensureLoaded();
|
||||
if (!this.byCode.has(code)) {
|
||||
throw new BadRequestException(`Unknown prosthesis type: ${code}`);
|
||||
throw new AppException(ErrorCode.CATALOG_UNKNOWN_PROSTHESIS_TYPE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,14 @@ import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { ErrorCode } from '../../../common/errors';
|
||||
|
||||
export class WorkingHoursBlockDto {
|
||||
@IsInt()
|
||||
@@ -41,4 +45,9 @@ export class UpsertWorkingHoursDto {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkingHoursBlockDto)
|
||||
blocks: WorkingHoursBlockDto[];
|
||||
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
@Matches(/^[A-Za-z0-9_+\-/]+$/, { message: ErrorCode.VALIDATION_TIMEZONE_INVALID })
|
||||
timeZone: string;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import {
|
||||
appointmentWithinWorkingHours,
|
||||
@@ -12,10 +7,12 @@ import {
|
||||
validateWorkingHoursBlocks,
|
||||
type WorkingHoursBlockInput,
|
||||
} from '../../common/working-hours';
|
||||
import { isValidIanaTimeZone, zonedWeekdayAndMinutes } from '../../common/zoned-civil-time';
|
||||
import { UpsertWorkingHoursDto } from './dto/upsert-working-hours.dto';
|
||||
import {
|
||||
hasEffectivePermission,
|
||||
} from '../../common/membership-permissions';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
|
||||
@Injectable()
|
||||
export class StaffWorkingHoursService {
|
||||
@@ -124,14 +121,16 @@ export class StaffWorkingHoursService {
|
||||
) {
|
||||
const validationError = validateWorkingHoursBlocks(dto.blocks);
|
||||
if (validationError) {
|
||||
throw new BadRequestException(validationError);
|
||||
throw new AppException(ErrorCode.WORKING_HOURS_INVALID, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const timeZone = this.requireTimeZone(dto.timeZone);
|
||||
const normalizedBlocks = this.normalizeBlocks(dto.blocks);
|
||||
await this.assertNoConflictingAppointments(
|
||||
organizationId,
|
||||
membership.userId,
|
||||
normalizedBlocks,
|
||||
timeZone,
|
||||
);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
@@ -207,10 +206,18 @@ export class StaffWorkingHoursService {
|
||||
}));
|
||||
}
|
||||
|
||||
private requireTimeZone(timeZone: string | undefined): string {
|
||||
if (!timeZone || !isValidIanaTimeZone(timeZone)) {
|
||||
throw new AppException(ErrorCode.VALIDATION_TIMEZONE_INVALID, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return timeZone;
|
||||
}
|
||||
|
||||
private async assertNoConflictingAppointments(
|
||||
organizationId: string,
|
||||
providerUserId: string,
|
||||
blocks: WorkingHoursBlockInput[],
|
||||
timeZone: string,
|
||||
) {
|
||||
const now = new Date();
|
||||
const appointments = await this.prisma.appointment.findMany({
|
||||
@@ -219,47 +226,30 @@ export class StaffWorkingHoursService {
|
||||
providerUserId,
|
||||
endAt: { gt: now },
|
||||
},
|
||||
include: {
|
||||
patient: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
select: { startAt: true, endAt: true },
|
||||
orderBy: { startAt: 'asc' },
|
||||
});
|
||||
|
||||
const conflicts = appointments.filter((appointment) => {
|
||||
const startAt = new Date(appointment.startAt);
|
||||
const endAt = new Date(appointment.endAt);
|
||||
const dayOfWeek = localDayOfWeekMondayZero(startAt.getDay());
|
||||
const dayOfWeek = localDayOfWeekMondayZero(
|
||||
zonedWeekdayAndMinutes(startAt, timeZone).jsWeekday,
|
||||
);
|
||||
const dayBlocks = blocksForDay(blocks, dayOfWeek);
|
||||
if (dayBlocks.length === 0) {
|
||||
return true;
|
||||
}
|
||||
return !appointmentWithinWorkingHours(startAt, endAt, dayBlocks);
|
||||
return !appointmentWithinWorkingHours(startAt, endAt, dayBlocks, timeZone);
|
||||
});
|
||||
|
||||
if (conflicts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const examples = conflicts.slice(0, 3).map((appointment) => {
|
||||
const startAt = new Date(appointment.startAt);
|
||||
const patientName = `${appointment.patient.firstName} ${appointment.patient.lastName}`;
|
||||
const when = startAt.toLocaleString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return `${patientName} (${when})`;
|
||||
});
|
||||
|
||||
const extra =
|
||||
conflicts.length > examples.length
|
||||
? ` and ${conflicts.length - examples.length} more`
|
||||
: '';
|
||||
|
||||
throw new BadRequestException(
|
||||
`Cannot save working hours: ${conflicts.length} upcoming appointment${conflicts.length === 1 ? '' : 's'} fall outside the new schedule (${examples.join(', ')}${extra}). Reschedule or remove those appointments first.`,
|
||||
throw new AppException(
|
||||
ErrorCode.WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS,
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -269,10 +259,10 @@ export class StaffWorkingHoursService {
|
||||
select: { id: true, isOwner: true, userId: true },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new NotFoundException('Member not found');
|
||||
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (membership.isOwner) {
|
||||
throw new BadRequestException('Working hours cannot be set for the organization owner');
|
||||
throw new AppException(ErrorCode.WORKING_HOURS_OWNER_NOT_ALLOWED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return membership;
|
||||
}
|
||||
@@ -286,7 +276,7 @@ export class StaffWorkingHoursService {
|
||||
},
|
||||
});
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('Only organization owners can manage their working hours');
|
||||
throw new AppException(ErrorCode.PERMISSION_OWNER_ONLY, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return membership;
|
||||
}
|
||||
@@ -297,23 +287,21 @@ export class StaffWorkingHoursService {
|
||||
organization: { type: { name: string }; plan?: { name: string } | null; planId?: string | null };
|
||||
}) {
|
||||
if (!hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')) {
|
||||
throw new ForbiddenException(
|
||||
'Enable treatment participation before setting working hours',
|
||||
);
|
||||
throw new AppException(ErrorCode.PERMISSION_ENABLE_PARTICIPATION_FIRST, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCanViewStaff(userId: string, organizationId: string) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
if (!actor || !this.canViewStaff(actor)) {
|
||||
throw new ForbiddenException('You do not have access to staff management');
|
||||
throw new AppException(ErrorCode.PERMISSION_ACCESS_STAFF, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCanEditStaff(userId: string, organizationId: string) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
if (!actor || !this.canEditStaff(actor)) {
|
||||
throw new ForbiddenException('You cannot manage staff working hours');
|
||||
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { Prisma, UserNotificationType } from '@prisma/client';
|
||||
@@ -28,7 +26,7 @@ export class StaffService {
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return user.organizationId;
|
||||
}
|
||||
@@ -36,7 +34,7 @@ export class StaffService {
|
||||
async list(userId: string, organizationId: string) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
if (!actor || !this.canViewStaff(actor)) {
|
||||
throw new ForbiddenException('You do not have access to staff management');
|
||||
throw new AppException(ErrorCode.PERMISSION_ACCESS_STAFF, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const org = await this.prisma.organization.findUnique({
|
||||
@@ -44,7 +42,7 @@ export class StaffService {
|
||||
include: { plan: true },
|
||||
});
|
||||
if (!org) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
const [members, seatsUsed] = await Promise.all([
|
||||
@@ -100,7 +98,7 @@ export class StaffService {
|
||||
async invite(userId: string, organizationId: string, dto: InviteStaffDto) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
if (!actor || !this.canEditStaff(actor)) {
|
||||
throw new ForbiddenException('You cannot invite or manage staff');
|
||||
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const email = dto.email.trim().toLowerCase();
|
||||
@@ -114,7 +112,7 @@ export class StaffService {
|
||||
if (permissionRows.length !== normalizedPerms.length) {
|
||||
const ok = new Set(permissionRows.map((p) => p.name));
|
||||
const missing = normalizedPerms.filter((n) => !ok.has(n));
|
||||
throw new BadRequestException(`Unknown or invalid permissions: ${missing.join(', ')}`);
|
||||
throw new AppException(ErrorCode.STAFF_UNKNOWN_PERMISSIONS, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const plainToken = this.generateInviteToken();
|
||||
@@ -126,13 +124,11 @@ export class StaffService {
|
||||
include: { plan: true },
|
||||
});
|
||||
if (!org) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (!org.plan) {
|
||||
throw new BadRequestException(
|
||||
'This organization has no active subscription. Please choose a plan before inviting staff.',
|
||||
);
|
||||
throw new AppException(ErrorCode.STAFF_NO_SUBSCRIPTION, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const maxUsers = org.plan.maxUsers;
|
||||
@@ -143,9 +139,7 @@ export class StaffService {
|
||||
},
|
||||
});
|
||||
if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) {
|
||||
throw new BadRequestException(
|
||||
`Your plan allows ${maxUsers} team members. Remove a member or upgrade to add more.`,
|
||||
);
|
||||
throw new AppException(ErrorCode.STAFF_SEAT_LIMIT, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const existingUser = await tx.user.findUnique({ where: { email } });
|
||||
@@ -153,7 +147,7 @@ export class StaffService {
|
||||
|
||||
if (existingUser) {
|
||||
if (existingUser.id === org.ownerId) {
|
||||
throw new BadRequestException('Organization owner is already a member');
|
||||
throw new AppException(ErrorCode.STAFF_INVITE_OWNER_EMAIL, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
const dup = await tx.membership.findUnique({
|
||||
where: {
|
||||
@@ -164,7 +158,7 @@ export class StaffService {
|
||||
},
|
||||
});
|
||||
if (dup) {
|
||||
throw new ConflictException('This user is already a member of this organization');
|
||||
throw new AppException(ErrorCode.STAFF_ALREADY_MEMBER, HttpStatus.CONFLICT);
|
||||
}
|
||||
targetUserId = existingUser.id;
|
||||
} else {
|
||||
@@ -250,7 +244,7 @@ export class StaffService {
|
||||
async getInvitationLink(userId: string, organizationId: string, membershipId: string) {
|
||||
const actor = await this.getActorMembership(userId, organizationId);
|
||||
if (!actor || !this.canEditStaff(actor)) {
|
||||
throw new ForbiddenException('You cannot invite or manage staff');
|
||||
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
@@ -262,24 +256,24 @@ export class StaffService {
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new NotFoundException('Member not found');
|
||||
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (membership.isOwner) {
|
||||
throw new BadRequestException('Owner does not use an invitation link');
|
||||
throw new AppException(ErrorCode.STAFF_OWNER_NO_INVITE_LINK, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (membership.isActive) {
|
||||
throw new BadRequestException('This member has already accepted their invitation');
|
||||
throw new AppException(ErrorCode.STAFF_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const invitation = membership.invitations[0];
|
||||
if (!invitation) {
|
||||
throw new BadRequestException('No invitation found for this member');
|
||||
throw new AppException(ErrorCode.STAFF_INVITE_MISSING, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (invitation.acceptedAt) {
|
||||
throw new BadRequestException('This invitation has already been accepted');
|
||||
throw new AppException(ErrorCode.STAFF_INVITE_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (invitation.revokedAt) {
|
||||
throw new BadRequestException('This invitation is no longer valid');
|
||||
throw new AppException(ErrorCode.STAFF_INVITE_INVALID, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const plainToken = this.generateInviteToken();
|
||||
@@ -324,7 +318,7 @@ export class StaffService {
|
||||
const invitation = await this.findValidInvitation(dto.token);
|
||||
|
||||
if (invitation.acceptedAt) {
|
||||
throw new BadRequestException('This invitation has already been accepted');
|
||||
throw new AppException(ErrorCode.STAFF_INVITE_ALREADY_ACCEPTED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, 10);
|
||||
@@ -367,7 +361,7 @@ export class StaffService {
|
||||
) {
|
||||
const actor = await this.getActorMembership(actorUserId, organizationId);
|
||||
if (!actor || !this.canEditStaff(actor)) {
|
||||
throw new ForbiddenException('You cannot edit staff');
|
||||
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const target = await this.prisma.membership.findFirst({
|
||||
@@ -379,10 +373,10 @@ export class StaffService {
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
throw new NotFoundException('Member not found');
|
||||
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (target.isOwner) {
|
||||
throw new ForbiddenException('Owner membership cannot be edited here');
|
||||
throw new AppException(ErrorCode.STAFF_CANNOT_EDIT_OWNER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
@@ -402,7 +396,7 @@ export class StaffService {
|
||||
if (permissionRows.length !== normalizedPerms.length) {
|
||||
const ok = new Set(permissionRows.map((p) => p.name));
|
||||
const missing = normalizedPerms.filter((n) => !ok.has(n));
|
||||
throw new BadRequestException(`Unknown or invalid permissions: ${missing.join(', ')}`);
|
||||
throw new AppException(ErrorCode.STAFF_UNKNOWN_PERMISSIONS, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
@@ -426,7 +420,7 @@ export class StaffService {
|
||||
async enableMember(actorUserId: string, organizationId: string, membershipId: string) {
|
||||
const actor = await this.getActorMembership(actorUserId, organizationId);
|
||||
if (!actor || !this.canEditStaff(actor)) {
|
||||
throw new ForbiddenException('You cannot manage staff');
|
||||
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const target = await this.prisma.membership.findFirst({
|
||||
@@ -437,20 +431,18 @@ export class StaffService {
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
throw new NotFoundException('Member not found');
|
||||
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (target.isOwner) {
|
||||
throw new ForbiddenException('Cannot enable the organization owner');
|
||||
throw new AppException(ErrorCode.STAFF_CANNOT_ENABLE_OWNER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (target.isActive) {
|
||||
throw new BadRequestException('This member is already active');
|
||||
throw new AppException(ErrorCode.STAFF_ALREADY_ACTIVE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const invitation = target.invitations[0];
|
||||
if (invitation && !invitation.acceptedAt) {
|
||||
throw new BadRequestException(
|
||||
'This member has not completed their invitation yet. Share the invite link instead.',
|
||||
);
|
||||
throw new AppException(ErrorCode.STAFF_INVITE_MISSING, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
@@ -470,7 +462,7 @@ export class StaffService {
|
||||
async disableMember(actorUserId: string, organizationId: string, membershipId: string) {
|
||||
const actor = await this.getActorMembership(actorUserId, organizationId);
|
||||
if (!actor || !this.canEditStaff(actor)) {
|
||||
throw new ForbiddenException('You cannot manage staff');
|
||||
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const target = await this.prisma.membership.findFirst({
|
||||
@@ -478,16 +470,16 @@ export class StaffService {
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
throw new NotFoundException('Member not found');
|
||||
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (target.isOwner) {
|
||||
throw new ForbiddenException('Cannot disable the organization owner');
|
||||
throw new AppException(ErrorCode.STAFF_CANNOT_DISABLE_OWNER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (actorUserId === target.userId) {
|
||||
throw new BadRequestException('You cannot disable your own access');
|
||||
throw new AppException(ErrorCode.STAFF_CANNOT_DISABLE_SELF, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (!target.isActive) {
|
||||
throw new BadRequestException('This member is already disabled or pending activation');
|
||||
throw new AppException(ErrorCode.STAFF_ALREADY_DISABLED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
await this.prisma.membership.update({
|
||||
@@ -508,7 +500,7 @@ export class StaffService {
|
||||
async removeMember(actorUserId: string, organizationId: string, membershipId: string) {
|
||||
const actor = await this.getActorMembership(actorUserId, organizationId);
|
||||
if (!actor || !this.canEditStaff(actor)) {
|
||||
throw new ForbiddenException('You cannot remove staff');
|
||||
throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const target = await this.prisma.membership.findFirst({
|
||||
@@ -516,10 +508,10 @@ export class StaffService {
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
throw new NotFoundException('Member not found');
|
||||
throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (target.isOwner) {
|
||||
throw new ForbiddenException('Cannot remove the organization owner');
|
||||
throw new AppException(ErrorCode.STAFF_CANNOT_REMOVE_OWNER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
await this.prisma.membership.delete({ where: { id: membershipId } });
|
||||
@@ -536,12 +528,10 @@ export class StaffService {
|
||||
include: { plan: true },
|
||||
});
|
||||
if (!org) {
|
||||
throw new NotFoundException('Organization not found');
|
||||
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (!org.plan) {
|
||||
throw new BadRequestException(
|
||||
'This organization has no active subscription. Please choose a plan before adding staff.',
|
||||
);
|
||||
throw new AppException(ErrorCode.STAFF_NO_SUBSCRIPTION, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const maxUsers = org.plan.maxUsers;
|
||||
@@ -552,9 +542,7 @@ export class StaffService {
|
||||
},
|
||||
});
|
||||
if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) {
|
||||
throw new BadRequestException(
|
||||
`Your plan allows ${maxUsers} team members. Free a seat by disabling another member or upgrade your plan.`,
|
||||
);
|
||||
throw new AppException(ErrorCode.STAFF_SEAT_LIMIT, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,13 +603,13 @@ export class StaffService {
|
||||
});
|
||||
|
||||
if (!invitation) {
|
||||
throw new NotFoundException('Invitation not found');
|
||||
throw new AppException(ErrorCode.STAFF_INVITE_MISSING, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (invitation.revokedAt) {
|
||||
throw new BadRequestException('Invitation has been revoked');
|
||||
throw new AppException(ErrorCode.STAFF_INVITE_REVOKED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (invitation.expiresAt.getTime() <= Date.now()) {
|
||||
throw new BadRequestException('Invitation has expired');
|
||||
throw new AppException(ErrorCode.STAFF_INVITE_EXPIRED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return invitation;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { normalizeMobile } from '../../common/phone';
|
||||
@@ -48,7 +47,7 @@ export class TasksService {
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return user.organizationId;
|
||||
}
|
||||
@@ -118,7 +117,7 @@ export class TasksService {
|
||||
});
|
||||
|
||||
if (!labCase) {
|
||||
throw new NotFoundException('Case not found');
|
||||
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return this.listTasksForLabCaseId(labCaseId, localeInput);
|
||||
@@ -176,7 +175,7 @@ export class TasksService {
|
||||
});
|
||||
|
||||
if (!target?.labCase.sentAt) {
|
||||
throw new NotFoundException('Task not found');
|
||||
throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
const where = await this.buildListWhere(labOrganizationId, actorUserId, listQuery);
|
||||
@@ -235,11 +234,11 @@ export class TasksService {
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException('Task not found');
|
||||
throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (task.assigneeUserId && task.assigneeUserId !== actorUserId) {
|
||||
throw new ForbiddenException('This task is assigned to another staff member');
|
||||
throw new AppException(ErrorCode.TASK_ASSIGNED_TO_OTHER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
@@ -419,14 +418,14 @@ export class TasksService {
|
||||
if (query.sentFrom) {
|
||||
const from = new Date(query.sentFrom);
|
||||
if (Number.isNaN(from.getTime())) {
|
||||
throw new BadRequestException('Invalid sentFrom date');
|
||||
throw new AppException(ErrorCode.INVALID_SENT_FROM, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
sentAtFilter.gte = from;
|
||||
}
|
||||
if (query.sentTo) {
|
||||
const to = new Date(query.sentTo);
|
||||
if (Number.isNaN(to.getTime())) {
|
||||
throw new BadRequestException('Invalid sentTo date');
|
||||
throw new AppException(ErrorCode.INVALID_SENT_TO, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
to.setHours(23, 59, 59, 999);
|
||||
sentAtFilter.lte = to;
|
||||
@@ -540,7 +539,7 @@ export class TasksService {
|
||||
const dir = query.sortDir ?? 'desc';
|
||||
|
||||
if (sortBy !== 'date') {
|
||||
throw new BadRequestException('Task page location is only supported for date sort');
|
||||
throw new AppException(ErrorCode.TASK_PAGE_DATE_SORT_ONLY, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const sentAt = target.sentAt;
|
||||
@@ -732,7 +731,7 @@ export class TasksService {
|
||||
private async assertCanReadTasks(userId: string, organizationId: string) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (
|
||||
hasEffectivePermission(m, 'TAB_TASKS_READ') ||
|
||||
@@ -740,18 +739,18 @@ export class TasksService {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You do not have access to tasks');
|
||||
throw new AppException(ErrorCode.PERMISSION_ACCESS_TASKS, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
private async assertCanEditTasks(userId: string, organizationId: string) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (hasEffectivePermission(m, 'TAB_TASKS_EDIT')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You cannot update tasks');
|
||||
throw new AppException(ErrorCode.PERMISSION_EDIT_TASKS, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
private async getMembership(userId: string, organizationId: string) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import { LabTaskStatus, LinkStatus, CatalogEntityKind } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import {
|
||||
@@ -107,7 +107,7 @@ export class TodayService {
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return user.organizationId;
|
||||
}
|
||||
@@ -428,10 +428,10 @@ export class TodayService {
|
||||
const from = new Date(query.from);
|
||||
const to = new Date(query.to);
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
|
||||
throw new BadRequestException('Invalid date range');
|
||||
throw new AppException(ErrorCode.TODAY_INVALID_RANGE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (to <= from) {
|
||||
throw new BadRequestException('Range "to" must be after "from"');
|
||||
throw new AppException(ErrorCode.TODAY_INVALID_RANGE_ORDER, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return { from, to };
|
||||
}
|
||||
@@ -1324,7 +1324,7 @@ export class TodayService {
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
return membership;
|
||||
@@ -1345,7 +1345,7 @@ export class TodayService {
|
||||
private assertCanViewToday(isOwner: boolean, permissionNames: string[]) {
|
||||
if (isOwner) return;
|
||||
if (!permissionNames.includes('TAB_TODAY_READ')) {
|
||||
throw new ForbiddenException('You do not have access to Today');
|
||||
throw new AppException(ErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { HttpStatus, Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import { CatalogEntityKind } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import {
|
||||
@@ -101,7 +101,7 @@ export class TreatmentCatalogService implements OnModuleInit {
|
||||
assertKnownTreatmentType(code: string): TreatmentTypeCatalogEntry {
|
||||
const entry = this.getByCode(code);
|
||||
if (!entry) {
|
||||
throw new BadRequestException(`Unknown treatment type: ${code}`);
|
||||
throw new AppException(ErrorCode.CATALOG_UNKNOWN_TREATMENT_TYPE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
@@ -109,9 +109,7 @@ export class TreatmentCatalogService implements OnModuleInit {
|
||||
assertLabDependentTreatmentType(code: string): TreatmentTypeCatalogEntry {
|
||||
const entry = this.assertKnownTreatmentType(code);
|
||||
if (!entry.labDependent) {
|
||||
throw new BadRequestException(
|
||||
`Treatment type "${code}" is completed in the clinic and cannot be sent to a lab`,
|
||||
);
|
||||
throw new AppException(ErrorCode.CATALOG_TREATMENT_NOT_LAB_DEPENDENT, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AppException } from '../../common/errors';
|
||||
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
|
||||
|
||||
describe('assertCompleteToothProsthesisMap', () => {
|
||||
@@ -47,6 +48,6 @@ describe('assertCompleteToothProsthesisMap', () => {
|
||||
{ treatmentDetailId: prosthesisDetailId, tooth: '14', prosthesisTypeCode: 'pfm_crown' },
|
||||
],
|
||||
}),
|
||||
).toThrow('missing tooth 15');
|
||||
).toThrow(AppException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import { normalizeTeeth } from './treatment.utils';
|
||||
|
||||
export type LabCaseProsthesisLink = {
|
||||
@@ -28,9 +29,7 @@ export function assertCompleteToothProsthesisMap(labCase: {
|
||||
for (const tooth of teeth) {
|
||||
const key = `${link.treatmentDetailId}:${tooth}`;
|
||||
if (!prosthesisByKey.has(key)) {
|
||||
throw new BadRequestException(
|
||||
`Each tooth must have a prosthesis type before sending (missing tooth ${tooth})`,
|
||||
);
|
||||
throw new AppException(ErrorCode.TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma, UserNotificationType } from '@prisma/client';
|
||||
@@ -136,7 +133,7 @@ export class TreatmentsService {
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
return user.organizationId;
|
||||
}
|
||||
@@ -561,13 +558,13 @@ export class TreatmentsService {
|
||||
});
|
||||
|
||||
if (!treatment) {
|
||||
throw new NotFoundException('Save treatment details before creating lab cases');
|
||||
throw new AppException(ErrorCode.TREATMENT_SAVE_DETAILS_BEFORE_LAB, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
const detailIds = dto.labCases.map((lc) => lc.treatmentDetailId);
|
||||
const uniqueDetailIds = new Set(detailIds);
|
||||
if (uniqueDetailIds.size !== detailIds.length) {
|
||||
throw new BadRequestException('Each treatment detail can belong to only one lab case');
|
||||
throw new AppException(ErrorCode.TREATMENT_DETAIL_ONE_CASE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const details = await this.prisma.treatmentDetail.findMany({
|
||||
@@ -575,7 +572,7 @@ export class TreatmentsService {
|
||||
select: { id: true, treatmentType: true, teeth: true },
|
||||
});
|
||||
if (details.length !== uniqueDetailIds.size) {
|
||||
throw new BadRequestException('One or more treatment details were not found');
|
||||
throw new AppException(ErrorCode.TREATMENT_DETAILS_NOT_FOUND, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
for (const detail of details) {
|
||||
@@ -587,22 +584,20 @@ export class TreatmentsService {
|
||||
|
||||
for (const lc of dto.labCases) {
|
||||
if (lc.destinationOrganizationId && !linkedOrgIds.has(lc.destinationOrganizationId)) {
|
||||
throw new BadRequestException('Destination organization is not an active linked counterpart');
|
||||
throw new AppException(ErrorCode.TREATMENT_DEST_NOT_LINKED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
for (const row of lc.toothProsthesis ?? []) {
|
||||
if (lc.treatmentDetailId !== row.treatmentDetailId) {
|
||||
throw new BadRequestException(
|
||||
'Tooth prosthesis must reference the lab case treatment detail',
|
||||
);
|
||||
throw new AppException(ErrorCode.TREATMENT_TOOTH_UNKNOWN_DETAIL, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
const detail = detailById.get(row.treatmentDetailId);
|
||||
if (!detail) {
|
||||
throw new BadRequestException('Tooth prosthesis references an unknown treatment detail');
|
||||
throw new AppException(ErrorCode.TREATMENT_TOOTH_UNKNOWN_DETAIL, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
const teeth = normalizeTeeth(detail.teeth);
|
||||
if (!teeth.includes(row.tooth)) {
|
||||
throw new BadRequestException(`Tooth ${row.tooth} is not on the selected treatment detail`);
|
||||
throw new AppException(ErrorCode.TREATMENT_TOOTH_NOT_ON_DETAIL, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
this.prosthesisCatalog.assertKnownProsthesisType(row.prosthesisTypeCode);
|
||||
}
|
||||
@@ -641,8 +636,14 @@ export class TreatmentsService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dueDate =
|
||||
lc.dueDate !== undefined ? parseDueDateInput(lc.dueDate) : undefined;
|
||||
let dueDate: Date | null | undefined;
|
||||
if (lc.dueDate !== undefined) {
|
||||
try {
|
||||
dueDate = parseDueDateInput(lc.dueDate);
|
||||
} catch {
|
||||
throw new AppException(ErrorCode.TREATMENT_INVALID_DUE_DATE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
const row = lc.id
|
||||
? await tx.labCase.update({
|
||||
@@ -696,9 +697,7 @@ export class TreatmentsService {
|
||||
select: { id: true },
|
||||
});
|
||||
if (validAttachments.length !== attachmentIds.length) {
|
||||
throw new BadRequestException(
|
||||
'One or more attachments are invalid for this lab case',
|
||||
);
|
||||
throw new AppException(ErrorCode.TREATMENT_CASE_INVALID_ATTACHMENTS, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
await tx.labCaseAttachment.createMany({
|
||||
data: attachmentIds.map((attachmentId) => ({
|
||||
@@ -749,37 +748,37 @@ export class TreatmentsService {
|
||||
});
|
||||
|
||||
if (!labCase) {
|
||||
throw new NotFoundException('Lab case not found');
|
||||
throw new AppException(ErrorCode.LAB_CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (!labCase.destinationOrganizationId) {
|
||||
throw new BadRequestException('Lab case has no destination organization');
|
||||
throw new AppException(ErrorCode.TREATMENT_CASE_NO_DEST, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (labCase.details.length === 0) {
|
||||
throw new BadRequestException('Lab case must include a treatment detail');
|
||||
throw new AppException(ErrorCode.TREATMENT_CASE_NEEDS_DETAIL, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (labCase.details.length > 1) {
|
||||
throw new BadRequestException('Lab case can include only one treatment detail');
|
||||
throw new AppException(ErrorCode.TREATMENT_CASE_ONE_DETAIL, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
assertCompleteToothProsthesisMap(labCase);
|
||||
|
||||
if (!isActorTreatmentProvider(labCase.treatment, actorUserId)) {
|
||||
throw new ForbiddenException('Only the treatment provider can send this lab case');
|
||||
throw new AppException(ErrorCode.TREATMENT_ONLY_PROVIDER_SEND, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
const linkedOrgIds = await this.getActiveLinkedOrganizationIds(organizationId);
|
||||
if (!linkedOrgIds.has(labCase.destinationOrganizationId)) {
|
||||
throw new BadRequestException('Destination organization is not an active linked counterpart');
|
||||
throw new AppException(ErrorCode.TREATMENT_DEST_NOT_LINKED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const alreadySent = labCase.sends.some(
|
||||
(s) => s.organizationId === labCase.destinationOrganizationId,
|
||||
);
|
||||
if (alreadySent) {
|
||||
throw new BadRequestException('Lab case was already sent to the destination organization');
|
||||
throw new AppException(ErrorCode.TREATMENT_CASE_ALREADY_SENT, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
@@ -922,18 +921,18 @@ export class TreatmentsService {
|
||||
});
|
||||
|
||||
if (!labCase) {
|
||||
throw new NotFoundException('Lab case not found');
|
||||
throw new AppException(ErrorCode.LAB_CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (isLabCaseFullyCompleted(labCase.tasks)) {
|
||||
throw new BadRequestException('Due date cannot be changed after all tasks are completed');
|
||||
throw new AppException(ErrorCode.TREATMENT_DUE_DATE_LOCKED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
let dueDate: Date | null;
|
||||
try {
|
||||
dueDate = parseDueDateInput(dueDateInput ?? null);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid due date');
|
||||
throw new AppException(ErrorCode.TREATMENT_INVALID_DUE_DATE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
await tx.labCase.update({
|
||||
@@ -953,11 +952,11 @@ export class TreatmentsService {
|
||||
await this.ensureAppointmentProvider(appointmentId, organizationId, actorUserId);
|
||||
|
||||
if (!detailClientKey?.trim()) {
|
||||
throw new BadRequestException('detailClientKey is required');
|
||||
throw new AppException(ErrorCode.TREATMENT_DETAIL_KEY_REQUIRED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (!files?.length) {
|
||||
throw new BadRequestException('At least one file is required');
|
||||
throw new AppException(ErrorCode.TREATMENT_FILE_REQUIRED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const existingDetail = await this.prisma.treatmentDetail.findFirst({
|
||||
@@ -1040,11 +1039,11 @@ export class TreatmentsService {
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
throw new NotFoundException('Attachment not found');
|
||||
throw new AppException(ErrorCode.TREATMENT_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (attachment.detail && attachment.detail.treatment.organizationId !== organizationId) {
|
||||
throw new NotFoundException('Attachment not found');
|
||||
throw new AppException(ErrorCode.TREATMENT_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (!attachment.detail && attachment.appointmentId) {
|
||||
@@ -1057,12 +1056,12 @@ export class TreatmentsService {
|
||||
select: { id: true },
|
||||
});
|
||||
if (!appointment) {
|
||||
throw new NotFoundException('Attachment not found');
|
||||
throw new AppException(ErrorCode.TREATMENT_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(attachment.storagePath)) {
|
||||
throw new NotFoundException('File is no longer available');
|
||||
throw new AppException(ErrorCode.TREATMENT_FILE_UNAVAILABLE, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1316,7 +1315,7 @@ export class TreatmentsService {
|
||||
select: { id: true },
|
||||
});
|
||||
if (!patient) {
|
||||
throw new NotFoundException('Patient not found');
|
||||
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1336,11 +1335,11 @@ export class TreatmentsService {
|
||||
});
|
||||
|
||||
if (!appointment) {
|
||||
throw new NotFoundException('Appointment not found');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
if (appointment.providerUserId !== actorUserId) {
|
||||
throw new ForbiddenException('You are not the provider for this appointment');
|
||||
throw new AppException(ErrorCode.APPOINTMENT_NOT_PROVIDER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
return appointment;
|
||||
@@ -1349,7 +1348,7 @@ export class TreatmentsService {
|
||||
private async assertCanReadTreatment(userId: string, organizationId: string) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (
|
||||
hasEffectivePermission(m, 'TAB_TREATMENT_READ') ||
|
||||
@@ -1357,18 +1356,18 @@ export class TreatmentsService {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You do not have access to treatments');
|
||||
throw new AppException(ErrorCode.PERMISSION_ACCESS_TREATMENTS, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
private async assertCanEditTreatment(userId: string, organizationId: string) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
throw new ForbiddenException('You are not a member of this organization');
|
||||
throw new AppException(ErrorCode.PERMISSION_NOT_MEMBER, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
if (hasEffectivePermission(m, 'TAB_TREATMENT_EDIT')) {
|
||||
return;
|
||||
}
|
||||
throw new ForbiddenException('You cannot edit treatments');
|
||||
throw new AppException(ErrorCode.PERMISSION_EDIT_TREATMENTS, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
private async getMembership(userId: string, organizationId: string) {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "تا وقتی نوبتهای آینده دارید نمیتوانید مشارکت در درمان را متوقف کنید. ابتدا آنها را لغو یا واگذار کنید.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -20,6 +20,7 @@ import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesi
|
||||
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
|
||||
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
|
||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
|
||||
|
||||
interface LabCasesDispatchPanelProps {
|
||||
@@ -124,6 +125,7 @@ export function LabCasesDispatchPanel({
|
||||
onCommentError,
|
||||
}: LabCasesDispatchPanelProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const tErrors = useTranslations('errors');
|
||||
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
|
||||
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
|
||||
const [pendingComment, setPendingComment] = useState('');
|
||||
@@ -273,7 +275,7 @@ export function LabCasesDispatchPanel({
|
||||
taskProgress: response.data.taskProgress ?? activeLabCase.taskProgress,
|
||||
});
|
||||
} catch (error) {
|
||||
onCommentError?.(error instanceof Error ? error.message : t('dueDateUpdateError'));
|
||||
onCommentError?.(getUserFacingError(error, tErrors, t('dueDateUpdateError')));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
8
frontend/src/lib/i18n/clientTimeZone.ts
Normal file
8
frontend/src/lib/i18n/clientTimeZone.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** Browser IANA zone (e.g. Asia/Tehran). Used so the UTC server can interpret wall-clock hours. */
|
||||
export function getClientTimeZone(): string {
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
} catch {
|
||||
return 'UTC';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user