improvement/ux-overhaul up #61
17
backend/src/common/errors/app.exception.ts
Normal file
17
backend/src/common/errors/app.exception.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import type { ErrorCodeValue, ValidationErrorDetail } from './error-codes';
|
||||||
|
|
||||||
|
export interface AppErrorResponse {
|
||||||
|
code: ErrorCodeValue;
|
||||||
|
details?: ValidationErrorDetail[] | unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppException extends HttpException {
|
||||||
|
constructor(
|
||||||
|
code: ErrorCodeValue,
|
||||||
|
status: HttpStatus,
|
||||||
|
details?: ValidationErrorDetail[] | unknown,
|
||||||
|
) {
|
||||||
|
super({ code, details } satisfies AppErrorResponse, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
backend/src/common/errors/error-codes.ts
Normal file
73
backend/src/common/errors/error-codes.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/** Stable API error codes — frontend maps these to translated user messages. */
|
||||||
|
export const ErrorCode = {
|
||||||
|
// Auth
|
||||||
|
AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS',
|
||||||
|
AUTH_UNAUTHORIZED: 'AUTH_UNAUTHORIZED',
|
||||||
|
AUTH_REFRESH_TOKEN_NOT_FOUND: 'AUTH_REFRESH_TOKEN_NOT_FOUND',
|
||||||
|
AUTH_REFRESH_TOKEN_INVALID: 'AUTH_REFRESH_TOKEN_INVALID',
|
||||||
|
AUTH_SESSION_EXPIRED: 'AUTH_SESSION_EXPIRED',
|
||||||
|
AUTH_USER_NOT_FOUND: 'AUTH_USER_NOT_FOUND',
|
||||||
|
AUTH_ORG_NOT_SELECTED: 'AUTH_ORG_NOT_SELECTED',
|
||||||
|
AUTH_ORG_ACCESS_DENIED: 'AUTH_ORG_ACCESS_DENIED',
|
||||||
|
AUTH_INVITATION_PENDING: 'AUTH_INVITATION_PENDING',
|
||||||
|
AUTH_REGISTRATION_EMAIL_EXISTS: 'AUTH_REGISTRATION_EMAIL_EXISTS',
|
||||||
|
AUTH_REGISTRATION_MOBILE_EXISTS: 'AUTH_REGISTRATION_MOBILE_EXISTS',
|
||||||
|
AUTH_REGISTRATION_MOBILE_INVALID: 'AUTH_REGISTRATION_MOBILE_INVALID',
|
||||||
|
AUTH_REGISTRATION_FAILED: 'AUTH_REGISTRATION_FAILED',
|
||||||
|
AUTH_PASSWORD_INCORRECT: 'AUTH_PASSWORD_INCORRECT',
|
||||||
|
AUTH_PASSWORD_RESET_EXPIRED: 'AUTH_PASSWORD_RESET_EXPIRED',
|
||||||
|
AUTH_VERIFICATION_CODE_INVALID: 'AUTH_VERIFICATION_CODE_INVALID',
|
||||||
|
AUTH_VERIFICATION_RATE_LIMIT: 'AUTH_VERIFICATION_RATE_LIMIT',
|
||||||
|
AUTH_SELECT_ORG_FIRST: 'AUTH_SELECT_ORG_FIRST',
|
||||||
|
AUTH_CREATE_ORG_OWNER_ONLY: 'AUTH_CREATE_ORG_OWNER_ONLY',
|
||||||
|
AUTH_PASSWORD_CURRENT_REQUIRED: 'AUTH_PASSWORD_CURRENT_REQUIRED',
|
||||||
|
|
||||||
|
// Permission
|
||||||
|
PERMISSION_DENIED: 'PERMISSION_DENIED',
|
||||||
|
PERMISSION_ORG_MANAGE: 'PERMISSION_ORG_MANAGE',
|
||||||
|
PERMISSION_CLINIC_ONLY: 'PERMISSION_CLINIC_ONLY',
|
||||||
|
PERMISSION_LAB_ONLY: 'PERMISSION_LAB_ONLY',
|
||||||
|
PERMISSION_NOT_MEMBER: 'PERMISSION_NOT_MEMBER',
|
||||||
|
PERMISSION_OWNER_ONLY: 'PERMISSION_OWNER_ONLY',
|
||||||
|
PERMISSION_PARTICIPATION_SUBSCRIPTION: 'PERMISSION_PARTICIPATION_SUBSCRIPTION',
|
||||||
|
PERMISSION_ENABLE_PARTICIPATION_FIRST: 'PERMISSION_ENABLE_PARTICIPATION_FIRST',
|
||||||
|
PERMISSION_CLINIC_WORKING_HOURS: 'PERMISSION_CLINIC_WORKING_HOURS',
|
||||||
|
PERMISSION_ACCESS_APPOINTMENTS: 'PERMISSION_ACCESS_APPOINTMENTS',
|
||||||
|
PERMISSION_EDIT_APPOINTMENTS: 'PERMISSION_EDIT_APPOINTMENTS',
|
||||||
|
PERMISSION_ACCESS_TREATMENTS: 'PERMISSION_ACCESS_TREATMENTS',
|
||||||
|
PERMISSION_EDIT_TREATMENTS: 'PERMISSION_EDIT_TREATMENTS',
|
||||||
|
PERMISSION_ACCESS_TASKS: 'PERMISSION_ACCESS_TASKS',
|
||||||
|
PERMISSION_EDIT_TASKS: 'PERMISSION_EDIT_TASKS',
|
||||||
|
PERMISSION_ACCESS_CASES: 'PERMISSION_ACCESS_CASES',
|
||||||
|
PERMISSION_ACCESS_STAFF: 'PERMISSION_ACCESS_STAFF',
|
||||||
|
PERMISSION_EDIT_STAFF: 'PERMISSION_EDIT_STAFF',
|
||||||
|
PERMISSION_ORG_NOT_FOUND: 'PERMISSION_ORG_NOT_FOUND',
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
VALIDATION_FAILED: 'VALIDATION_FAILED',
|
||||||
|
VALIDATION_EMAIL_INVALID: 'VALIDATION_EMAIL_INVALID',
|
||||||
|
VALIDATION_PASSWORD_TOO_SHORT: 'VALIDATION_PASSWORD_TOO_SHORT',
|
||||||
|
VALIDATION_PASSWORD_REQUIRED: 'VALIDATION_PASSWORD_REQUIRED',
|
||||||
|
VALIDATION_MOBILE_INVALID: 'VALIDATION_MOBILE_INVALID',
|
||||||
|
VALIDATION_NAME_TOO_SHORT: 'VALIDATION_NAME_TOO_SHORT',
|
||||||
|
VALIDATION_ORGANIZATION_NAME_REQUIRED: 'VALIDATION_ORGANIZATION_NAME_REQUIRED',
|
||||||
|
VALIDATION_ORGANIZATION_TYPE_INVALID: 'VALIDATION_ORGANIZATION_TYPE_INVALID',
|
||||||
|
VALIDATION_TOKEN_REQUIRED: 'VALIDATION_TOKEN_REQUIRED',
|
||||||
|
VALIDATION_FIELD_REQUIRED: 'VALIDATION_FIELD_REQUIRED',
|
||||||
|
VALIDATION_LANGUAGE_INVALID: 'VALIDATION_LANGUAGE_INVALID',
|
||||||
|
VALIDATION_INVALID_REQUEST: 'VALIDATION_INVALID_REQUEST',
|
||||||
|
|
||||||
|
// Generic HTTP
|
||||||
|
NOT_FOUND: 'NOT_FOUND',
|
||||||
|
CONFLICT: 'CONFLICT',
|
||||||
|
CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS',
|
||||||
|
BAD_REQUEST: 'BAD_REQUEST',
|
||||||
|
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode];
|
||||||
|
|
||||||
|
export interface ValidationErrorDetail {
|
||||||
|
field: string;
|
||||||
|
code: ErrorCodeValue;
|
||||||
|
}
|
||||||
184
backend/src/common/errors/http-exception.filter.ts
Normal file
184
backend/src/common/errors/http-exception.filter.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import {
|
||||||
|
ArgumentsHost,
|
||||||
|
Catch,
|
||||||
|
ExceptionFilter,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { AppException, type AppErrorResponse } from './app.exception';
|
||||||
|
import { ErrorCode, type ErrorCodeValue } from './error-codes';
|
||||||
|
|
||||||
|
interface ClientErrorBody {
|
||||||
|
success: false;
|
||||||
|
error: {
|
||||||
|
code: ErrorCodeValue;
|
||||||
|
statusCode: number;
|
||||||
|
details?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_FALLBACK_CODES: Partial<Record<number, ErrorCodeValue>> = {
|
||||||
|
[HttpStatus.BAD_REQUEST]: ErrorCode.BAD_REQUEST,
|
||||||
|
[HttpStatus.UNAUTHORIZED]: ErrorCode.AUTH_UNAUTHORIZED,
|
||||||
|
[HttpStatus.FORBIDDEN]: ErrorCode.PERMISSION_DENIED,
|
||||||
|
[HttpStatus.NOT_FOUND]: ErrorCode.NOT_FOUND,
|
||||||
|
[HttpStatus.CONFLICT]: ErrorCode.CONFLICT,
|
||||||
|
[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);
|
||||||
|
|
||||||
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
|
||||||
|
const { statusCode, body } = this.normalizeException(exception);
|
||||||
|
|
||||||
|
if (statusCode >= 500) {
|
||||||
|
this.logger.error(
|
||||||
|
exception instanceof Error ? exception.stack : String(exception),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.status(statusCode).json(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeException(exception: unknown): {
|
||||||
|
statusCode: number;
|
||||||
|
body: ClientErrorBody;
|
||||||
|
} {
|
||||||
|
if (!(exception instanceof HttpException)) {
|
||||||
|
return {
|
||||||
|
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
body: this.buildBody(ErrorCode.INTERNAL_ERROR, HttpStatus.INTERNAL_SERVER_ERROR),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusCode = exception.getStatus();
|
||||||
|
const rawResponse = exception.getResponse();
|
||||||
|
|
||||||
|
if (exception instanceof AppException || this.isAppErrorResponse(rawResponse)) {
|
||||||
|
const appError = rawResponse as AppErrorResponse;
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
body: this.buildBody(appError.code, statusCode, appError.details),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = this.resolveLegacyCode(rawResponse, statusCode);
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
body: this.buildBody(code, statusCode),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAppErrorResponse(value: unknown): value is AppErrorResponse {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'code' in value &&
|
||||||
|
typeof (value as AppErrorResponse).code === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveLegacyCode(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractMessage(rawResponse: string | object): string | string[] | undefined {
|
||||||
|
if (typeof rawResponse === 'string') {
|
||||||
|
return rawResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawResponse === 'object' && rawResponse !== null && 'message' in rawResponse) {
|
||||||
|
const message = (rawResponse as { message?: string | string[] }).message;
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildBody(
|
||||||
|
code: ErrorCodeValue,
|
||||||
|
statusCode: number,
|
||||||
|
details?: unknown,
|
||||||
|
): ClientErrorBody {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code,
|
||||||
|
statusCode,
|
||||||
|
...(details !== undefined ? { details } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
4
backend/src/common/errors/index.ts
Normal file
4
backend/src/common/errors/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { ErrorCode, type ErrorCodeValue, type ValidationErrorDetail } from './error-codes';
|
||||||
|
export { AppException, type AppErrorResponse } from './app.exception';
|
||||||
|
export { HttpExceptionFilter } from './http-exception.filter';
|
||||||
|
export { validationExceptionFactory } from './validation-exception.factory';
|
||||||
73
backend/src/common/errors/validation-exception.factory.ts
Normal file
73
backend/src/common/errors/validation-exception.factory.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { BadRequestException, 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';
|
||||||
|
|
||||||
|
const KNOWN_VALIDATION_CODES = new Set<string>(Object.values(ErrorCode));
|
||||||
|
|
||||||
|
function isKnownValidationCode(value: string): value is ErrorCodeValue {
|
||||||
|
return KNOWN_VALIDATION_CODES.has(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function constraintToCode(constraintKey: string, message: string): ErrorCodeValue {
|
||||||
|
if (isKnownValidationCode(message)) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (constraintKey) {
|
||||||
|
case 'isEmail':
|
||||||
|
return ErrorCode.VALIDATION_EMAIL_INVALID;
|
||||||
|
case 'minLength':
|
||||||
|
return ErrorCode.VALIDATION_PASSWORD_TOO_SHORT;
|
||||||
|
case 'isEnum':
|
||||||
|
return ErrorCode.VALIDATION_ORGANIZATION_TYPE_INVALID;
|
||||||
|
case 'matches':
|
||||||
|
return ErrorCode.VALIDATION_MOBILE_INVALID;
|
||||||
|
case 'isIn':
|
||||||
|
return ErrorCode.VALIDATION_LANGUAGE_INVALID;
|
||||||
|
case 'whitelistValidation':
|
||||||
|
return ErrorCode.VALIDATION_INVALID_REQUEST;
|
||||||
|
default:
|
||||||
|
return ErrorCode.VALIDATION_FIELD_REQUIRED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenValidationErrors(
|
||||||
|
errors: ValidationError[],
|
||||||
|
parentPath = '',
|
||||||
|
): ValidationErrorDetail[] {
|
||||||
|
const details: ValidationErrorDetail[] = [];
|
||||||
|
|
||||||
|
for (const error of errors) {
|
||||||
|
const field = parentPath ? `${parentPath}.${error.property}` : error.property;
|
||||||
|
|
||||||
|
if (error.constraints) {
|
||||||
|
const [constraintKey, message] = Object.entries(error.constraints)[0];
|
||||||
|
details.push({
|
||||||
|
field,
|
||||||
|
code: constraintToCode(constraintKey, message),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.children?.length) {
|
||||||
|
details.push(...flattenValidationErrors(error.children, field));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validationExceptionFactory(errors: ValidationError[]): HttpException {
|
||||||
|
const details = flattenValidationErrors(errors);
|
||||||
|
|
||||||
|
if (details.length === 0) {
|
||||||
|
return new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,11 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
CanActivate,
|
CanActivate,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
|
HttpStatus,
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { assertClinicOrganization } from '../../common/organization-type';
|
import { assertClinicOrganization } from '../../common/organization-type';
|
||||||
|
import { AppException, ErrorCode } from '../errors';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ClinicOrgGuard implements CanActivate {
|
export class ClinicOrgGuard implements CanActivate {
|
||||||
@@ -16,7 +17,7 @@ export class ClinicOrgGuard implements CanActivate {
|
|||||||
const organizationId = request.user?.organizationId;
|
const organizationId = request.user?.organizationId;
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
throw new UnauthorizedException('Organization is not selected');
|
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
await assertClinicOrganization(this.prisma, organizationId);
|
await assertClinicOrganization(this.prisma, organizationId);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
CanActivate,
|
CanActivate,
|
||||||
ExecutionContext,
|
ExecutionContext,
|
||||||
|
HttpStatus,
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { assertLabOrganization } from '../../common/organization-type';
|
import { assertLabOrganization } from '../../common/organization-type';
|
||||||
|
import { AppException, ErrorCode } from '../errors';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LabOrgGuard implements CanActivate {
|
export class LabOrgGuard implements CanActivate {
|
||||||
@@ -16,7 +17,7 @@ export class LabOrgGuard implements CanActivate {
|
|||||||
const organizationId = request.user?.organizationId;
|
const organizationId = request.user?.organizationId;
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
throw new UnauthorizedException('Organization is not selected');
|
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
await assertLabOrganization(this.prisma, organizationId);
|
await assertLabOrganization(this.prisma, organizationId);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
import { HttpStatus } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { ALL_TAB_PERMISSIONS, normalizeTabPermissions } from './permissions';
|
import { ALL_TAB_PERMISSIONS, normalizeTabPermissions } from './permissions';
|
||||||
|
import { AppException, ErrorCode } from './errors';
|
||||||
|
|
||||||
export type OrganizationTypeName = 'CLINIC' | 'LAB';
|
export type OrganizationTypeName = 'CLINIC' | 'LAB';
|
||||||
|
|
||||||
@@ -82,12 +83,12 @@ export async function getOrganizationTypeName(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!org) {
|
if (!org) {
|
||||||
throw new NotFoundException('Organization not found');
|
throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = org.type.name;
|
const name = org.type.name;
|
||||||
if (name !== 'CLINIC' && name !== 'LAB') {
|
if (name !== 'CLINIC' && name !== 'LAB') {
|
||||||
throw new ForbiddenException('Unknown organization type');
|
throw new AppException(ErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
return name;
|
return name;
|
||||||
@@ -99,7 +100,7 @@ export async function assertClinicOrganization(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const type = await getOrganizationTypeName(prisma, organizationId);
|
const type = await getOrganizationTypeName(prisma, organizationId);
|
||||||
if (type !== 'CLINIC') {
|
if (type !== 'CLINIC') {
|
||||||
throw new ForbiddenException('This action is only available for clinic organizations');
|
throw new AppException(ErrorCode.PERMISSION_CLINIC_ONLY, HttpStatus.FORBIDDEN);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +110,6 @@ export async function assertLabOrganization(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const type = await getOrganizationTypeName(prisma, organizationId);
|
const type = await getOrganizationTypeName(prisma, organizationId);
|
||||||
if (type !== 'LAB') {
|
if (type !== 'LAB') {
|
||||||
throw new ForbiddenException('This action is only available for lab organizations');
|
throw new AppException(ErrorCode.PERMISSION_LAB_ONLY, HttpStatus.FORBIDDEN);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { AppModule } from './app.module';
|
|||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import cookieParser from 'cookie-parser'; // 👈 Change this line!
|
import cookieParser from 'cookie-parser'; // 👈 Change this line!
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
HttpExceptionFilter,
|
||||||
|
validationExceptionFactory,
|
||||||
|
} from './common/errors';
|
||||||
|
|
||||||
// At the VERY TOP of main.ts, before anything else
|
// At the VERY TOP of main.ts, before anything else
|
||||||
const originalConsoleLog = console.log;
|
const originalConsoleLog = console.log;
|
||||||
@@ -21,11 +25,14 @@ console.log = (...args) => {
|
|||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
|
|
||||||
// Global pipes
|
// Global pipes
|
||||||
app.useGlobalPipes(new ValidationPipe({
|
app.useGlobalPipes(new ValidationPipe({
|
||||||
whitelist: true,
|
whitelist: true,
|
||||||
forbidNonWhitelisted: true,
|
forbidNonWhitelisted: true,
|
||||||
transform: true,
|
transform: true,
|
||||||
|
exceptionFactory: validationExceptionFactory,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Cookie parser - this is correct for Express
|
// Cookie parser - this is correct for Express
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
Patch,
|
Patch,
|
||||||
Put,
|
Put,
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +27,7 @@ import {
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { AppException, ErrorCode } from '../../common/errors';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
import { CreateOrganizationDto } from './dto/create-organization.dto';
|
||||||
@@ -322,7 +322,7 @@ export class AuthController {
|
|||||||
const refreshToken = req?.cookies?.refreshToken;
|
const refreshToken = req?.cookies?.refreshToken;
|
||||||
|
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
throw new UnauthorizedException('Refresh token not found');
|
throw new AppException(ErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.authService.refreshToken(
|
const result = await this.authService.refreshToken(
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
// backend/src/modules/auth/auth.service.ts
|
// backend/src/modules/auth/auth.service.ts
|
||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
UnauthorizedException,
|
HttpStatus,
|
||||||
BadRequestException,
|
HttpException,
|
||||||
ConflictException,
|
|
||||||
ForbiddenException,
|
|
||||||
InternalServerErrorException
|
InternalServerErrorException
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
@@ -33,6 +31,7 @@ import {
|
|||||||
} from './dto/forgot-password.dto';
|
} from './dto/forgot-password.dto';
|
||||||
import { normalizeIranMobile } from '../../common/utils/mobile.util';
|
import { normalizeIranMobile } from '../../common/utils/mobile.util';
|
||||||
import { sessionExpiresAtFromNow } from '../../common/jwt-duration';
|
import { sessionExpiresAtFromNow } from '../../common/jwt-duration';
|
||||||
|
import { AppException, ErrorCode } from '../../common/errors';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
const FORGOT_PASSWORD_PURPOSE = 'forgot_password';
|
const FORGOT_PASSWORD_PURPOSE = 'forgot_password';
|
||||||
@@ -242,7 +241,7 @@ export class AuthService {
|
|||||||
const email = registerDto.email.trim().toLowerCase();
|
const email = registerDto.email.trim().toLowerCase();
|
||||||
|
|
||||||
if (!RegisterDto.isValidMobile(registerDto.mobile)) {
|
if (!RegisterDto.isValidMobile(registerDto.mobile)) {
|
||||||
throw new BadRequestException('Please enter a valid mobile number');
|
throw new AppException(ErrorCode.AUTH_REGISTRATION_MOBILE_INVALID, HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mobile = normalizeIranMobile(registerDto.mobile);
|
const mobile = normalizeIranMobile(registerDto.mobile);
|
||||||
@@ -256,9 +255,9 @@ export class AuthService {
|
|||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
if (existingUser.email === email) {
|
if (existingUser.email === email) {
|
||||||
throw new ConflictException('User already exists. Please login and create a new organization from your account.');
|
throw new AppException(ErrorCode.AUTH_REGISTRATION_EMAIL_EXISTS, HttpStatus.CONFLICT);
|
||||||
}
|
}
|
||||||
throw new ConflictException('This mobile number is already registered.');
|
throw new AppException(ErrorCode.AUTH_REGISTRATION_MOBILE_EXISTS, HttpStatus.CONFLICT);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Hash password
|
// 2. Hash password
|
||||||
@@ -315,7 +314,7 @@ export class AuthService {
|
|||||||
const validatedUser = await this.validateUser(email, password);
|
const validatedUser = await this.validateUser(email, password);
|
||||||
|
|
||||||
if (!validatedUser) {
|
if (!validatedUser) {
|
||||||
throw new UnauthorizedException('Auto-login failed');
|
throw new AppException(ErrorCode.AUTH_REGISTRATION_FAILED, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.login({ email, password } as any, validatedUser);
|
return this.login({ email, password } as any, validatedUser);
|
||||||
@@ -332,13 +331,11 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!owner) {
|
if (!owner) {
|
||||||
throw new UnauthorizedException('User not found');
|
throw new AppException(ErrorCode.AUTH_USER_NOT_FOUND, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentOrganizationId) {
|
if (!currentOrganizationId) {
|
||||||
throw new ForbiddenException(
|
throw new AppException(ErrorCode.AUTH_SELECT_ORG_FIRST, HttpStatus.FORBIDDEN);
|
||||||
'Select an organization before creating a new one.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentMembership = await this.prisma.membership.findUnique({
|
const currentMembership = await this.prisma.membership.findUnique({
|
||||||
@@ -352,9 +349,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!currentMembership?.isOwner) {
|
if (!currentMembership?.isOwner) {
|
||||||
throw new ForbiddenException(
|
throw new AppException(ErrorCode.AUTH_CREATE_ORG_OWNER_ONLY, HttpStatus.FORBIDDEN);
|
||||||
'Only owners of the current organization can create new organizations.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const organization = await this.prisma.$transaction(async (tx) => {
|
const organization = await this.prisma.$transaction(async (tx) => {
|
||||||
@@ -417,7 +412,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('User not found');
|
throw new AppException(ErrorCode.AUTH_USER_NOT_FOUND, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { passwordHash, ...result } = user;
|
const { passwordHash, ...result } = user;
|
||||||
@@ -485,7 +480,7 @@ export class AuthService {
|
|||||||
|
|
||||||
// Ensure this is a refresh token
|
// Ensure this is a refresh token
|
||||||
if (payload.type !== 'refresh') {
|
if (payload.type !== 'refresh') {
|
||||||
throw new UnauthorizedException('Invalid token type');
|
throw new AppException(ErrorCode.AUTH_SESSION_EXPIRED, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find session with this refresh token
|
// Find session with this refresh token
|
||||||
@@ -518,7 +513,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new UnauthorizedException('Invalid refresh token');
|
throw new AppException(ErrorCode.AUTH_REFRESH_TOKEN_INVALID, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
const organizationId = await this.resolveOrganizationIdForRefresh(
|
const organizationId = await this.resolveOrganizationIdForRefresh(
|
||||||
@@ -574,10 +569,13 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
if (error instanceof HttpException) {
|
||||||
throw new UnauthorizedException('Invalid or expired refresh token');
|
throw error;
|
||||||
}
|
}
|
||||||
throw new UnauthorizedException('Refresh token failed');
|
if (error instanceof Error && (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError')) {
|
||||||
|
throw new AppException(ErrorCode.AUTH_REFRESH_TOKEN_INVALID, HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
throw new AppException(ErrorCode.AUTH_REFRESH_TOKEN_INVALID, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,22 +599,22 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !user.passwordHash) {
|
if (!user || !user.passwordHash) {
|
||||||
throw new BadRequestException('User not found or invalid password method');
|
throw new AppException(ErrorCode.AUTH_USER_NOT_FOUND, HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (skipCurrentPassword) {
|
if (skipCurrentPassword) {
|
||||||
const hasRecentReset = await this.hasRecentPasswordResetVerification(userId);
|
const hasRecentReset = await this.hasRecentPasswordResetVerification(userId);
|
||||||
if (!hasRecentReset) {
|
if (!hasRecentReset) {
|
||||||
throw new UnauthorizedException('Password reset verification expired. Please verify your mobile again.');
|
throw new AppException(ErrorCode.AUTH_PASSWORD_RESET_EXPIRED, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!oldPassword) {
|
if (!oldPassword) {
|
||||||
throw new BadRequestException('Current password is required');
|
throw new AppException(ErrorCode.AUTH_PASSWORD_CURRENT_REQUIRED, HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPasswordValid = await bcrypt.compare(oldPassword, user.passwordHash);
|
const isPasswordValid = await bcrypt.compare(oldPassword, user.passwordHash);
|
||||||
if (!isPasswordValid) {
|
if (!isPasswordValid) {
|
||||||
throw new UnauthorizedException('Current password is incorrect');
|
throw new AppException(ErrorCode.AUTH_PASSWORD_INCORRECT, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,7 +634,7 @@ export class AuthService {
|
|||||||
message: 'Password changed successfully. Please login again.',
|
message: 'Password changed successfully. Please login again.',
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnauthorizedException || error instanceof BadRequestException) {
|
if (error instanceof HttpException) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
throw new InternalServerErrorException('Failed to change password');
|
throw new InternalServerErrorException('Failed to change password');
|
||||||
@@ -645,7 +643,7 @@ export class AuthService {
|
|||||||
|
|
||||||
async sendForgotPasswordCode(dto: ForgotPasswordSendCodeDto) {
|
async sendForgotPasswordCode(dto: ForgotPasswordSendCodeDto) {
|
||||||
if (!ForgotPasswordSendCodeDto.validateMobile(dto.mobile)) {
|
if (!ForgotPasswordSendCodeDto.validateMobile(dto.mobile)) {
|
||||||
throw new BadRequestException('Please enter a valid mobile number');
|
throw new AppException(ErrorCode.AUTH_REGISTRATION_MOBILE_INVALID, HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mobile = normalizeIranMobile(dto.mobile);
|
const mobile = normalizeIranMobile(dto.mobile);
|
||||||
@@ -671,7 +669,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (recentCode) {
|
if (recentCode) {
|
||||||
throw new BadRequestException('Please wait before requesting another code');
|
throw new AppException(ErrorCode.AUTH_VERIFICATION_RATE_LIMIT, HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = this.generateVerificationCode();
|
const code = this.generateVerificationCode();
|
||||||
@@ -701,7 +699,7 @@ export class AuthService {
|
|||||||
|
|
||||||
async verifyForgotPasswordCode(dto: ForgotPasswordVerifyDto) {
|
async verifyForgotPasswordCode(dto: ForgotPasswordVerifyDto) {
|
||||||
if (!ForgotPasswordSendCodeDto.validateMobile(dto.mobile)) {
|
if (!ForgotPasswordSendCodeDto.validateMobile(dto.mobile)) {
|
||||||
throw new BadRequestException('Please enter a valid mobile number');
|
throw new AppException(ErrorCode.AUTH_REGISTRATION_MOBILE_INVALID, HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mobile = normalizeIranMobile(dto.mobile);
|
const mobile = normalizeIranMobile(dto.mobile);
|
||||||
@@ -718,7 +716,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!verification || !this.isVerificationCodeValid(code, verification.codeHash)) {
|
if (!verification || !this.isVerificationCodeValid(code, verification.codeHash)) {
|
||||||
throw new UnauthorizedException('Invalid or expired verification code');
|
throw new AppException(ErrorCode.AUTH_VERIFICATION_CODE_INVALID, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.phoneVerificationCode.update({
|
await this.prisma.phoneVerificationCode.update({
|
||||||
@@ -748,7 +746,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('Invalid or expired verification code');
|
throw new AppException(ErrorCode.AUTH_VERIFICATION_CODE_INVALID, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginResult = await this.login(
|
const loginResult = await this.login(
|
||||||
@@ -877,7 +875,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (payload.type !== 'access') {
|
if (payload.type !== 'access') {
|
||||||
throw new UnauthorizedException('Invalid token type');
|
throw new AppException(ErrorCode.AUTH_SESSION_EXPIRED, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await this.prisma.session.findFirst({
|
const session = await this.prisma.session.findFirst({
|
||||||
@@ -909,7 +907,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new UnauthorizedException('Session not found or expired');
|
throw new AppException(ErrorCode.AUTH_SESSION_EXPIRED, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { passwordHash, ...user } = session.user;
|
const { passwordHash, ...user } = session.user;
|
||||||
@@ -937,7 +935,7 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new UnauthorizedException('Invalid token');
|
throw new AppException(ErrorCode.AUTH_SESSION_EXPIRED, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -965,10 +963,10 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
throw new UnauthorizedException('Access denied to this organization');
|
throw new AppException(ErrorCode.AUTH_ORG_ACCESS_DENIED, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
if (!membership.isOwner && !membership.isActive) {
|
if (!membership.isOwner && !membership.isActive) {
|
||||||
throw new UnauthorizedException('Your invitation is still pending activation');
|
throw new AppException(ErrorCode.AUTH_INVITATION_PENDING, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Build payload WITH org context
|
// 2. Build payload WITH org context
|
||||||
@@ -1147,7 +1145,7 @@ export class AuthService {
|
|||||||
const language = dto.language;
|
const language = dto.language;
|
||||||
|
|
||||||
if (!SUPPORTED_USER_LANGUAGES.includes(language)) {
|
if (!SUPPORTED_USER_LANGUAGES.includes(language)) {
|
||||||
throw new BadRequestException('Language must be one of: en, fa, nl');
|
throw new AppException(ErrorCode.VALIDATION_LANGUAGE_INVALID, HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.prisma.user.update({
|
const user = await this.prisma.user.update({
|
||||||
@@ -1231,7 +1229,7 @@ export class AuthService {
|
|||||||
|
|
||||||
private async getOwnerMembership(userId: string, organizationId: string) {
|
private async getOwnerMembership(userId: string, organizationId: string) {
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
throw new BadRequestException('Organization is not selected');
|
throw new AppException(ErrorCode.AUTH_ORG_NOT_SELECTED, HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
const membership = await this.prisma.membership.findFirst({
|
const membership = await this.prisma.membership.findFirst({
|
||||||
@@ -1245,7 +1243,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
throw new ForbiddenException('Only organization owners can manage participation');
|
throw new AppException(ErrorCode.PERMISSION_OWNER_ONLY, HttpStatus.FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
return membership;
|
return membership;
|
||||||
@@ -1281,9 +1279,7 @@ export class AuthService {
|
|||||||
const orgType = getOrgTypeFromMembership(membership);
|
const orgType = getOrgTypeFromMembership(membership);
|
||||||
|
|
||||||
if (!hasActivePlan(membership)) {
|
if (!hasActivePlan(membership)) {
|
||||||
throw new ForbiddenException(
|
throw new AppException(ErrorCode.PERMISSION_PARTICIPATION_SUBSCRIPTION, HttpStatus.FORBIDDEN);
|
||||||
'An active subscription is required to participate in treatments or tasks',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.participate) {
|
if (dto.participate) {
|
||||||
@@ -1321,7 +1317,7 @@ export class AuthService {
|
|||||||
async getMyWorkingHours(userId: string, organizationId: string) {
|
async getMyWorkingHours(userId: string, organizationId: string) {
|
||||||
const membership = await this.getOwnerMembership(userId, organizationId);
|
const membership = await this.getOwnerMembership(userId, organizationId);
|
||||||
if (getOrgTypeFromMembership(membership) !== 'CLINIC') {
|
if (getOrgTypeFromMembership(membership) !== 'CLINIC') {
|
||||||
throw new BadRequestException('Working hours are only available for clinic organizations');
|
throw new AppException(ErrorCode.PERMISSION_CLINIC_WORKING_HOURS, HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
return this.staffWorkingHoursService.getMyWorkingHours(userId, organizationId);
|
return this.staffWorkingHoursService.getMyWorkingHours(userId, organizationId);
|
||||||
}
|
}
|
||||||
@@ -1333,12 +1329,10 @@ export class AuthService {
|
|||||||
) {
|
) {
|
||||||
const membership = await this.getOwnerMembership(userId, organizationId);
|
const membership = await this.getOwnerMembership(userId, organizationId);
|
||||||
if (getOrgTypeFromMembership(membership) !== 'CLINIC') {
|
if (getOrgTypeFromMembership(membership) !== 'CLINIC') {
|
||||||
throw new BadRequestException('Working hours are only available for clinic organizations');
|
throw new AppException(ErrorCode.PERMISSION_CLINIC_WORKING_HOURS, HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
if (!participatesInTreatments(membership)) {
|
if (!participatesInTreatments(membership)) {
|
||||||
throw new ForbiddenException(
|
throw new AppException(ErrorCode.PERMISSION_ENABLE_PARTICIPATION_FIRST, HttpStatus.FORBIDDEN);
|
||||||
'Enable treatment participation before setting working hours',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return this.staffWorkingHoursService.upsertMyWorkingHours(userId, organizationId, dto);
|
return this.staffWorkingHoursService.upsertMyWorkingHours(userId, organizationId, dto);
|
||||||
}
|
}
|
||||||
@@ -1406,9 +1400,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (futureAppointment) {
|
if (futureAppointment) {
|
||||||
throw new ConflictException(
|
throw new AppException(ErrorCode.CONFLICT_FUTURE_APPOINTMENTS, HttpStatus.CONFLICT);
|
||||||
'You cannot stop participating in treatments while you have future appointments assigned. Reassign or cancel those appointments first.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await assertLabOrganization(this.prisma, membership.organizationId);
|
await assertLabOrganization(this.prisma, membership.organizationId);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IsOptional, IsString, MinLength } from 'class-validator';
|
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
import { ErrorCode } from '../../../common/errors';
|
||||||
|
|
||||||
export class ChangePasswordDto {
|
export class ChangePasswordDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -6,6 +7,6 @@ export class ChangePasswordDto {
|
|||||||
currentPassword?: string;
|
currentPassword?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(8)
|
@MinLength(8, { message: ErrorCode.VALIDATION_PASSWORD_TOO_SHORT })
|
||||||
newPassword: string;
|
newPassword: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { IsEmail, IsEnum, IsOptional, IsString } from 'class-validator';
|
import { IsEmail, IsEnum, IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
import { ErrorCode } from '../../../common/errors';
|
||||||
|
|
||||||
export class CreateOrganizationDto {
|
export class CreateOrganizationDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@MinLength(2, { message: ErrorCode.VALIDATION_ORGANIZATION_NAME_REQUIRED })
|
||||||
organizationName: string;
|
organizationName: string;
|
||||||
|
|
||||||
@IsEmail()
|
@IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID })
|
||||||
organizationEmail: string;
|
organizationEmail: string;
|
||||||
|
|
||||||
@IsEnum(['CLINIC', 'LAB'])
|
@IsEnum(['CLINIC', 'LAB'], { message: ErrorCode.VALIDATION_ORGANIZATION_TYPE_INVALID })
|
||||||
organizationType: 'CLINIC' | 'LAB';
|
organizationType: 'CLINIC' | 'LAB';
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { IsString, Matches, Length } from 'class-validator';
|
import { IsString, Matches, Length, MinLength } from 'class-validator';
|
||||||
import { isValidIranMobile } from '../../../common/utils/mobile.util';
|
import { isValidIranMobile } from '../../../common/utils/mobile.util';
|
||||||
|
import { ErrorCode } from '../../../common/errors';
|
||||||
|
|
||||||
export class ForgotPasswordSendCodeDto {
|
export class ForgotPasswordSendCodeDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^[\d+\s()-]+$/, { message: 'Mobile number format is invalid' })
|
@Matches(/^[\d+\s()-]+$/, { message: ErrorCode.VALIDATION_MOBILE_INVALID })
|
||||||
mobile: string;
|
mobile: string;
|
||||||
|
|
||||||
static validateMobile(mobile: string): boolean {
|
static validateMobile(mobile: string): boolean {
|
||||||
@@ -13,10 +14,10 @@ export class ForgotPasswordSendCodeDto {
|
|||||||
|
|
||||||
export class ForgotPasswordVerifyDto {
|
export class ForgotPasswordVerifyDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^[\d+\s()-]+$/, { message: 'Mobile number format is invalid' })
|
@Matches(/^[\d+\s()-]+$/, { message: ErrorCode.VALIDATION_MOBILE_INVALID })
|
||||||
mobile: string;
|
mobile: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@Length(5, 6)
|
@Length(5, 6, { message: ErrorCode.VALIDATION_FIELD_REQUIRED })
|
||||||
code: string;
|
code: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,14 @@
|
|||||||
// backend/src/modules/auth/dto/login.dto.ts
|
import { IsEmail, IsString, MinLength, IsOptional, IsBoolean } from 'class-validator';
|
||||||
import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
|
import { ErrorCode } from '../../../common/errors';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
export class LoginDto {
|
export class LoginDto {
|
||||||
@ApiProperty({
|
@IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID })
|
||||||
description: 'User email address',
|
|
||||||
example: 'user@example.com',
|
|
||||||
required: true,
|
|
||||||
})
|
|
||||||
@IsEmail({}, { message: 'Please provide a valid email address' })
|
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'User password (min 6 characters)',
|
|
||||||
example: 'password123',
|
|
||||||
required: true,
|
|
||||||
minLength: 6,
|
|
||||||
})
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(6, { message: 'Password must be at least 6 characters long' })
|
@MinLength(6, { message: ErrorCode.VALIDATION_PASSWORD_TOO_SHORT })
|
||||||
password: string;
|
password: string;
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Keep the user signed in for 30 days on this device',
|
|
||||||
required: false,
|
|
||||||
default: false,
|
|
||||||
})
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
rememberMe?: boolean;
|
rememberMe?: boolean;
|
||||||
|
|||||||
@@ -1,28 +1,31 @@
|
|||||||
import { IsEmail, IsString, MinLength, IsEnum, Matches } from 'class-validator';
|
import { IsEmail, IsString, MinLength, IsEnum, Matches } from 'class-validator';
|
||||||
import { isValidIranMobile } from '../../../common/utils/mobile.util';
|
import { isValidIranMobile } from '../../../common/utils/mobile.util';
|
||||||
|
import { ErrorCode } from '../../../common/errors';
|
||||||
|
|
||||||
export class RegisterDto {
|
export class RegisterDto {
|
||||||
@IsEmail()
|
@IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID })
|
||||||
email: string;
|
email: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^[\d+\s()-]+$/, { message: 'Mobile number format is invalid' })
|
@Matches(/^[\d+\s()-]+$/, { message: ErrorCode.VALIDATION_MOBILE_INVALID })
|
||||||
mobile: string;
|
mobile: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(8)
|
@MinLength(8, { message: ErrorCode.VALIDATION_PASSWORD_TOO_SHORT })
|
||||||
password: string;
|
password: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@MinLength(2, { message: ErrorCode.VALIDATION_NAME_TOO_SHORT })
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@MinLength(2, { message: ErrorCode.VALIDATION_ORGANIZATION_NAME_REQUIRED })
|
||||||
organizationName: string;
|
organizationName: string;
|
||||||
|
|
||||||
@IsEmail()
|
@IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID })
|
||||||
organizationEmail: string;
|
organizationEmail: string;
|
||||||
|
|
||||||
@IsEnum(['CLINIC', 'LAB'])
|
@IsEnum(['CLINIC', 'LAB'], { message: ErrorCode.VALIDATION_ORGANIZATION_TYPE_INVALID })
|
||||||
organizationType: 'CLINIC' | 'LAB';
|
organizationType: 'CLINIC' | 'LAB';
|
||||||
|
|
||||||
static isValidMobile(mobile: string): boolean {
|
static isValidMobile(mobile: string): boolean {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsIn, IsString } from 'class-validator';
|
import { IsIn, IsString } from 'class-validator';
|
||||||
|
import { ErrorCode } from '../../../common/errors';
|
||||||
|
|
||||||
export const SUPPORTED_USER_LANGUAGES = ['en', 'fa', 'nl'] as const;
|
export const SUPPORTED_USER_LANGUAGES = ['en', 'fa', 'nl'] as const;
|
||||||
export type SupportedUserLanguage = (typeof SUPPORTED_USER_LANGUAGES)[number];
|
export type SupportedUserLanguage = (typeof SUPPORTED_USER_LANGUAGES)[number];
|
||||||
@@ -8,7 +9,7 @@ export class UpdateLanguageDto {
|
|||||||
@ApiProperty({ enum: SUPPORTED_USER_LANGUAGES, example: 'en' })
|
@ApiProperty({ enum: SUPPORTED_USER_LANGUAGES, example: 'en' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(SUPPORTED_USER_LANGUAGES, {
|
@IsIn(SUPPORTED_USER_LANGUAGES, {
|
||||||
message: 'Language must be one of: en, fa, nl',
|
message: ErrorCode.VALIDATION_LANGUAGE_INVALID,
|
||||||
})
|
})
|
||||||
language: SupportedUserLanguage;
|
language: SupportedUserLanguage;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// backend/src/modules/auth/strategies/jwt.strategy.ts
|
// backend/src/modules/auth/strategies/jwt.strategy.ts
|
||||||
import { Strategy } from 'passport-jwt';
|
import { Strategy } from 'passport-jwt';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
|
import { AppException, ErrorCode } from '../../../common/errors';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
@@ -27,7 +28,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException();
|
throw new AppException(ErrorCode.AUTH_UNAUTHORIZED, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { passwordHash, ...result } = user;
|
const { passwordHash, ...result } = user;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// backend/src/modules/auth/strategies/local.strategy.ts
|
|
||||||
import { Strategy } from 'passport-local';
|
import { Strategy } from 'passport-local';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
import { AuthService } from '../auth.service';
|
import { AuthService } from '../auth.service';
|
||||||
|
import { AppException, ErrorCode } from '../../../common/errors';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||||
@@ -13,7 +13,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
|
|||||||
async validate(email: string, password: string): Promise<any> {
|
async validate(email: string, password: string): Promise<any> {
|
||||||
const user = await this.authService.validateUser(email, password);
|
const user = await this.authService.validateUser(email, password);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new UnauthorizedException('Invalid credentials');
|
throw new AppException(ErrorCode.AUTH_INVALID_CREDENTIALS, HttpStatus.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
import { IsEmail, IsEnum, IsString, MinLength } from 'class-validator';
|
import { IsEmail, IsEnum, IsString, MinLength } from 'class-validator';
|
||||||
|
import { ErrorCode } from '../../../common/errors';
|
||||||
|
|
||||||
export class AcceptOrganizationInviteDto {
|
export class AcceptOrganizationInviteDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1, { message: ErrorCode.VALIDATION_TOKEN_REQUIRED })
|
||||||
token: string;
|
token: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(2, { message: ErrorCode.VALIDATION_ORGANIZATION_NAME_REQUIRED })
|
||||||
organizationName: string;
|
organizationName: string;
|
||||||
|
|
||||||
@IsEmail()
|
@IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID })
|
||||||
organizationEmail: string;
|
organizationEmail: string;
|
||||||
|
|
||||||
@IsEnum(['CLINIC', 'LAB'])
|
@IsEnum(['CLINIC', 'LAB'], { message: ErrorCode.VALIDATION_ORGANIZATION_TYPE_INVALID })
|
||||||
organizationType: 'CLINIC' | 'LAB';
|
organizationType: 'CLINIC' | 'LAB';
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(2, { message: ErrorCode.VALIDATION_NAME_TOO_SHORT })
|
||||||
ownerName: string;
|
ownerName: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(8)
|
@MinLength(8, { message: ErrorCode.VALIDATION_PASSWORD_TOO_SHORT })
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { IsString, MinLength } from 'class-validator';
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
import { ErrorCode } from '../../../common/errors';
|
||||||
|
|
||||||
export class AcceptStaffInviteDto {
|
export class AcceptStaffInviteDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@MinLength(1, { message: ErrorCode.VALIDATION_TOKEN_REQUIRED })
|
||||||
token: string;
|
token: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(8)
|
@MinLength(8, { message: ErrorCode.VALIDATION_PASSWORD_TOO_SHORT })
|
||||||
password: string;
|
password: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1, { message: ErrorCode.VALIDATION_NAME_TOO_SHORT })
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -828,5 +828,66 @@
|
|||||||
"monthOctober": "October",
|
"monthOctober": "October",
|
||||||
"monthNovember": "November",
|
"monthNovember": "November",
|
||||||
"monthDecember": "December"
|
"monthDecember": "December"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"GENERIC": "Something went wrong. Please try again.",
|
||||||
|
"NETWORK_ERROR": "Could not reach the server. Check your connection and try again.",
|
||||||
|
"TIMEOUT": "The request took too long. Please try again.",
|
||||||
|
"AUTH_INVALID_CREDENTIALS": "Incorrect email or password.",
|
||||||
|
"AUTH_UNAUTHORIZED": "Your session has expired. Please sign in again.",
|
||||||
|
"AUTH_REFRESH_TOKEN_NOT_FOUND": "Your session has expired. Please sign in again.",
|
||||||
|
"AUTH_REFRESH_TOKEN_INVALID": "Your session has expired. Please sign in again.",
|
||||||
|
"AUTH_SESSION_EXPIRED": "Your session has expired. Please sign in again.",
|
||||||
|
"AUTH_USER_NOT_FOUND": "We could not find your account.",
|
||||||
|
"AUTH_ORG_NOT_SELECTED": "Please select an organization to continue.",
|
||||||
|
"AUTH_ORG_ACCESS_DENIED": "You do not have access to this organization.",
|
||||||
|
"AUTH_INVITATION_PENDING": "Your invitation is still pending. Ask an administrator to activate your account.",
|
||||||
|
"AUTH_REGISTRATION_EMAIL_EXISTS": "An account with this email already exists. Sign in to add a new organization from your account.",
|
||||||
|
"AUTH_REGISTRATION_MOBILE_EXISTS": "This mobile number is already registered.",
|
||||||
|
"AUTH_REGISTRATION_MOBILE_INVALID": "Please enter a valid mobile number.",
|
||||||
|
"AUTH_REGISTRATION_FAILED": "Registration could not be completed. Please try again.",
|
||||||
|
"AUTH_PASSWORD_INCORRECT": "Current password is incorrect.",
|
||||||
|
"AUTH_PASSWORD_RESET_EXPIRED": "Password reset verification expired. Please verify your mobile number again.",
|
||||||
|
"AUTH_VERIFICATION_CODE_INVALID": "Invalid or expired verification code.",
|
||||||
|
"AUTH_VERIFICATION_RATE_LIMIT": "Please wait a moment before requesting another code.",
|
||||||
|
"AUTH_SELECT_ORG_FIRST": "Select an organization before creating a new one.",
|
||||||
|
"AUTH_CREATE_ORG_OWNER_ONLY": "Only owners of the current organization can create new organizations.",
|
||||||
|
"AUTH_PASSWORD_CURRENT_REQUIRED": "Current password is required.",
|
||||||
|
"PERMISSION_DENIED": "You do not have permission to perform this action.",
|
||||||
|
"PERMISSION_ORG_MANAGE": "You do not have permission to manage organizations.",
|
||||||
|
"PERMISSION_CLINIC_ONLY": "This action is only available for clinic organizations.",
|
||||||
|
"PERMISSION_LAB_ONLY": "This action is only available for lab organizations.",
|
||||||
|
"PERMISSION_NOT_MEMBER": "You are not a member of this organization.",
|
||||||
|
"PERMISSION_OWNER_ONLY": "Only organization owners can perform this action.",
|
||||||
|
"PERMISSION_PARTICIPATION_SUBSCRIPTION": "An active subscription is required to participate in treatments or tasks.",
|
||||||
|
"PERMISSION_ENABLE_PARTICIPATION_FIRST": "Enable treatment participation before setting working hours.",
|
||||||
|
"PERMISSION_CLINIC_WORKING_HOURS": "Working hours are only available for clinic organizations.",
|
||||||
|
"PERMISSION_ACCESS_APPOINTMENTS": "You do not have access to appointments.",
|
||||||
|
"PERMISSION_EDIT_APPOINTMENTS": "You cannot create or modify appointments.",
|
||||||
|
"PERMISSION_ACCESS_TREATMENTS": "You do not have access to treatments.",
|
||||||
|
"PERMISSION_EDIT_TREATMENTS": "You cannot edit treatments.",
|
||||||
|
"PERMISSION_ACCESS_TASKS": "You do not have access to tasks.",
|
||||||
|
"PERMISSION_EDIT_TASKS": "You cannot edit tasks.",
|
||||||
|
"PERMISSION_ACCESS_CASES": "You do not have access to cases.",
|
||||||
|
"PERMISSION_ACCESS_STAFF": "You do not have access to staff management.",
|
||||||
|
"PERMISSION_EDIT_STAFF": "You cannot manage staff working hours.",
|
||||||
|
"PERMISSION_ORG_NOT_FOUND": "Organization not found.",
|
||||||
|
"VALIDATION_FAILED": "Please check the form and try again.",
|
||||||
|
"VALIDATION_EMAIL_INVALID": "Please enter a valid email address.",
|
||||||
|
"VALIDATION_PASSWORD_TOO_SHORT": "Password is too short.",
|
||||||
|
"VALIDATION_PASSWORD_REQUIRED": "Password is required.",
|
||||||
|
"VALIDATION_MOBILE_INVALID": "Please enter a valid mobile number.",
|
||||||
|
"VALIDATION_NAME_TOO_SHORT": "Name is too short.",
|
||||||
|
"VALIDATION_ORGANIZATION_NAME_REQUIRED": "Organization name is required.",
|
||||||
|
"VALIDATION_ORGANIZATION_TYPE_INVALID": "Please select a valid organization type.",
|
||||||
|
"VALIDATION_TOKEN_REQUIRED": "This link is invalid or incomplete.",
|
||||||
|
"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.",
|
||||||
|
"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.",
|
||||||
|
"BAD_REQUEST": "The request could not be processed.",
|
||||||
|
"INTERNAL_ERROR": "Something went wrong on our end. Please try again later."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -829,5 +829,66 @@
|
|||||||
"monthOctober": "اکتبر",
|
"monthOctober": "اکتبر",
|
||||||
"monthNovember": "نوامبر",
|
"monthNovember": "نوامبر",
|
||||||
"monthDecember": "دسامبر"
|
"monthDecember": "دسامبر"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
|
||||||
|
"NETWORK_ERROR": "اتصال به سرور برقرار نشد. اتصال اینترنت را بررسی کنید.",
|
||||||
|
"TIMEOUT": "درخواست بیش از حد طول کشید. لطفاً دوباره تلاش کنید.",
|
||||||
|
"AUTH_INVALID_CREDENTIALS": "ایمیل یا رمز عبور نادرست است.",
|
||||||
|
"AUTH_UNAUTHORIZED": "نشست شما منقضی شده است. لطفاً دوباره وارد شوید.",
|
||||||
|
"AUTH_REFRESH_TOKEN_NOT_FOUND": "نشست شما منقضی شده است. لطفاً دوباره وارد شوید.",
|
||||||
|
"AUTH_REFRESH_TOKEN_INVALID": "نشست شما منقضی شده است. لطفاً دوباره وارد شوید.",
|
||||||
|
"AUTH_SESSION_EXPIRED": "نشست شما منقضی شده است. لطفاً دوباره وارد شوید.",
|
||||||
|
"AUTH_USER_NOT_FOUND": "حساب کاربری یافت نشد.",
|
||||||
|
"AUTH_ORG_NOT_SELECTED": "لطفاً یک سازمان را انتخاب کنید.",
|
||||||
|
"AUTH_ORG_ACCESS_DENIED": "به این سازمان دسترسی ندارید.",
|
||||||
|
"AUTH_INVITATION_PENDING": "دعوتنامه شما هنوز فعال نشده است. از مدیر بخواهید حساب شما را فعال کند.",
|
||||||
|
"AUTH_REGISTRATION_EMAIL_EXISTS": "حسابی با این ایمیل وجود دارد. وارد شوید و از حساب خود سازمان جدید بسازید.",
|
||||||
|
"AUTH_REGISTRATION_MOBILE_EXISTS": "این شماره موبایل قبلاً ثبت شده است.",
|
||||||
|
"AUTH_REGISTRATION_MOBILE_INVALID": "لطفاً شماره موبایل معتبر وارد کنید.",
|
||||||
|
"AUTH_REGISTRATION_FAILED": "ثبتنام انجام نشد. لطفاً دوباره تلاش کنید.",
|
||||||
|
"AUTH_PASSWORD_INCORRECT": "رمز عبور فعلی نادرست است.",
|
||||||
|
"AUTH_PASSWORD_RESET_EXPIRED": "زمان بازیابی رمز عبور تمام شده است. دوباره شماره موبایل را تأیید کنید.",
|
||||||
|
"AUTH_VERIFICATION_CODE_INVALID": "کد تأیید نامعتبر یا منقضی شده است.",
|
||||||
|
"AUTH_VERIFICATION_RATE_LIMIT": "لطفاً کمی صبر کنید و دوباره درخواست کد دهید.",
|
||||||
|
"AUTH_SELECT_ORG_FIRST": "قبل از ایجاد سازمان جدید، یک سازمان را انتخاب کنید.",
|
||||||
|
"AUTH_CREATE_ORG_OWNER_ONLY": "فقط مالک سازمان فعلی میتواند سازمان جدید ایجاد کند.",
|
||||||
|
"AUTH_PASSWORD_CURRENT_REQUIRED": "رمز عبور فعلی الزامی است.",
|
||||||
|
"PERMISSION_DENIED": "اجازه انجام این کار را ندارید.",
|
||||||
|
"PERMISSION_ORG_MANAGE": "اجازه مدیریت سازمانها را ندارید.",
|
||||||
|
"PERMISSION_CLINIC_ONLY": "این عمل فقط برای کلینیکها در دسترس است.",
|
||||||
|
"PERMISSION_LAB_ONLY": "این عمل فقط برای لابراتوارها در دسترس است.",
|
||||||
|
"PERMISSION_NOT_MEMBER": "عضو این سازمان نیستید.",
|
||||||
|
"PERMISSION_OWNER_ONLY": "فقط مالک سازمان میتواند این کار را انجام دهد.",
|
||||||
|
"PERMISSION_PARTICIPATION_SUBSCRIPTION": "برای شرکت در درمان یا وظایف، اشتراک فعال لازم است.",
|
||||||
|
"PERMISSION_ENABLE_PARTICIPATION_FIRST": "قبل از تنظیم ساعات کاری، مشارکت در درمان را فعال کنید.",
|
||||||
|
"PERMISSION_CLINIC_WORKING_HOURS": "ساعات کاری فقط برای کلینیکها در دسترس است.",
|
||||||
|
"PERMISSION_ACCESS_APPOINTMENTS": "به نوبتها دسترسی ندارید.",
|
||||||
|
"PERMISSION_EDIT_APPOINTMENTS": "نمیتوانید نوبت ایجاد یا ویرایش کنید.",
|
||||||
|
"PERMISSION_ACCESS_TREATMENTS": "به درمانها دسترسی ندارید.",
|
||||||
|
"PERMISSION_EDIT_TREATMENTS": "نمیتوانید درمانها را ویرایش کنید.",
|
||||||
|
"PERMISSION_ACCESS_TASKS": "به وظایف دسترسی ندارید.",
|
||||||
|
"PERMISSION_EDIT_TASKS": "نمیتوانید وظایف را ویرایش کنید.",
|
||||||
|
"PERMISSION_ACCESS_CASES": "به پروندهها دسترسی ندارید.",
|
||||||
|
"PERMISSION_ACCESS_STAFF": "به مدیریت پرسنل دسترسی ندارید.",
|
||||||
|
"PERMISSION_EDIT_STAFF": "نمیتوانید ساعات کاری پرسنل را مدیریت کنید.",
|
||||||
|
"PERMISSION_ORG_NOT_FOUND": "سازمان یافت نشد.",
|
||||||
|
"VALIDATION_FAILED": "لطفاً فرم را بررسی و دوباره تلاش کنید.",
|
||||||
|
"VALIDATION_EMAIL_INVALID": "لطفاً یک ایمیل معتبر وارد کنید.",
|
||||||
|
"VALIDATION_PASSWORD_TOO_SHORT": "رمز عبور کوتاه است.",
|
||||||
|
"VALIDATION_PASSWORD_REQUIRED": "رمز عبور الزامی است.",
|
||||||
|
"VALIDATION_MOBILE_INVALID": "لطفاً شماره موبایل معتبر وارد کنید.",
|
||||||
|
"VALIDATION_NAME_TOO_SHORT": "نام کوتاه است.",
|
||||||
|
"VALIDATION_ORGANIZATION_NAME_REQUIRED": "نام سازمان الزامی است.",
|
||||||
|
"VALIDATION_ORGANIZATION_TYPE_INVALID": "نوع سازمان معتبر انتخاب کنید.",
|
||||||
|
"VALIDATION_TOKEN_REQUIRED": "این لینک نامعتبر یا ناقص است.",
|
||||||
|
"VALIDATION_FIELD_REQUIRED": "لطفاً همه فیلدهای الزامی را پر کنید.",
|
||||||
|
"VALIDATION_LANGUAGE_INVALID": "زبان پشتیبانیشده انتخاب کنید.",
|
||||||
|
"VALIDATION_INVALID_REQUEST": "درخواست حاوی داده نامعتبر است.",
|
||||||
|
"NOT_FOUND": "مورد درخواستی یافت نشد.",
|
||||||
|
"CONFLICT": "این عمل با دادههای موجود در تضاد است.",
|
||||||
|
"CONFLICT_FUTURE_APPOINTMENTS": "تا وقتی نوبتهای آینده دارید نمیتوانید مشارکت در درمان را متوقف کنید. ابتدا آنها را لغو یا واگذار کنید.",
|
||||||
|
"BAD_REQUEST": "درخواست قابل پردازش نبود.",
|
||||||
|
"INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -829,5 +829,66 @@
|
|||||||
"monthOctober": "Oktober",
|
"monthOctober": "Oktober",
|
||||||
"monthNovember": "November",
|
"monthNovember": "November",
|
||||||
"monthDecember": "December"
|
"monthDecember": "December"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"GENERIC": "Er is iets misgegaan. Probeer het opnieuw.",
|
||||||
|
"NETWORK_ERROR": "Kan de server niet bereiken. Controleer uw verbinding.",
|
||||||
|
"TIMEOUT": "Het verzoek duurde te lang. Probeer het opnieuw.",
|
||||||
|
"AUTH_INVALID_CREDENTIALS": "Onjuist e-mailadres of wachtwoord.",
|
||||||
|
"AUTH_UNAUTHORIZED": "Uw sessie is verlopen. Meld u opnieuw aan.",
|
||||||
|
"AUTH_REFRESH_TOKEN_NOT_FOUND": "Uw sessie is verlopen. Meld u opnieuw aan.",
|
||||||
|
"AUTH_REFRESH_TOKEN_INVALID": "Uw sessie is verlopen. Meld u opnieuw aan.",
|
||||||
|
"AUTH_SESSION_EXPIRED": "Uw sessie is verlopen. Meld u opnieuw aan.",
|
||||||
|
"AUTH_USER_NOT_FOUND": "We konden uw account niet vinden.",
|
||||||
|
"AUTH_ORG_NOT_SELECTED": "Selecteer een organisatie om door te gaan.",
|
||||||
|
"AUTH_ORG_ACCESS_DENIED": "U hebt geen toegang tot deze organisatie.",
|
||||||
|
"AUTH_INVITATION_PENDING": "Uw uitnodiging is nog in behandeling. Vraag een beheerder om uw account te activeren.",
|
||||||
|
"AUTH_REGISTRATION_EMAIL_EXISTS": "Er bestaat al een account met dit e-mailadres. Meld u aan om een nieuwe organisatie toe te voegen.",
|
||||||
|
"AUTH_REGISTRATION_MOBILE_EXISTS": "Dit mobiele nummer is al geregistreerd.",
|
||||||
|
"AUTH_REGISTRATION_MOBILE_INVALID": "Voer een geldig mobiel nummer in.",
|
||||||
|
"AUTH_REGISTRATION_FAILED": "Registratie kon niet worden voltooid. Probeer het opnieuw.",
|
||||||
|
"AUTH_PASSWORD_INCORRECT": "Huidig wachtwoord is onjuist.",
|
||||||
|
"AUTH_PASSWORD_RESET_EXPIRED": "Wachtwoordherstel is verlopen. Verifieer uw mobiele nummer opnieuw.",
|
||||||
|
"AUTH_VERIFICATION_CODE_INVALID": "Ongeldige of verlopen verificatiecode.",
|
||||||
|
"AUTH_VERIFICATION_RATE_LIMIT": "Wacht even voordat u opnieuw een code aanvraagt.",
|
||||||
|
"AUTH_SELECT_ORG_FIRST": "Selecteer een organisatie voordat u een nieuwe aanmaakt.",
|
||||||
|
"AUTH_CREATE_ORG_OWNER_ONLY": "Alleen eigenaren van de huidige organisatie kunnen nieuwe organisaties aanmaken.",
|
||||||
|
"AUTH_PASSWORD_CURRENT_REQUIRED": "Huidig wachtwoord is verplicht.",
|
||||||
|
"PERMISSION_DENIED": "U hebt geen toestemming voor deze actie.",
|
||||||
|
"PERMISSION_ORG_MANAGE": "U hebt geen toestemming om organisaties te beheren.",
|
||||||
|
"PERMISSION_CLINIC_ONLY": "Deze actie is alleen beschikbaar voor klinieken.",
|
||||||
|
"PERMISSION_LAB_ONLY": "Deze actie is alleen beschikbaar voor laboratoria.",
|
||||||
|
"PERMISSION_NOT_MEMBER": "U bent geen lid van deze organisatie.",
|
||||||
|
"PERMISSION_OWNER_ONLY": "Alleen organisatie-eigenaren kunnen dit doen.",
|
||||||
|
"PERMISSION_PARTICIPATION_SUBSCRIPTION": "Een actief abonnement is vereist om deel te nemen aan behandelingen of taken.",
|
||||||
|
"PERMISSION_ENABLE_PARTICIPATION_FIRST": "Schakel deelname aan behandelingen in voordat u werktijden instelt.",
|
||||||
|
"PERMISSION_CLINIC_WORKING_HOURS": "Werktijden zijn alleen beschikbaar voor klinieken.",
|
||||||
|
"PERMISSION_ACCESS_APPOINTMENTS": "U hebt geen toegang tot afspraken.",
|
||||||
|
"PERMISSION_EDIT_APPOINTMENTS": "U kunt geen afspraken maken of wijzigen.",
|
||||||
|
"PERMISSION_ACCESS_TREATMENTS": "U hebt geen toegang tot behandelingen.",
|
||||||
|
"PERMISSION_EDIT_TREATMENTS": "U kunt behandelingen niet bewerken.",
|
||||||
|
"PERMISSION_ACCESS_TASKS": "U hebt geen toegang tot taken.",
|
||||||
|
"PERMISSION_EDIT_TASKS": "U kunt taken niet bewerken.",
|
||||||
|
"PERMISSION_ACCESS_CASES": "U hebt geen toegang tot dossiers.",
|
||||||
|
"PERMISSION_ACCESS_STAFF": "U hebt geen toegang tot personeelsbeheer.",
|
||||||
|
"PERMISSION_EDIT_STAFF": "U kunt werktijden van personeel niet beheren.",
|
||||||
|
"PERMISSION_ORG_NOT_FOUND": "Organisatie niet gevonden.",
|
||||||
|
"VALIDATION_FAILED": "Controleer het formulier en probeer het opnieuw.",
|
||||||
|
"VALIDATION_EMAIL_INVALID": "Voer een geldig e-mailadres in.",
|
||||||
|
"VALIDATION_PASSWORD_TOO_SHORT": "Wachtwoord is te kort.",
|
||||||
|
"VALIDATION_PASSWORD_REQUIRED": "Wachtwoord is verplicht.",
|
||||||
|
"VALIDATION_MOBILE_INVALID": "Voer een geldig mobiel nummer in.",
|
||||||
|
"VALIDATION_NAME_TOO_SHORT": "Naam is te kort.",
|
||||||
|
"VALIDATION_ORGANIZATION_NAME_REQUIRED": "Organisatienaam is verplicht.",
|
||||||
|
"VALIDATION_ORGANIZATION_TYPE_INVALID": "Selecteer een geldig organisatietype.",
|
||||||
|
"VALIDATION_TOKEN_REQUIRED": "Deze link is ongeldig of onvolledig.",
|
||||||
|
"VALIDATION_FIELD_REQUIRED": "Vul alle verplichte velden in.",
|
||||||
|
"VALIDATION_LANGUAGE_INVALID": "Selecteer een ondersteunde taal.",
|
||||||
|
"VALIDATION_INVALID_REQUEST": "Het verzoek bevat ongeldige gegevens.",
|
||||||
|
"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.",
|
||||||
|
"BAD_REQUEST": "Het verzoek kon niet worden verwerkt.",
|
||||||
|
"INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker';
|
|||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import type { AppointmentPurpose } from '@/types/appointment';
|
import type { AppointmentPurpose } from '@/types/appointment';
|
||||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime';
|
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/components/appointments/appointmentTime';
|
||||||
|
|
||||||
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
||||||
@@ -32,6 +32,7 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
|||||||
|
|
||||||
export default function AppointmentsPage() {
|
export default function AppointmentsPage() {
|
||||||
const t = useTranslations('appointments');
|
const t = useTranslations('appointments');
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
const tPatients = useTranslations('patients');
|
const tPatients = useTranslations('patients');
|
||||||
const { currentOrganization } = useAuth();
|
const { currentOrganization } = useAuth();
|
||||||
const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date()));
|
const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date()));
|
||||||
@@ -105,7 +106,7 @@ export default function AppointmentsPage() {
|
|||||||
if (gen !== scheduleLoadGen.current) {
|
if (gen !== scheduleLoadGen.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.showError(formatApiErrorMessage(err, t('errorLoadSchedule')));
|
toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule')));
|
||||||
} finally {
|
} finally {
|
||||||
if (gen === scheduleLoadGen.current) {
|
if (gen === scheduleLoadGen.current) {
|
||||||
setLoadingSchedule(false);
|
setLoadingSchedule(false);
|
||||||
@@ -178,11 +179,7 @@ export default function AppointmentsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message =
|
toast.showError(getUserFacingError(err, tErrors, tPatients('errorSavePatient')));
|
||||||
err && typeof err === 'object' && 'message' in err
|
|
||||||
? String((err as { message: unknown }).message)
|
|
||||||
: tPatients('errorSavePatient');
|
|
||||||
toast.showError(message);
|
|
||||||
} finally {
|
} finally {
|
||||||
setSavingPatient(false);
|
setSavingPatient(false);
|
||||||
}
|
}
|
||||||
@@ -248,13 +245,13 @@ export default function AppointmentsPage() {
|
|||||||
toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved'));
|
toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved'));
|
||||||
await loadSchedule();
|
await loadSchedule();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message =
|
toast.showError(
|
||||||
err && typeof err === 'object' && 'message' in err
|
getUserFacingError(
|
||||||
? String((err as { message: unknown }).message)
|
err,
|
||||||
: activeEditingAppointment
|
tErrors,
|
||||||
? t('errorUpdate')
|
activeEditingAppointment ? t('errorUpdate') : t('errorSave'),
|
||||||
: t('errorSave');
|
),
|
||||||
toast.showError(message);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingAppointment(false);
|
setSavingAppointment(false);
|
||||||
}
|
}
|
||||||
@@ -276,11 +273,7 @@ export default function AppointmentsPage() {
|
|||||||
toast.showSuccess(t('successRemoved'));
|
toast.showSuccess(t('successRemoved'));
|
||||||
await loadSchedule();
|
await loadSchedule();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message =
|
toast.showError(getUserFacingError(err, tErrors, t('errorDelete')));
|
||||||
err && typeof err === 'object' && 'message' in err
|
|
||||||
? String((err as { message: unknown }).message)
|
|
||||||
: t('errorDelete');
|
|
||||||
toast.showError(message);
|
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingAppointment(false);
|
setDeletingAppointment(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import { canEditCases, canEditTasks } from '@/components/shared/permissions';
|
import { canEditCases, canEditTasks } from '@/components/shared/permissions';
|
||||||
@@ -35,6 +35,7 @@ const PAGE_SIZE = 20;
|
|||||||
|
|
||||||
export default function CasesPage() {
|
export default function CasesPage() {
|
||||||
const t = useTranslations('cases');
|
const t = useTranslations('cases');
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { currentOrganization, user } = useAuth();
|
const { currentOrganization, user } = useAuth();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -112,7 +113,7 @@ export default function CasesPage() {
|
|||||||
setCases(response.data.items);
|
setCases(response.data.items);
|
||||||
setPagination(response.data.pagination);
|
setPagination(response.data.pagination);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.showError(formatApiErrorMessage(error, t('errorLoadList')));
|
toast.showError(getUserFacingError(error, tErrors, t('errorLoadList')));
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingList(false);
|
setLoadingList(false);
|
||||||
}
|
}
|
||||||
@@ -127,7 +128,7 @@ export default function CasesPage() {
|
|||||||
const response = await casesApi.getOne(caseId);
|
const response = await casesApi.getOne(caseId);
|
||||||
setSelectedCase(response.data);
|
setSelectedCase(response.data);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.showError(formatApiErrorMessage(error, t('errorLoadDetail')));
|
toast.showError(getUserFacingError(error, tErrors, t('errorLoadDetail')));
|
||||||
if (!options?.silent) {
|
if (!options?.silent) {
|
||||||
setSelectedCase(null);
|
setSelectedCase(null);
|
||||||
}
|
}
|
||||||
@@ -218,7 +219,7 @@ export default function CasesPage() {
|
|||||||
setSelectedCase(response.data);
|
setSelectedCase(response.data);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setSelectedCase(previousCase);
|
setSelectedCase(previousCase);
|
||||||
toast.showError(formatApiErrorMessage(error, t('errorUpdateTask')));
|
toast.showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
|
||||||
} finally {
|
} finally {
|
||||||
setUpdatingImportant(false);
|
setUpdatingImportant(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { Input } from '@/components/ui/shared/Input';
|
|||||||
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
import { SearchBar } from '@/components/ui/shared/SearchBar';
|
||||||
import { Table } from '@/components/ui/shared/Table';
|
import { Table } from '@/components/ui/shared/Table';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||||
import type { ApiError } from '@/types/api';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
|
|
||||||
function formatOrganizationStatusLabel(status: string): string {
|
function formatOrganizationStatusLabel(status: string): string {
|
||||||
if (!status) return status;
|
if (!status) return status;
|
||||||
@@ -42,6 +42,7 @@ type TableMode = 'existing' | 'search';
|
|||||||
|
|
||||||
export default function OrganizationsPage() {
|
export default function OrganizationsPage() {
|
||||||
const t = useTranslations('organizations');
|
const t = useTranslations('organizations');
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
const tNav = useTranslations('nav');
|
const tNav = useTranslations('nav');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { currentOrganization } = useAuth();
|
const { currentOrganization } = useAuth();
|
||||||
@@ -49,14 +50,8 @@ export default function OrganizationsPage() {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const formatApiMessage = useCallback(
|
const formatApiMessage = useCallback(
|
||||||
(err: unknown): string => {
|
(err: unknown): string => getUserFacingError(err, tErrors, tCommon('errorGeneric')),
|
||||||
if (!err || typeof err !== 'object') return tCommon('errorGeneric');
|
[tCommon, tErrors],
|
||||||
const m = (err as ApiError).message;
|
|
||||||
if (Array.isArray(m)) return m.join(', ');
|
|
||||||
if (typeof m === 'string') return m;
|
|
||||||
return tCommon('errorGeneric');
|
|
||||||
},
|
|
||||||
[tCommon],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const formatConnectionStatusLabel = useCallback(
|
const formatConnectionStatusLabel = useCallback(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||||
import { patientsApi } from '@/lib/api/patients';
|
import { patientsApi } from '@/lib/api/patients';
|
||||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import { hasPermission } from '@/components/shared/permissions';
|
import { hasPermission } from '@/components/shared/permissions';
|
||||||
@@ -23,6 +23,7 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = {
|
|||||||
|
|
||||||
export default function PatientsPage() {
|
export default function PatientsPage() {
|
||||||
const t = useTranslations('patients');
|
const t = useTranslations('patients');
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { currentOrganization } = useAuth();
|
const { currentOrganization } = useAuth();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -67,7 +68,7 @@ export default function PatientsPage() {
|
|||||||
setSelectedPatient(freshSelected);
|
setSelectedPatient(freshSelected);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.showError(formatApiErrorMessage(error, t('errorLoadPatients')));
|
toast.showError(getUserFacingError(error, tErrors, t('errorLoadPatients')));
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingPatients(false);
|
setLoadingPatients(false);
|
||||||
}
|
}
|
||||||
@@ -98,7 +99,7 @@ export default function PatientsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.showError(formatApiErrorMessage(error, t('errorSavePatient')));
|
toast.showError(getUserFacingError(error, tErrors, t('errorSavePatient')));
|
||||||
} finally {
|
} finally {
|
||||||
setSavingPatient(false);
|
setSavingPatient(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Input } from '@/components/ui/shared/Input';
|
|||||||
import { Toast } from '@/components/ui/shared/Toast';
|
import { Toast } from '@/components/ui/shared/Toast';
|
||||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||||
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { OwnerWorkingHoursDialog } from '@/components/settings/OwnerWorkingHoursDialog';
|
import { OwnerWorkingHoursDialog } from '@/components/settings/OwnerWorkingHoursDialog';
|
||||||
|
|
||||||
type PasswordForm = {
|
type PasswordForm = {
|
||||||
@@ -26,6 +27,7 @@ type PasswordForm = {
|
|||||||
|
|
||||||
export default function AccountSettingsPage() {
|
export default function AccountSettingsPage() {
|
||||||
const t = useTranslations('settings');
|
const t = useTranslations('settings');
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
const tAuth = useTranslations('auth');
|
const tAuth = useTranslations('auth');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const tValidation = useTranslations('validation');
|
const tValidation = useTranslations('validation');
|
||||||
@@ -152,8 +154,7 @@ export default function AccountSettingsPage() {
|
|||||||
await syncSessionAfterParticipationChange();
|
await syncSessionAfterParticipationChange();
|
||||||
setSuccessMessage(t('participateEnabledTasks'));
|
setSuccessMessage(t('participateEnabledTasks'));
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : t('participateUpdateFailed');
|
setError(getUserFacingError(err, tErrors, t('participateUpdateFailed')));
|
||||||
setError(message || t('participateUpdateFailed'));
|
|
||||||
setParticipatesInTasks(false);
|
setParticipatesInTasks(false);
|
||||||
} finally {
|
} finally {
|
||||||
setParticipationLoading(false);
|
setParticipationLoading(false);
|
||||||
@@ -180,8 +181,7 @@ export default function AccountSettingsPage() {
|
|||||||
setRevokeConfirmOpen(false);
|
setRevokeConfirmOpen(false);
|
||||||
setPendingRevokeType(null);
|
setPendingRevokeType(null);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : t('participateUpdateFailed');
|
setError(getUserFacingError(err, tErrors, t('participateUpdateFailed')));
|
||||||
setError(message || t('participateUpdateFailed'));
|
|
||||||
} finally {
|
} finally {
|
||||||
setParticipationLoading(false);
|
setParticipationLoading(false);
|
||||||
}
|
}
|
||||||
@@ -214,8 +214,7 @@ export default function AccountSettingsPage() {
|
|||||||
try {
|
try {
|
||||||
await enableClinicParticipation(options);
|
await enableClinicParticipation(options);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : t('participateUpdateFailed');
|
setError(getUserFacingError(err, tErrors, t('participateUpdateFailed')));
|
||||||
setError(message || t('participateUpdateFailed'));
|
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setParticipationLoading(false);
|
setParticipationLoading(false);
|
||||||
@@ -249,8 +248,7 @@ export default function AccountSettingsPage() {
|
|||||||
setSuccessMessage(t('passwordChanged'));
|
setSuccessMessage(t('passwordChanged'));
|
||||||
router.replace('/login');
|
router.replace('/login');
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : t('passwordChangeFailed');
|
setError(getUserFacingError(err, tErrors, t('passwordChangeFailed')));
|
||||||
setError(message || t('passwordChangeFailed'));
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import { Input } from '@/components/ui/shared/Input';
|
|||||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
import { Table } from '@/components/ui/shared/Table';
|
import { Table } from '@/components/ui/shared/Table';
|
||||||
import { ToastStack } from '@/components/ui/shared/Toast';
|
import { ToastStack } from '@/components/ui/shared/Toast';
|
||||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { StaffMembersMobileList } from '@/components/staff/StaffMembersMobileList';
|
import { StaffMembersMobileList } from '@/components/staff/StaffMembersMobileList';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
|
|
||||||
@@ -147,6 +147,7 @@ function PermissionGrid({
|
|||||||
export default function StaffPage() {
|
export default function StaffPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations('staff');
|
const t = useTranslations('staff');
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const tFeatures = useTranslations('staff.features');
|
const tFeatures = useTranslations('staff.features');
|
||||||
const tWorkingHours = useTranslations('staff.workingHours');
|
const tWorkingHours = useTranslations('staff.workingHours');
|
||||||
@@ -229,7 +230,7 @@ export default function StaffPage() {
|
|||||||
setMembers(res.data.members);
|
setMembers(res.data.members);
|
||||||
setSeats(res.data.seats);
|
setSeats(res.data.seats);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.showError(formatApiErrorMessage(e, t('errorLoadStaff')));
|
toast.showError(getUserFacingError(e, tErrors, t('errorLoadStaff')));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -304,7 +305,7 @@ export default function StaffPage() {
|
|||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.showError(formatApiErrorMessage(e, t('errorCopyInvite')));
|
toast.showError(getUserFacingError(e, tErrors, t('errorCopyInvite')));
|
||||||
} finally {
|
} finally {
|
||||||
setCopyingInviteMembershipId(null);
|
setCopyingInviteMembershipId(null);
|
||||||
}
|
}
|
||||||
@@ -387,7 +388,7 @@ export default function StaffPage() {
|
|||||||
resetInviteForm();
|
resetInviteForm();
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.showError(formatApiErrorMessage(e, t('errorSendInvite')));
|
toast.showError(getUserFacingError(e, tErrors, t('errorSendInvite')));
|
||||||
} finally {
|
} finally {
|
||||||
setInviteLoading(false);
|
setInviteLoading(false);
|
||||||
}
|
}
|
||||||
@@ -410,7 +411,7 @@ export default function StaffPage() {
|
|||||||
setEditWorkingHoursDays(state.days);
|
setEditWorkingHoursDays(state.days);
|
||||||
setEditAutoRepeatWeekly(state.autoRepeatWeekly);
|
setEditAutoRepeatWeekly(state.autoRepeatWeekly);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.showError(formatApiErrorMessage(e, t('errorLoadWorkingHours')));
|
toast.showError(getUserFacingError(e, tErrors, t('errorLoadWorkingHours')));
|
||||||
} finally {
|
} finally {
|
||||||
setEditLoadingWorkingHours(false);
|
setEditLoadingWorkingHours(false);
|
||||||
}
|
}
|
||||||
@@ -449,7 +450,7 @@ export default function StaffPage() {
|
|||||||
setEditStep(1);
|
setEditStep(1);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.showError(formatApiErrorMessage(e, t('errorUpdateMember')));
|
toast.showError(getUserFacingError(e, tErrors, t('errorUpdateMember')));
|
||||||
} finally {
|
} finally {
|
||||||
setEditLoading(false);
|
setEditLoading(false);
|
||||||
}
|
}
|
||||||
@@ -470,7 +471,7 @@ export default function StaffPage() {
|
|||||||
setDisableTarget(null);
|
setDisableTarget(null);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.showError(formatApiErrorMessage(e, t('errorDisableMember')));
|
toast.showError(getUserFacingError(e, tErrors, t('errorDisableMember')));
|
||||||
} finally {
|
} finally {
|
||||||
setDisablingMembershipId(null);
|
setDisablingMembershipId(null);
|
||||||
}
|
}
|
||||||
@@ -487,7 +488,7 @@ export default function StaffPage() {
|
|||||||
setEnableTarget(null);
|
setEnableTarget(null);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.showError(formatApiErrorMessage(e, t('errorEnableMember')));
|
toast.showError(getUserFacingError(e, tErrors, t('errorEnableMember')));
|
||||||
} finally {
|
} finally {
|
||||||
setEnablingMembershipId(null);
|
setEnablingMembershipId(null);
|
||||||
}
|
}
|
||||||
@@ -600,7 +601,7 @@ export default function StaffPage() {
|
|||||||
setCopiedInviteMembershipId(lastInviteInfo.membershipId);
|
setCopiedInviteMembershipId(lastInviteInfo.membershipId);
|
||||||
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
|
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.showError(formatApiErrorMessage(e, t('errorCopyInvite')));
|
toast.showError(getUserFacingError(e, tErrors, t('errorCopyInvite')));
|
||||||
} finally {
|
} finally {
|
||||||
setCopyingInviteMembershipId(null);
|
setCopyingInviteMembershipId(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
formatToothList,
|
formatToothList,
|
||||||
prosthesisTypeBadgeStyle,
|
prosthesisTypeBadgeStyle,
|
||||||
} from '@/components/ui/treatment/prosthesisTypeDisplay';
|
} from '@/components/ui/treatment/prosthesisTypeDisplay';
|
||||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
|
import { canEditTasks, canViewTasks } from '@/components/shared/permissions';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
@@ -38,6 +38,7 @@ function formatPatientName(patient: { firstName: string; lastName: string }) {
|
|||||||
|
|
||||||
export default function TasksPage() {
|
export default function TasksPage() {
|
||||||
const t = useTranslations('tasks');
|
const t = useTranslations('tasks');
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
const { currentOrganization, user, isAuthReady } = useAuth();
|
const { currentOrganization, user, isAuthReady } = useAuth();
|
||||||
const { showError, setError, messages: toastMessages } = useToast();
|
const { showError, setError, messages: toastMessages } = useToast();
|
||||||
|
|
||||||
@@ -107,7 +108,7 @@ export default function TasksPage() {
|
|||||||
setTasks(response.data.items);
|
setTasks(response.data.items);
|
||||||
setPagination(response.data.pagination);
|
setPagination(response.data.pagination);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
showError(formatApiErrorMessage(error, tRef.current('errorLoadList')));
|
showError(getUserFacingError(error, tErrors, tRef.current('errorLoadList')));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -127,7 +128,7 @@ export default function TasksPage() {
|
|||||||
await tasksApi.updateStatus(taskId, status);
|
await tasksApi.updateStatus(taskId, status);
|
||||||
await loadTasks();
|
await loadTasks();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
showError(formatApiErrorMessage(error, t('errorUpdateTask')));
|
showError(getUserFacingError(error, tErrors, t('errorUpdateTask')));
|
||||||
} finally {
|
} finally {
|
||||||
setUpdatingTaskId(null);
|
setUpdatingTaskId(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useMemo } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { TodayDashboard } from '@/components/today/TodayDashboard';
|
import { TodayDashboard } from '@/components/today/TodayDashboard';
|
||||||
import { TodayLoadErrorBanner } from '@/components/today/TodayLoadErrorBanner';
|
import { TodayLoadErrorBanner } from '@/components/today/TodayLoadErrorBanner';
|
||||||
import { TodaySectionErrorFallback } from '@/components/today/TodaySectionErrorFallback';
|
import { TodaySectionErrorFallback } from '@/components/today/TodaySectionErrorFallback';
|
||||||
@@ -13,6 +13,7 @@ import { useTodaySummary } from '@/lib/hooks/useTodaySummary';
|
|||||||
|
|
||||||
export default function TodayPage() {
|
export default function TodayPage() {
|
||||||
const t = useTranslations('today');
|
const t = useTranslations('today');
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
const { currentOrganization } = useAuth();
|
const { currentOrganization } = useAuth();
|
||||||
const orgId = currentOrganization?.id;
|
const orgId = currentOrganization?.id;
|
||||||
const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId);
|
const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId);
|
||||||
@@ -54,7 +55,7 @@ export default function TodayPage() {
|
|||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<TodayLoadErrorBanner
|
<TodayLoadErrorBanner
|
||||||
message={formatApiErrorMessage(error, t('loadError'))}
|
message={getUserFacingError(error, tErrors, t('loadError'))}
|
||||||
retryLabel={t('retryLoad')}
|
retryLabel={t('retryLoad')}
|
||||||
onRetry={() => void reload()}
|
onRetry={() => void reload()}
|
||||||
isRetrying={loading && Boolean(data)}
|
isRetrying={loading && Boolean(data)}
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import { Link, useRouter } from '@/i18n/navigation';
|
|||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Input } from '@/components/ui/shared/Input';
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { staffApi } from '@/lib/api/staff';
|
import { staffApi } from '@/lib/api/staff';
|
||||||
|
|
||||||
function AcceptInviteContent() {
|
function AcceptInviteContent() {
|
||||||
const t = useTranslations('auth');
|
const t = useTranslations('auth');
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const token = useMemo(() => params.get('token') || '', [params]);
|
const token = useMemo(() => params.get('token') || '', [params]);
|
||||||
@@ -49,8 +51,7 @@ function AcceptInviteContent() {
|
|||||||
setSuccess(t('invitationAlreadyAccepted'));
|
setSuccess(t('invitationAlreadyAccepted'));
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : '';
|
setError(getUserFacingError(e, tErrors, t('errorLoadInvitation')));
|
||||||
setError(message || t('errorLoadInvitation'));
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -86,8 +87,7 @@ function AcceptInviteContent() {
|
|||||||
router.replace('/login');
|
router.replace('/login');
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : '';
|
setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation')));
|
||||||
setError(message || t('errorAcceptInvitation'));
|
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/shared/Button';
|
|||||||
import { Input } from '@/components/ui/shared/Input';
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
|
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
|
||||||
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
|
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
|
||||||
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { organizationApi } from '@/lib/api/organization';
|
import { organizationApi } from '@/lib/api/organization';
|
||||||
|
|
||||||
type AcceptOrganizationInviteForm = {
|
type AcceptOrganizationInviteForm = {
|
||||||
@@ -27,6 +28,7 @@ type AcceptOrganizationInviteForm = {
|
|||||||
|
|
||||||
function AcceptOrganizationInviteContent() {
|
function AcceptOrganizationInviteContent() {
|
||||||
const t = useTranslations('auth');
|
const t = useTranslations('auth');
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const tValidation = useTranslations('validation');
|
const tValidation = useTranslations('validation');
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
@@ -115,8 +117,7 @@ function AcceptOrganizationInviteContent() {
|
|||||||
setSuccess(t('invitationAlreadyAccepted'));
|
setSuccess(t('invitationAlreadyAccepted'));
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : '';
|
setError(getUserFacingError(e, tErrors, t('errorLoadInvitation')));
|
||||||
setError(message || t('errorLoadInvitation'));
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -148,8 +149,7 @@ function AcceptOrganizationInviteContent() {
|
|||||||
setSuccess(t('organizationAcceptedRedirect'));
|
setSuccess(t('organizationAcceptedRedirect'));
|
||||||
setTimeout(() => router.replace('/login'), 1000);
|
setTimeout(() => router.replace('/login'), 1000);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : '';
|
setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation')));
|
||||||
setError(message || t('errorAcceptInvitation'));
|
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { Link, useRouter } from '@/i18n/navigation';
|
import { Link, useRouter } from '@/i18n/navigation';
|
||||||
import { Phone, ShieldCheck } from 'lucide-react';
|
import { Phone, ShieldCheck } from 'lucide-react';
|
||||||
import { authApi } from '@/lib/api/auth';
|
import { authApi } from '@/lib/api/auth';
|
||||||
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { AuthPageShell } from '@/components/ui/auth/AuthPageShell';
|
import { AuthPageShell } from '@/components/ui/auth/AuthPageShell';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
@@ -29,6 +30,7 @@ export default function ForgotPasswordPage() {
|
|||||||
const t = useTranslations('auth');
|
const t = useTranslations('auth');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const tValidation = useTranslations('validation');
|
const tValidation = useTranslations('validation');
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { refreshSession } = useAuth();
|
const { refreshSession } = useAuth();
|
||||||
const [step, setStep] = useState<'mobile' | 'code'>('mobile');
|
const [step, setStep] = useState<'mobile' | 'code'>('mobile');
|
||||||
@@ -76,8 +78,7 @@ export default function ForgotPasswordPage() {
|
|||||||
setSentMobile(mobile);
|
setSentMobile(mobile);
|
||||||
setStep('code');
|
setStep('code');
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : t('codeSendFailed');
|
setError(getUserFacingError(err, tErrors, t('codeSendFailed')));
|
||||||
setError(message || t('codeSendFailed'));
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
}
|
}
|
||||||
@@ -116,8 +117,7 @@ export default function ForgotPasswordPage() {
|
|||||||
await refreshSession();
|
await refreshSession();
|
||||||
router.push('/settings/account?reset=1');
|
router.push('/settings/account?reset=1');
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : t('verifyFailed');
|
setError(getUserFacingError(err, tErrors, t('verifyFailed')));
|
||||||
setError(message || t('verifyFailed'));
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsVerifying(false);
|
setIsVerifying(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import * as z from 'zod';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { Mail, Lock } from 'lucide-react';
|
import { Mail, Lock } from 'lucide-react';
|
||||||
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { getRememberedEmail } from '@/lib/auth/rememberMe';
|
import { getRememberedEmail } from '@/lib/auth/rememberMe';
|
||||||
import { AuthPageShell } from '@/components/ui/auth/AuthPageShell';
|
import { AuthPageShell } from '@/components/ui/auth/AuthPageShell';
|
||||||
@@ -25,6 +26,7 @@ export default function LoginPage() {
|
|||||||
const t = useTranslations('auth');
|
const t = useTranslations('auth');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const tValidation = useTranslations('validation');
|
const tValidation = useTranslations('validation');
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
const { login, isLoading, user, isAuthReady } = useAuth();
|
const { login, isLoading, user, isAuthReady } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -67,8 +69,7 @@ export default function LoginPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
await login(data.email, data.password, data.rememberMe);
|
await login(data.email, data.password, data.rememberMe);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : t('invalidCredentials');
|
setError(getUserFacingError(err, tErrors, t('invalidCredentials')));
|
||||||
setError(message || t('invalidCredentials'));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as z from 'zod';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Link } from '@/i18n/navigation';
|
import { Link } from '@/i18n/navigation';
|
||||||
import { Mail, Lock, User, Phone } from 'lucide-react';
|
import { Mail, Lock, User, Phone } from 'lucide-react';
|
||||||
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { AuthPageShell } from '@/components/ui/auth/AuthPageShell';
|
import { AuthPageShell } from '@/components/ui/auth/AuthPageShell';
|
||||||
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
|
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
|
||||||
@@ -36,6 +37,7 @@ export default function RegisterPage() {
|
|||||||
const t = useTranslations('auth');
|
const t = useTranslations('auth');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const tValidation = useTranslations('validation');
|
const tValidation = useTranslations('validation');
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
const { registerTrial, isLoading } = useAuth();
|
const { registerTrial, isLoading } = useAuth();
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -110,8 +112,7 @@ export default function RegisterPage() {
|
|||||||
data.organizationType,
|
data.organizationType,
|
||||||
);
|
);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : t('registrationFailed');
|
setError(getUserFacingError(err, tErrors, t('registrationFailed')));
|
||||||
setError(message || t('registrationFailed'));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,123 @@
|
|||||||
export function formatApiErrorMessage(err: unknown, fallback: string): string {
|
import { asApiError, isApiError, type ApiError, type ApiErrorDetail } from '@/types/api';
|
||||||
if (err && typeof err === 'object' && 'message' in err) {
|
|
||||||
const m = (err as { message: unknown }).message;
|
type TranslateFn = (key: string) => string;
|
||||||
if (Array.isArray(m)) {
|
|
||||||
return m.filter(Boolean).join(', ');
|
const STATUS_FALLBACK_KEYS: Record<number, string> = {
|
||||||
|
401: 'AUTH_UNAUTHORIZED',
|
||||||
|
403: 'PERMISSION_DENIED',
|
||||||
|
404: 'NOT_FOUND',
|
||||||
|
409: 'CONFLICT',
|
||||||
|
500: 'INTERNAL_ERROR',
|
||||||
|
};
|
||||||
|
|
||||||
|
function translateKey(t: TranslateFn, key: string): string | null {
|
||||||
|
const translated = t(key);
|
||||||
|
return translated !== key ? translated : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValidationDetails(details: ApiErrorDetail[], t: TranslateFn): string | null {
|
||||||
|
const messages = details
|
||||||
|
.map((detail) => translateKey(t, detail.code))
|
||||||
|
.filter((message): message is string => Boolean(message));
|
||||||
|
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
if (typeof m === 'string' && m.trim()) {
|
|
||||||
return m;
|
return messages.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFromStatus(statusCode: number, t: TranslateFn): string | null {
|
||||||
|
const key = STATUS_FALLBACK_KEYS[statusCode] ?? (statusCode >= 500 ? 'INTERNAL_ERROR' : null);
|
||||||
|
return key ? translateKey(t, key) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLegacyError(value: unknown): ApiError | null {
|
||||||
|
if (isApiError(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object' && value !== null && 'statusCode' in value) {
|
||||||
|
const statusCode =
|
||||||
|
typeof (value as { statusCode?: unknown }).statusCode === 'number'
|
||||||
|
? (value as { statusCode: number }).statusCode
|
||||||
|
: 500;
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusCode,
|
||||||
|
code:
|
||||||
|
statusCode === 401
|
||||||
|
? 'AUTH_UNAUTHORIZED'
|
||||||
|
: statusCode === 403
|
||||||
|
? 'PERMISSION_DENIED'
|
||||||
|
: statusCode >= 500
|
||||||
|
? 'INTERNAL_ERROR'
|
||||||
|
: 'BAD_REQUEST',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Error) {
|
||||||
|
if (value.message === 'Network Error') {
|
||||||
|
return { statusCode: 0, code: 'NETWORK_ERROR' };
|
||||||
|
}
|
||||||
|
if (value.message.includes('timeout')) {
|
||||||
|
return { statusCode: 0, code: 'TIMEOUT' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps API error codes (and status fallbacks) to translated user-facing text. */
|
||||||
|
export function getUserFacingError(
|
||||||
|
err: unknown,
|
||||||
|
t: TranslateFn,
|
||||||
|
featureFallback?: string,
|
||||||
|
): string {
|
||||||
|
const apiError = asApiError(err) ?? normalizeLegacyError(err);
|
||||||
|
|
||||||
|
if (apiError?.code === 'VALIDATION_FAILED' && apiError.details?.length) {
|
||||||
|
const validationMessage = formatValidationDetails(apiError.details, t);
|
||||||
|
if (validationMessage) {
|
||||||
|
return validationMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiError?.code) {
|
||||||
|
const codeMessage = translateKey(t, apiError.code);
|
||||||
|
if (codeMessage) {
|
||||||
|
return codeMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiError?.statusCode) {
|
||||||
|
const statusMessage = resolveFromStatus(apiError.statusCode, t);
|
||||||
|
if (statusMessage) {
|
||||||
|
return statusMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (featureFallback?.trim()) {
|
||||||
|
return featureFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return translateKey(t, 'GENERIC') ?? 'Something went wrong';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Prefer getUserFacingError(err, tErrors, fallback). */
|
||||||
|
export function formatApiErrorMessage(
|
||||||
|
err: unknown,
|
||||||
|
fallback: string,
|
||||||
|
t?: TranslateFn,
|
||||||
|
): string {
|
||||||
|
if (t) {
|
||||||
|
return getUserFacingError(err, t, fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiError = asApiError(err);
|
||||||
|
if (apiError?.code) {
|
||||||
|
return apiError.code;
|
||||||
|
}
|
||||||
|
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useCallback, useEffect, useState, type KeyboardEvent } from 'react';
|
import { useCallback, useEffect, useState, type KeyboardEvent } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Eye, EyeOff, Send } from 'lucide-react';
|
import { Eye, EyeOff, Send } from 'lucide-react';
|
||||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import type { LabCaseComment } from '@/types/cases';
|
import type { LabCaseComment } from '@/types/cases';
|
||||||
|
|
||||||
interface LabCaseCommentsPanelProps {
|
interface LabCaseCommentsPanelProps {
|
||||||
@@ -36,6 +36,7 @@ export function LabCaseCommentsPanel({
|
|||||||
onComposerValueChange,
|
onComposerValueChange,
|
||||||
}: LabCaseCommentsPanelProps) {
|
}: LabCaseCommentsPanelProps) {
|
||||||
const t = useTranslations('caseComments');
|
const t = useTranslations('caseComments');
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
const [comments, setComments] = useState<LabCaseComment[]>([]);
|
const [comments, setComments] = useState<LabCaseComment[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [posting, setPosting] = useState(false);
|
const [posting, setPosting] = useState(false);
|
||||||
@@ -48,7 +49,7 @@ export function LabCaseCommentsPanel({
|
|||||||
const items = await loadComments();
|
const items = await loadComments();
|
||||||
setComments(items);
|
setComments(items);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
onError?.(formatApiErrorMessage(error, t('errorLoad')));
|
onError?.(getUserFacingError(error, tErrors, t('errorLoad')));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -68,7 +69,7 @@ export function LabCaseCommentsPanel({
|
|||||||
setBody('');
|
setBody('');
|
||||||
setVisibleToClinic(false);
|
setVisibleToClinic(false);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
onError?.(formatApiErrorMessage(error, t('errorPost')));
|
onError?.(getUserFacingError(error, tErrors, t('errorPost')));
|
||||||
} finally {
|
} finally {
|
||||||
setPosting(false);
|
setPosting(false);
|
||||||
}
|
}
|
||||||
@@ -87,7 +88,7 @@ export function LabCaseCommentsPanel({
|
|||||||
const updated = await onToggleVisibility(comment.id, !comment.visibleToClinic);
|
const updated = await onToggleVisibility(comment.id, !comment.visibleToClinic);
|
||||||
setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
|
setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
onError?.(formatApiErrorMessage(error, t('errorToggle')));
|
onError?.(getUserFacingError(error, tErrors, t('errorToggle')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { canEditCases } from '@/components/shared/permissions';
|
import { canEditCases } from '@/components/shared/permissions';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
@@ -37,6 +37,7 @@ export function ConnectionCaseHistoryContent({
|
|||||||
onBack,
|
onBack,
|
||||||
}: ConnectionCaseHistoryContentProps) {
|
}: ConnectionCaseHistoryContentProps) {
|
||||||
const t = useTranslations('organizations');
|
const t = useTranslations('organizations');
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
const tCases = useTranslations('cases');
|
const tCases = useTranslations('cases');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const { currentOrganization, user } = useAuth();
|
const { currentOrganization, user } = useAuth();
|
||||||
@@ -102,7 +103,7 @@ export function ConnectionCaseHistoryContent({
|
|||||||
setPagination(response.data.pagination);
|
setPagination(response.data.pagination);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
showError(formatApiErrorMessage(error, tRef.current('caseHistoryErrorLoadList')));
|
showError(getUserFacingError(error, tErrors, tRef.current('caseHistoryErrorLoadList')));
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoadingList(false);
|
if (!cancelled) setLoadingList(false);
|
||||||
}
|
}
|
||||||
@@ -142,7 +143,7 @@ export function ConnectionCaseHistoryContent({
|
|||||||
setSelectedCase(response.data);
|
setSelectedCase(response.data);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
showError(formatApiErrorMessage(error, tRef.current('caseHistoryErrorLoadDetail')));
|
showError(getUserFacingError(error, tErrors, tRef.current('caseHistoryErrorLoadDetail')));
|
||||||
setSelectedCase(null);
|
setSelectedCase(null);
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoadingDetail(false);
|
if (!cancelled) setLoadingDetail(false);
|
||||||
@@ -182,7 +183,7 @@ export function ConnectionCaseHistoryContent({
|
|||||||
setSelectedCase(response.data);
|
setSelectedCase(response.data);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setSelectedCase(previousCase);
|
setSelectedCase(previousCase);
|
||||||
showError(formatApiErrorMessage(error, tCases('errorUpdateTask')));
|
showError(getUserFacingError(error, tErrors, tCases('errorUpdateTask')));
|
||||||
} finally {
|
} finally {
|
||||||
setUpdatingImportant(false);
|
setUpdatingImportant(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
import { canCreateOrganizationFromCurrentOrg } from '@/components/shared/permissions';
|
import { canCreateOrganizationFromCurrentOrg } from '@/components/shared/permissions';
|
||||||
import { Building2, Beaker, Mail } from 'lucide-react';
|
import { Building2, Beaker, Mail } from 'lucide-react';
|
||||||
@@ -12,13 +13,14 @@ export function OrganizationSelectorContent() {
|
|||||||
const t = useTranslations('organizations');
|
const t = useTranslations('organizations');
|
||||||
const tAuth = useTranslations('auth');
|
const tAuth = useTranslations('auth');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
const {
|
const {
|
||||||
organizations,
|
organizations,
|
||||||
currentOrganization,
|
currentOrganization,
|
||||||
selectOrganization,
|
selectOrganization,
|
||||||
createOrganization,
|
createOrganization,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
apiError,
|
||||||
clearError,
|
clearError,
|
||||||
} = useAuth();
|
} = useAuth();
|
||||||
const canCreateOrganization = useMemo(
|
const canCreateOrganization = useMemo(
|
||||||
@@ -82,6 +84,12 @@ export function OrganizationSelectorContent() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{apiError && (
|
||||||
|
<div className="p-3 bg-red-950/30 border border-red-600/40 rounded-[var(--radius-md)]">
|
||||||
|
<p className="text-sm text-red-600">{getUserFacingError(apiError, tErrors)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{canCreateOrganization && isCreateOpen && (
|
{canCreateOrganization && isCreateOpen && (
|
||||||
<div className="surface-card p-4 sm:p-6 space-y-4">
|
<div className="surface-card p-4 sm:p-6 space-y-4">
|
||||||
<Input
|
<Input
|
||||||
@@ -128,11 +136,6 @@ export function OrganizationSelectorContent() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && (
|
|
||||||
<div className="p-3 bg-red-950/30 border border-red-600/40 rounded-[var(--radius-md)]">
|
|
||||||
<p className="text-sm text-red-600">{error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:justify-end">
|
<div className="flex flex-col sm:flex-row sm:justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
|
|||||||
import { treatmentsApi } from '@/lib/api/treatments';
|
import { treatmentsApi } from '@/lib/api/treatments';
|
||||||
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
|
import { pickAutoAppointment } from '@/components/shared/treatmentSelection';
|
||||||
import { canEditTreatment, canViewTreatment } from '@/components/shared/permissions';
|
import { canEditTreatment, canViewTreatment } from '@/components/shared/permissions';
|
||||||
import { formatApiErrorMessage } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { useToast } from '@/lib/hooks/useToast';
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import type { Organization } from '@/types/organization';
|
import type { Organization } from '@/types/organization';
|
||||||
import type { AppointmentRecord } from '@/types/appointment';
|
import type { AppointmentRecord } from '@/types/appointment';
|
||||||
@@ -264,6 +264,7 @@ export function TreatmentWorkspace({
|
|||||||
initialAppointmentId = null,
|
initialAppointmentId = null,
|
||||||
}: TreatmentWorkspaceProps) {
|
}: TreatmentWorkspaceProps) {
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
|
const tErrors = useTranslations('errors');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showError, showSuccess, messages: toastMessages } = useToast();
|
const { showError, showSuccess, messages: toastMessages } = useToast();
|
||||||
const canView = canViewTreatment(currentOrganization);
|
const canView = canViewTreatment(currentOrganization);
|
||||||
@@ -493,7 +494,7 @@ export function TreatmentWorkspace({
|
|||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
showError(formatApiErrorMessage(error, t('errorLoadAppointments')));
|
showError(getUserFacingError(error, tErrors, t('errorLoadAppointments')));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setApptsLoading(false);
|
if (!cancelled) setApptsLoading(false);
|
||||||
@@ -534,7 +535,7 @@ export function TreatmentWorkspace({
|
|||||||
);
|
);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
showError(formatApiErrorMessage(error, t('errorLoadOrgs')));
|
showError(getUserFacingError(error, tErrors, t('errorLoadOrgs')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -563,7 +564,7 @@ export function TreatmentWorkspace({
|
|||||||
if (!cancelled) setHistory(response.data);
|
if (!cancelled) setHistory(response.data);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
showError(formatApiErrorMessage(error, t('errorLoadHistory')));
|
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setHistoryLoading(false);
|
if (!cancelled) setHistoryLoading(false);
|
||||||
@@ -621,7 +622,7 @@ export function TreatmentWorkspace({
|
|||||||
setSaveStatus('idle');
|
setSaveStatus('idle');
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
showError(formatApiErrorMessage(error, t('errorLoadDraft')));
|
showError(getUserFacingError(error, tErrors, t('errorLoadDraft')));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -693,7 +694,7 @@ export function TreatmentWorkspace({
|
|||||||
setSaveStatus('saved');
|
setSaveStatus('saved');
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
setSaveStatus('error');
|
setSaveStatus('error');
|
||||||
showError(formatApiErrorMessage(error, t('errorSaveDraft')));
|
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
saveInFlightRef.current = false;
|
saveInFlightRef.current = false;
|
||||||
@@ -711,7 +712,7 @@ export function TreatmentWorkspace({
|
|||||||
const response = await treatmentsApi.listPatientHistory(patientId);
|
const response = await treatmentsApi.listPatientHistory(patientId);
|
||||||
setHistory(response.data);
|
setHistory(response.data);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
showError(formatApiErrorMessage(error, t('errorLoadHistory')));
|
showError(getUserFacingError(error, tErrors, t('errorLoadHistory')));
|
||||||
}
|
}
|
||||||
}, [showError, t]);
|
}, [showError, t]);
|
||||||
|
|
||||||
@@ -864,7 +865,7 @@ export function TreatmentWorkspace({
|
|||||||
);
|
);
|
||||||
showSuccess(t('successFilesUploaded', { count: uploaded.data.length }));
|
showSuccess(t('successFilesUploaded', { count: uploaded.data.length }));
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
showError(formatApiErrorMessage(error, t('errorUpload')));
|
showError(getUserFacingError(error, tErrors, t('errorUpload')));
|
||||||
} finally {
|
} finally {
|
||||||
setUploadBusyDetailId(null);
|
setUploadBusyDetailId(null);
|
||||||
}
|
}
|
||||||
@@ -954,7 +955,7 @@ export function TreatmentWorkspace({
|
|||||||
const saved = await persistDraft({ force: true });
|
const saved = await persistDraft({ force: true });
|
||||||
await persistLabCases(saved, cleaned);
|
await persistLabCases(saved, cleaned);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
|
showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments')));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
@@ -999,7 +1000,7 @@ export function TreatmentWorkspace({
|
|||||||
const saved = await persistDraft({ force: true });
|
const saved = await persistDraft({ force: true });
|
||||||
await persistLabCases(saved, updatedLabCases);
|
await persistLabCases(saved, updatedLabCases);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
|
showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments')));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1016,7 +1017,7 @@ export function TreatmentWorkspace({
|
|||||||
const saved = await persistDraft({ force: true });
|
const saved = await persistDraft({ force: true });
|
||||||
await persistLabCases(saved, updatedLabCases);
|
await persistLabCases(saved, updatedLabCases);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
showError(formatApiErrorMessage(error, t('errorSaveLabShipments')));
|
showError(getUserFacingError(error, tErrors, t('errorSaveLabShipments')));
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
activeDetailId,
|
activeDetailId,
|
||||||
@@ -1104,7 +1105,7 @@ export function TreatmentWorkspace({
|
|||||||
});
|
});
|
||||||
showSuccess(t('successCaseSent'));
|
showSuccess(t('successCaseSent'));
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
showError(formatApiErrorMessage(error, t('errorSendCase')));
|
showError(getUserFacingError(error, tErrors, t('errorSendCase')));
|
||||||
} finally {
|
} finally {
|
||||||
setSendBusyId(null);
|
setSendBusyId(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,13 +88,34 @@ apiClient.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const responseData = error.response?.data as
|
||||||
|
| { success?: false; error?: { code?: string; statusCode?: number; details?: ApiError['details'] } }
|
||||||
|
| { message?: string | string[] }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (responseData && 'error' in responseData && responseData.error?.code) {
|
||||||
|
const apiError: ApiError = {
|
||||||
|
statusCode: responseData.error.statusCode ?? error.response?.status ?? 500,
|
||||||
|
code: responseData.error.code,
|
||||||
|
details: responseData.error.details,
|
||||||
|
};
|
||||||
|
return Promise.reject(apiError);
|
||||||
|
}
|
||||||
|
|
||||||
const apiError: ApiError = {
|
const apiError: ApiError = {
|
||||||
statusCode: error.response?.status || 500,
|
statusCode: error.response?.status || 500,
|
||||||
message:
|
code:
|
||||||
(error.response?.data as any)?.message ||
|
error.response?.status === 401
|
||||||
error.message ||
|
? 'AUTH_UNAUTHORIZED'
|
||||||
'An unexpected error occurred',
|
: error.response?.status === 403
|
||||||
error: (error.response?.data as any)?.error,
|
? 'PERMISSION_DENIED'
|
||||||
|
: error.response?.status === 404
|
||||||
|
? 'NOT_FOUND'
|
||||||
|
: error.response?.status === 409
|
||||||
|
? 'CONFLICT'
|
||||||
|
: (error.response?.status ?? 500) >= 500
|
||||||
|
? 'INTERNAL_ERROR'
|
||||||
|
: 'BAD_REQUEST',
|
||||||
};
|
};
|
||||||
|
|
||||||
return Promise.reject(apiError);
|
return Promise.reject(apiError);
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ import {
|
|||||||
rememberAccessTokenExpiresAt,
|
rememberAccessTokenExpiresAt,
|
||||||
startProactiveSessionRefresh,
|
startProactiveSessionRefresh,
|
||||||
} from '@/lib/auth/proactiveRefresh';
|
} from '@/lib/auth/proactiveRefresh';
|
||||||
|
import { asApiError, legacyStatusCode, type ApiError } from '@/types/api';
|
||||||
|
|
||||||
|
function toApiError(err: unknown): ApiError {
|
||||||
|
return (
|
||||||
|
asApiError(err) ?? {
|
||||||
|
statusCode: legacyStatusCode(err) ?? 500,
|
||||||
|
code:
|
||||||
|
legacyStatusCode(err) === 401
|
||||||
|
? 'AUTH_UNAUTHORIZED'
|
||||||
|
: legacyStatusCode(err) === 403
|
||||||
|
? 'PERMISSION_DENIED'
|
||||||
|
: 'INTERNAL_ERROR',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@@ -23,7 +38,7 @@ interface AuthContextType {
|
|||||||
currentOrganization: Organization | null;
|
currentOrganization: Organization | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAuthReady: boolean;
|
isAuthReady: boolean;
|
||||||
error: string | null;
|
apiError: ApiError | null;
|
||||||
registerTrial: (
|
registerTrial: (
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
@@ -56,7 +71,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [currentOrganization, setCurrentOrganization] = useState<Organization | null>(null);
|
const [currentOrganization, setCurrentOrganization] = useState<Organization | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isAuthReady, setIsAuthReady] = useState(false); // ✅ KEY FIX
|
const [isAuthReady, setIsAuthReady] = useState(false); // ✅ KEY FIX
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [apiError, setApiError] = useState<ApiError | null>(null);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -135,9 +150,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const status =
|
const status = legacyStatusCode(err);
|
||||||
(err as { statusCode?: number })?.statusCode ??
|
|
||||||
(err as { response?: { status?: number } })?.response?.status;
|
|
||||||
|
|
||||||
// Access token may have expired while refresh cookie is still valid (e.g. JWT_EXPIRES_IN=15m).
|
// Access token may have expired while refresh cookie is still valid (e.g. JWT_EXPIRES_IN=15m).
|
||||||
if (status === 401) {
|
if (status === 401) {
|
||||||
@@ -251,7 +264,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setApiError(null);
|
||||||
|
|
||||||
const response = await authApi.registerTrial({
|
const response = await authApi.registerTrial({
|
||||||
email,
|
email,
|
||||||
@@ -282,7 +295,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || t('errorRegistrationFailed'));
|
setApiError(toApiError(err));
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -297,7 +310,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setApiError(null);
|
||||||
|
|
||||||
const response = await authApi.login({ email, password, rememberMe });
|
const response = await authApi.login({ email, password, rememberMe });
|
||||||
|
|
||||||
@@ -325,7 +338,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || t('errorLoginFailed'));
|
setApiError(toApiError(err));
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -348,7 +361,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setUser(null);
|
setUser(null);
|
||||||
setOrganizations([]);
|
setOrganizations([]);
|
||||||
setCurrentOrganization(null);
|
setCurrentOrganization(null);
|
||||||
setError(null);
|
setApiError(null);
|
||||||
clearAccessTokenExpiresAt();
|
clearAccessTokenExpiresAt();
|
||||||
setIsAuthReady(true);
|
setIsAuthReady(true);
|
||||||
router.replace('/');
|
router.replace('/');
|
||||||
@@ -387,7 +400,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message);
|
setApiError(toApiError(err));
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -402,7 +415,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setApiError(null);
|
||||||
|
|
||||||
const createResponse = await authApi.createOrganization({
|
const createResponse = await authApi.createOrganization({
|
||||||
organizationName,
|
organizationName,
|
||||||
@@ -420,7 +433,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
return createResponse.data.organization.id as string;
|
return createResponse.data.organization.id as string;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || t('errorCreateOrganization'));
|
setApiError(toApiError(err));
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -431,7 +444,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
await checkAuth();
|
await checkAuth();
|
||||||
}, [checkAuth]);
|
}, [checkAuth]);
|
||||||
|
|
||||||
const clearError = useCallback(() => setError(null), []);
|
const clearError = useCallback(() => setApiError(null), []);
|
||||||
|
|
||||||
const setUserLanguage = useCallback((language: string) => {
|
const setUserLanguage = useCallback((language: string) => {
|
||||||
const normalized = isAppLocale(language) ? language : 'en';
|
const normalized = isAppLocale(language) ? language : 'en';
|
||||||
@@ -445,7 +458,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
currentOrganization,
|
currentOrganization,
|
||||||
isLoading,
|
isLoading,
|
||||||
isAuthReady,
|
isAuthReady,
|
||||||
error,
|
apiError,
|
||||||
registerTrial,
|
registerTrial,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
@@ -461,7 +474,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
currentOrganization,
|
currentOrganization,
|
||||||
isLoading,
|
isLoading,
|
||||||
isAuthReady,
|
isAuthReady,
|
||||||
error,
|
apiError,
|
||||||
registerTrial,
|
registerTrial,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
|
|||||||
@@ -1,5 +1,66 @@
|
|||||||
|
export interface ApiErrorDetail {
|
||||||
|
field: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
statusCode: number;
|
statusCode: number;
|
||||||
message: string | string[];
|
code: string;
|
||||||
error?: string;
|
details?: ApiErrorDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorResponse {
|
||||||
|
success: false;
|
||||||
|
error: {
|
||||||
|
code: string;
|
||||||
|
statusCode: number;
|
||||||
|
details?: ApiErrorDetail[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isApiError(value: unknown): value is ApiError {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'code' in value &&
|
||||||
|
typeof (value as ApiError).code === 'string' &&
|
||||||
|
'statusCode' in value &&
|
||||||
|
typeof (value as ApiError).statusCode === 'number'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asApiError(value: unknown): ApiError | null {
|
||||||
|
if (isApiError(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'response' in value &&
|
||||||
|
typeof (value as { response?: { data?: ApiErrorResponse } }).response?.data === 'object'
|
||||||
|
) {
|
||||||
|
const payload = (value as { response: { data?: ApiErrorResponse } }).response.data;
|
||||||
|
if (payload?.success === false && payload.error?.code) {
|
||||||
|
return {
|
||||||
|
statusCode: payload.error.statusCode,
|
||||||
|
code: payload.error.code,
|
||||||
|
details: payload.error.details,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use ApiError.code — kept for transitional parsing only. */
|
||||||
|
export function legacyStatusCode(value: unknown): number | undefined {
|
||||||
|
if (isApiError(value)) {
|
||||||
|
return value.statusCode;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object' && value !== null && 'statusCode' in value) {
|
||||||
|
const statusCode = (value as { statusCode?: unknown }).statusCode;
|
||||||
|
return typeof statusCode === 'number' ? statusCode : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user