diff --git a/backend/src/common/errors/app.exception.ts b/backend/src/common/errors/app.exception.ts new file mode 100644 index 0000000..0dc49d8 --- /dev/null +++ b/backend/src/common/errors/app.exception.ts @@ -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); + } +} diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts new file mode 100644 index 0000000..b3ed3fc --- /dev/null +++ b/backend/src/common/errors/error-codes.ts @@ -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; +} diff --git a/backend/src/common/errors/http-exception.filter.ts b/backend/src/common/errors/http-exception.filter.ts new file mode 100644 index 0000000..cc9d3d7 --- /dev/null +++ b/backend/src/common/errors/http-exception.filter.ts @@ -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> = { + [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 = { + '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(); + + 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 } : {}), + }, + }; + } +} diff --git a/backend/src/common/errors/index.ts b/backend/src/common/errors/index.ts new file mode 100644 index 0000000..e363097 --- /dev/null +++ b/backend/src/common/errors/index.ts @@ -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'; diff --git a/backend/src/common/errors/validation-exception.factory.ts b/backend/src/common/errors/validation-exception.factory.ts new file mode 100644 index 0000000..4d85008 --- /dev/null +++ b/backend/src/common/errors/validation-exception.factory.ts @@ -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(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; +} diff --git a/backend/src/common/guards/clinic-org.guard.ts b/backend/src/common/guards/clinic-org.guard.ts index 5b16b09..cbe729d 100644 --- a/backend/src/common/guards/clinic-org.guard.ts +++ b/backend/src/common/guards/clinic-org.guard.ts @@ -1,11 +1,12 @@ import { CanActivate, ExecutionContext, + HttpStatus, Injectable, - UnauthorizedException, } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { assertClinicOrganization } from '../../common/organization-type'; +import { AppException, ErrorCode } from '../errors'; @Injectable() export class ClinicOrgGuard implements CanActivate { @@ -16,7 +17,7 @@ export class ClinicOrgGuard implements CanActivate { const organizationId = request.user?.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); diff --git a/backend/src/common/guards/lab-org.guard.ts b/backend/src/common/guards/lab-org.guard.ts index 69bc829..24130aa 100644 --- a/backend/src/common/guards/lab-org.guard.ts +++ b/backend/src/common/guards/lab-org.guard.ts @@ -1,11 +1,12 @@ import { CanActivate, ExecutionContext, + HttpStatus, Injectable, - UnauthorizedException, } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { assertLabOrganization } from '../../common/organization-type'; +import { AppException, ErrorCode } from '../errors'; @Injectable() export class LabOrgGuard implements CanActivate { @@ -16,7 +17,7 @@ export class LabOrgGuard implements CanActivate { const organizationId = request.user?.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); diff --git a/backend/src/common/organization-type.ts b/backend/src/common/organization-type.ts index e6cef18..86167e8 100644 --- a/backend/src/common/organization-type.ts +++ b/backend/src/common/organization-type.ts @@ -1,6 +1,7 @@ -import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { HttpStatus } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { ALL_TAB_PERMISSIONS, normalizeTabPermissions } from './permissions'; +import { AppException, ErrorCode } from './errors'; export type OrganizationTypeName = 'CLINIC' | 'LAB'; @@ -82,12 +83,12 @@ export async function getOrganizationTypeName( }); if (!org) { - throw new NotFoundException('Organization not found'); + throw new AppException(ErrorCode.PERMISSION_ORG_NOT_FOUND, HttpStatus.NOT_FOUND); } const name = org.type.name; if (name !== 'CLINIC' && name !== 'LAB') { - throw new ForbiddenException('Unknown organization type'); + throw new AppException(ErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN); } return name; @@ -99,7 +100,7 @@ export async function assertClinicOrganization( ): Promise { const type = await getOrganizationTypeName(prisma, organizationId); 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 { const type = await getOrganizationTypeName(prisma, organizationId); if (type !== 'LAB') { - throw new ForbiddenException('This action is only available for lab organizations'); + throw new AppException(ErrorCode.PERMISSION_LAB_ONLY, HttpStatus.FORBIDDEN); } } diff --git a/backend/src/main.ts b/backend/src/main.ts index a7053f1..4af42c2 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -4,6 +4,10 @@ import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; import cookieParser from 'cookie-parser'; // 👈 Change this line! import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { + HttpExceptionFilter, + validationExceptionFactory, +} from './common/errors'; // At the VERY TOP of main.ts, before anything else const originalConsoleLog = console.log; @@ -21,11 +25,14 @@ console.log = (...args) => { async function bootstrap() { const app = await NestFactory.create(AppModule); + app.useGlobalFilters(new HttpExceptionFilter()); + // Global pipes app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true, + exceptionFactory: validationExceptionFactory, })); // Cookie parser - this is correct for Express diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 94292b8..c29ec9b 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -12,7 +12,6 @@ import { Get, Patch, Put, - UnauthorizedException, } from '@nestjs/common'; import type { Response } from 'express'; import { @@ -28,6 +27,7 @@ import { import { AuthService } from './auth.service'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; +import { AppException, ErrorCode } from '../../common/errors'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; import { CreateOrganizationDto } from './dto/create-organization.dto'; @@ -322,7 +322,7 @@ export class AuthController { const refreshToken = req?.cookies?.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( diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 3e88fce..9a29aa4 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -1,10 +1,8 @@ // backend/src/modules/auth/auth.service.ts import { Injectable, - UnauthorizedException, - BadRequestException, - ConflictException, - ForbiddenException, + HttpStatus, + HttpException, InternalServerErrorException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; @@ -33,6 +31,7 @@ import { } from './dto/forgot-password.dto'; import { normalizeIranMobile } from '../../common/utils/mobile.util'; import { sessionExpiresAtFromNow } from '../../common/jwt-duration'; +import { AppException, ErrorCode } from '../../common/errors'; import * as crypto from 'crypto'; const FORGOT_PASSWORD_PURPOSE = 'forgot_password'; @@ -242,7 +241,7 @@ export class AuthService { const email = registerDto.email.trim().toLowerCase(); 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); @@ -256,9 +255,9 @@ export class AuthService { if (existingUser) { 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 @@ -315,7 +314,7 @@ export class AuthService { const validatedUser = await this.validateUser(email, password); 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); @@ -332,13 +331,11 @@ export class AuthService { }); if (!owner) { - throw new UnauthorizedException('User not found'); + throw new AppException(ErrorCode.AUTH_USER_NOT_FOUND, HttpStatus.UNAUTHORIZED); } if (!currentOrganizationId) { - throw new ForbiddenException( - 'Select an organization before creating a new one.', - ); + throw new AppException(ErrorCode.AUTH_SELECT_ORG_FIRST, HttpStatus.FORBIDDEN); } const currentMembership = await this.prisma.membership.findUnique({ @@ -352,9 +349,7 @@ export class AuthService { }); if (!currentMembership?.isOwner) { - throw new ForbiddenException( - 'Only owners of the current organization can create new organizations.', - ); + throw new AppException(ErrorCode.AUTH_CREATE_ORG_OWNER_ONLY, HttpStatus.FORBIDDEN); } const organization = await this.prisma.$transaction(async (tx) => { @@ -417,7 +412,7 @@ export class AuthService { }); if (!user) { - throw new UnauthorizedException('User not found'); + throw new AppException(ErrorCode.AUTH_USER_NOT_FOUND, HttpStatus.UNAUTHORIZED); } const { passwordHash, ...result } = user; @@ -485,7 +480,7 @@ export class AuthService { // Ensure this is a refresh token 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 @@ -518,7 +513,7 @@ export class AuthService { }); if (!session) { - throw new UnauthorizedException('Invalid refresh token'); + throw new AppException(ErrorCode.AUTH_REFRESH_TOKEN_INVALID, HttpStatus.UNAUTHORIZED); } const organizationId = await this.resolveOrganizationIdForRefresh( @@ -574,10 +569,13 @@ export class AuthService { }, }; } catch (error) { - if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') { - throw new UnauthorizedException('Invalid or expired refresh token'); + if (error instanceof HttpException) { + 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) { - throw new BadRequestException('User not found or invalid password method'); + throw new AppException(ErrorCode.AUTH_USER_NOT_FOUND, HttpStatus.BAD_REQUEST); } if (skipCurrentPassword) { const hasRecentReset = await this.hasRecentPasswordResetVerification(userId); 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 { 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); 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.', }; } catch (error) { - if (error instanceof UnauthorizedException || error instanceof BadRequestException) { + if (error instanceof HttpException) { throw error; } throw new InternalServerErrorException('Failed to change password'); @@ -645,7 +643,7 @@ export class AuthService { async sendForgotPasswordCode(dto: ForgotPasswordSendCodeDto) { 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); @@ -671,7 +669,7 @@ export class AuthService { }); 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(); @@ -701,7 +699,7 @@ export class AuthService { async verifyForgotPasswordCode(dto: ForgotPasswordVerifyDto) { 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); @@ -718,7 +716,7 @@ export class AuthService { }); 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({ @@ -748,7 +746,7 @@ export class AuthService { }); 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( @@ -877,7 +875,7 @@ export class AuthService { }); 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({ @@ -909,7 +907,7 @@ export class AuthService { }); if (!session) { - throw new UnauthorizedException('Session not found or expired'); + throw new AppException(ErrorCode.AUTH_SESSION_EXPIRED, HttpStatus.UNAUTHORIZED); } const { passwordHash, ...user } = session.user; @@ -937,7 +935,7 @@ export class AuthService { }, }; } 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) { - throw new UnauthorizedException('Access denied to this organization'); + throw new AppException(ErrorCode.AUTH_ORG_ACCESS_DENIED, HttpStatus.UNAUTHORIZED); } 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 @@ -1147,7 +1145,7 @@ export class AuthService { const language = dto.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({ @@ -1231,7 +1229,7 @@ export class AuthService { private async getOwnerMembership(userId: string, organizationId: string) { 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({ @@ -1245,7 +1243,7 @@ export class AuthService { }); if (!membership) { - throw new ForbiddenException('Only organization owners can manage participation'); + throw new AppException(ErrorCode.PERMISSION_OWNER_ONLY, HttpStatus.FORBIDDEN); } return membership; @@ -1281,9 +1279,7 @@ export class AuthService { const orgType = getOrgTypeFromMembership(membership); if (!hasActivePlan(membership)) { - throw new ForbiddenException( - 'An active subscription is required to participate in treatments or tasks', - ); + throw new AppException(ErrorCode.PERMISSION_PARTICIPATION_SUBSCRIPTION, HttpStatus.FORBIDDEN); } if (dto.participate) { @@ -1321,7 +1317,7 @@ export class AuthService { async getMyWorkingHours(userId: string, organizationId: string) { const membership = await this.getOwnerMembership(userId, organizationId); 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); } @@ -1333,12 +1329,10 @@ export class AuthService { ) { const membership = await this.getOwnerMembership(userId, organizationId); 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)) { - throw new ForbiddenException( - 'Enable treatment participation before setting working hours', - ); + throw new AppException(ErrorCode.PERMISSION_ENABLE_PARTICIPATION_FIRST, HttpStatus.FORBIDDEN); } return this.staffWorkingHoursService.upsertMyWorkingHours(userId, organizationId, dto); } @@ -1406,9 +1400,7 @@ export class AuthService { }); if (futureAppointment) { - throw new ConflictException( - 'You cannot stop participating in treatments while you have future appointments assigned. Reassign or cancel those appointments first.', - ); + throw new AppException(ErrorCode.CONFLICT_FUTURE_APPOINTMENTS, HttpStatus.CONFLICT); } } else { await assertLabOrganization(this.prisma, membership.organizationId); diff --git a/backend/src/modules/auth/dto/change-password.dto.ts b/backend/src/modules/auth/dto/change-password.dto.ts index 14a9783..0b08fe2 100644 --- a/backend/src/modules/auth/dto/change-password.dto.ts +++ b/backend/src/modules/auth/dto/change-password.dto.ts @@ -1,4 +1,5 @@ import { IsOptional, IsString, MinLength } from 'class-validator'; +import { ErrorCode } from '../../../common/errors'; export class ChangePasswordDto { @IsOptional() @@ -6,6 +7,6 @@ export class ChangePasswordDto { currentPassword?: string; @IsString() - @MinLength(8) + @MinLength(8, { message: ErrorCode.VALIDATION_PASSWORD_TOO_SHORT }) newPassword: string; } diff --git a/backend/src/modules/auth/dto/create-organization.dto.ts b/backend/src/modules/auth/dto/create-organization.dto.ts index 716744d..642bace 100644 --- a/backend/src/modules/auth/dto/create-organization.dto.ts +++ b/backend/src/modules/auth/dto/create-organization.dto.ts @@ -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 { @IsString() + @MinLength(2, { message: ErrorCode.VALIDATION_ORGANIZATION_NAME_REQUIRED }) organizationName: string; - @IsEmail() + @IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID }) organizationEmail: string; - @IsEnum(['CLINIC', 'LAB']) + @IsEnum(['CLINIC', 'LAB'], { message: ErrorCode.VALIDATION_ORGANIZATION_TYPE_INVALID }) organizationType: 'CLINIC' | 'LAB'; @IsOptional() diff --git a/backend/src/modules/auth/dto/forgot-password.dto.ts b/backend/src/modules/auth/dto/forgot-password.dto.ts index 02ce7fe..1dcc935 100644 --- a/backend/src/modules/auth/dto/forgot-password.dto.ts +++ b/backend/src/modules/auth/dto/forgot-password.dto.ts @@ -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 { ErrorCode } from '../../../common/errors'; export class ForgotPasswordSendCodeDto { @IsString() - @Matches(/^[\d+\s()-]+$/, { message: 'Mobile number format is invalid' }) + @Matches(/^[\d+\s()-]+$/, { message: ErrorCode.VALIDATION_MOBILE_INVALID }) mobile: string; static validateMobile(mobile: string): boolean { @@ -13,10 +14,10 @@ export class ForgotPasswordSendCodeDto { export class ForgotPasswordVerifyDto { @IsString() - @Matches(/^[\d+\s()-]+$/, { message: 'Mobile number format is invalid' }) + @Matches(/^[\d+\s()-]+$/, { message: ErrorCode.VALIDATION_MOBILE_INVALID }) mobile: string; @IsString() - @Length(5, 6) + @Length(5, 6, { message: ErrorCode.VALIDATION_FIELD_REQUIRED }) code: string; } diff --git a/backend/src/modules/auth/dto/login.dto.ts b/backend/src/modules/auth/dto/login.dto.ts index 32df602..a253e18 100644 --- a/backend/src/modules/auth/dto/login.dto.ts +++ b/backend/src/modules/auth/dto/login.dto.ts @@ -1,32 +1,15 @@ -// backend/src/modules/auth/dto/login.dto.ts -import { IsBoolean, IsEmail, IsOptional, IsString, MinLength } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; +import { IsEmail, IsString, MinLength, IsOptional, IsBoolean } from 'class-validator'; +import { ErrorCode } from '../../../common/errors'; export class LoginDto { - @ApiProperty({ - description: 'User email address', - example: 'user@example.com', - required: true, - }) - @IsEmail({}, { message: 'Please provide a valid email address' }) + @IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID }) email: string; - @ApiProperty({ - description: 'User password (min 6 characters)', - example: 'password123', - required: true, - minLength: 6, - }) @IsString() - @MinLength(6, { message: 'Password must be at least 6 characters long' }) + @MinLength(6, { message: ErrorCode.VALIDATION_PASSWORD_TOO_SHORT }) password: string; - @ApiProperty({ - description: 'Keep the user signed in for 30 days on this device', - required: false, - default: false, - }) @IsOptional() @IsBoolean() rememberMe?: boolean; -} \ No newline at end of file +} diff --git a/backend/src/modules/auth/dto/register.dto.ts b/backend/src/modules/auth/dto/register.dto.ts index f3a795f..0eb3f8f 100644 --- a/backend/src/modules/auth/dto/register.dto.ts +++ b/backend/src/modules/auth/dto/register.dto.ts @@ -1,31 +1,34 @@ import { IsEmail, IsString, MinLength, IsEnum, Matches } from 'class-validator'; import { isValidIranMobile } from '../../../common/utils/mobile.util'; +import { ErrorCode } from '../../../common/errors'; export class RegisterDto { - @IsEmail() + @IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID }) email: string; @IsString() - @Matches(/^[\d+\s()-]+$/, { message: 'Mobile number format is invalid' }) + @Matches(/^[\d+\s()-]+$/, { message: ErrorCode.VALIDATION_MOBILE_INVALID }) mobile: string; @IsString() - @MinLength(8) + @MinLength(8, { message: ErrorCode.VALIDATION_PASSWORD_TOO_SHORT }) password: string; @IsString() + @MinLength(2, { message: ErrorCode.VALIDATION_NAME_TOO_SHORT }) name: string; @IsString() + @MinLength(2, { message: ErrorCode.VALIDATION_ORGANIZATION_NAME_REQUIRED }) organizationName: string; - @IsEmail() + @IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID }) organizationEmail: string; - @IsEnum(['CLINIC', 'LAB']) + @IsEnum(['CLINIC', 'LAB'], { message: ErrorCode.VALIDATION_ORGANIZATION_TYPE_INVALID }) organizationType: 'CLINIC' | 'LAB'; static isValidMobile(mobile: string): boolean { return isValidIranMobile(mobile); } -} \ No newline at end of file +} diff --git a/backend/src/modules/auth/dto/update-language.dto.ts b/backend/src/modules/auth/dto/update-language.dto.ts index 5bc91c1..16f9cbe 100644 --- a/backend/src/modules/auth/dto/update-language.dto.ts +++ b/backend/src/modules/auth/dto/update-language.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsIn, IsString } from 'class-validator'; +import { ErrorCode } from '../../../common/errors'; export const SUPPORTED_USER_LANGUAGES = ['en', 'fa', 'nl'] as const; export type SupportedUserLanguage = (typeof SUPPORTED_USER_LANGUAGES)[number]; @@ -8,7 +9,7 @@ export class UpdateLanguageDto { @ApiProperty({ enum: SUPPORTED_USER_LANGUAGES, example: 'en' }) @IsString() @IsIn(SUPPORTED_USER_LANGUAGES, { - message: 'Language must be one of: en, fa, nl', + message: ErrorCode.VALIDATION_LANGUAGE_INVALID, }) language: SupportedUserLanguage; } diff --git a/backend/src/modules/auth/strategies/jwt.strategy.ts b/backend/src/modules/auth/strategies/jwt.strategy.ts index 412ad61..811a262 100644 --- a/backend/src/modules/auth/strategies/jwt.strategy.ts +++ b/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -1,10 +1,11 @@ // backend/src/modules/auth/strategies/jwt.strategy.ts import { Strategy } from 'passport-jwt'; import { PassportStrategy } from '@nestjs/passport'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../../../../prisma/prisma.service'; import { Request } from 'express'; +import { AppException, ErrorCode } from '../../../common/errors'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { @@ -27,7 +28,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); if (!user) { - throw new UnauthorizedException(); + throw new AppException(ErrorCode.AUTH_UNAUTHORIZED, HttpStatus.UNAUTHORIZED); } const { passwordHash, ...result } = user; diff --git a/backend/src/modules/auth/strategies/local.strategy.ts b/backend/src/modules/auth/strategies/local.strategy.ts index fcec4b5..c888686 100644 --- a/backend/src/modules/auth/strategies/local.strategy.ts +++ b/backend/src/modules/auth/strategies/local.strategy.ts @@ -1,8 +1,8 @@ -// backend/src/modules/auth/strategies/local.strategy.ts import { Strategy } from 'passport-local'; import { PassportStrategy } from '@nestjs/passport'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { AuthService } from '../auth.service'; +import { AppException, ErrorCode } from '../../../common/errors'; @Injectable() export class LocalStrategy extends PassportStrategy(Strategy) { @@ -13,8 +13,8 @@ export class LocalStrategy extends PassportStrategy(Strategy) { async validate(email: string, password: string): Promise { const user = await this.authService.validateUser(email, password); if (!user) { - throw new UnauthorizedException('Invalid credentials'); + throw new AppException(ErrorCode.AUTH_INVALID_CREDENTIALS, HttpStatus.UNAUTHORIZED); } return user; } -} \ No newline at end of file +} diff --git a/backend/src/modules/organization/dto/accept-organization-invite.dto.ts b/backend/src/modules/organization/dto/accept-organization-invite.dto.ts index 2ef24f4..4f9c562 100644 --- a/backend/src/modules/organization/dto/accept-organization-invite.dto.ts +++ b/backend/src/modules/organization/dto/accept-organization-invite.dto.ts @@ -1,25 +1,26 @@ import { IsEmail, IsEnum, IsString, MinLength } from 'class-validator'; +import { ErrorCode } from '../../../common/errors'; export class AcceptOrganizationInviteDto { @IsString() - @MinLength(1) + @MinLength(1, { message: ErrorCode.VALIDATION_TOKEN_REQUIRED }) token: string; @IsString() - @MinLength(1) + @MinLength(2, { message: ErrorCode.VALIDATION_ORGANIZATION_NAME_REQUIRED }) organizationName: string; - @IsEmail() + @IsEmail({}, { message: ErrorCode.VALIDATION_EMAIL_INVALID }) organizationEmail: string; - @IsEnum(['CLINIC', 'LAB']) + @IsEnum(['CLINIC', 'LAB'], { message: ErrorCode.VALIDATION_ORGANIZATION_TYPE_INVALID }) organizationType: 'CLINIC' | 'LAB'; @IsString() - @MinLength(1) + @MinLength(2, { message: ErrorCode.VALIDATION_NAME_TOO_SHORT }) ownerName: string; @IsString() - @MinLength(8) + @MinLength(8, { message: ErrorCode.VALIDATION_PASSWORD_TOO_SHORT }) password: string; } diff --git a/backend/src/modules/staff/dto/accept-staff-invite.dto.ts b/backend/src/modules/staff/dto/accept-staff-invite.dto.ts index 03809b5..8a5cd45 100644 --- a/backend/src/modules/staff/dto/accept-staff-invite.dto.ts +++ b/backend/src/modules/staff/dto/accept-staff-invite.dto.ts @@ -1,14 +1,16 @@ import { IsString, MinLength } from 'class-validator'; +import { ErrorCode } from '../../../common/errors'; export class AcceptStaffInviteDto { @IsString() + @MinLength(1, { message: ErrorCode.VALIDATION_TOKEN_REQUIRED }) token: string; @IsString() - @MinLength(8) + @MinLength(8, { message: ErrorCode.VALIDATION_PASSWORD_TOO_SHORT }) password: string; @IsString() - @MinLength(1) + @MinLength(1, { message: ErrorCode.VALIDATION_NAME_TOO_SHORT }) name: string; } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 6d736b3..fcd43f4 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -828,5 +828,66 @@ "monthOctober": "October", "monthNovember": "November", "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." } } diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 281c9ed..edf6bcd 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -829,5 +829,66 @@ "monthOctober": "اکتبر", "monthNovember": "نوامبر", "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": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید." } } \ No newline at end of file diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index ba51b6c..878c88d 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -829,5 +829,66 @@ "monthOctober": "Oktober", "monthNovember": "November", "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." } } \ No newline at end of file diff --git a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx index df110b4..517c6b3 100644 --- a/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/appointments/page.tsx @@ -20,7 +20,7 @@ import { ScheduleDayPicker } from '@/components/ui/shared/ScheduleDayPicker'; import { ToastStack } from '@/components/ui/shared/Toast'; import { useToast } from '@/lib/hooks/useToast'; 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'; const EMPTY_PATIENT_FORM: CreatePatientInput = { @@ -32,6 +32,7 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = { export default function AppointmentsPage() { const t = useTranslations('appointments'); + const tErrors = useTranslations('errors'); const tPatients = useTranslations('patients'); const { currentOrganization } = useAuth(); const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date())); @@ -105,7 +106,7 @@ export default function AppointmentsPage() { if (gen !== scheduleLoadGen.current) { return; } - toast.showError(formatApiErrorMessage(err, t('errorLoadSchedule'))); + toast.showError(getUserFacingError(err, tErrors, t('errorLoadSchedule'))); } finally { if (gen === scheduleLoadGen.current) { setLoadingSchedule(false); @@ -178,11 +179,7 @@ export default function AppointmentsPage() { ); } } catch (err: unknown) { - const message = - err && typeof err === 'object' && 'message' in err - ? String((err as { message: unknown }).message) - : tPatients('errorSavePatient'); - toast.showError(message); + toast.showError(getUserFacingError(err, tErrors, tPatients('errorSavePatient'))); } finally { setSavingPatient(false); } @@ -248,13 +245,13 @@ export default function AppointmentsPage() { toast.showSuccess(activeEditingAppointment ? t('successUpdated') : t('successSaved')); await loadSchedule(); } catch (err: unknown) { - const message = - err && typeof err === 'object' && 'message' in err - ? String((err as { message: unknown }).message) - : activeEditingAppointment - ? t('errorUpdate') - : t('errorSave'); - toast.showError(message); + toast.showError( + getUserFacingError( + err, + tErrors, + activeEditingAppointment ? t('errorUpdate') : t('errorSave'), + ), + ); } finally { setSavingAppointment(false); } @@ -276,11 +273,7 @@ export default function AppointmentsPage() { toast.showSuccess(t('successRemoved')); await loadSchedule(); } catch (err: unknown) { - const message = - err && typeof err === 'object' && 'message' in err - ? String((err as { message: unknown }).message) - : t('errorDelete'); - toast.showError(message); + toast.showError(getUserFacingError(err, tErrors, t('errorDelete'))); } finally { setDeletingAppointment(false); } diff --git a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx index c6df3f4..0eeadd9 100644 --- a/frontend/src/app/[locale]/(dashboard)/cases/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/cases/page.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; 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 { useToast } from '@/lib/hooks/useToast'; import { canEditCases, canEditTasks } from '@/components/shared/permissions'; @@ -35,6 +35,7 @@ const PAGE_SIZE = 20; export default function CasesPage() { const t = useTranslations('cases'); + const tErrors = useTranslations('errors'); const tCommon = useTranslations('common'); const { currentOrganization, user } = useAuth(); const toast = useToast(); @@ -112,7 +113,7 @@ export default function CasesPage() { setCases(response.data.items); setPagination(response.data.pagination); } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, t('errorLoadList'))); + toast.showError(getUserFacingError(error, tErrors, t('errorLoadList'))); } finally { setLoadingList(false); } @@ -127,7 +128,7 @@ export default function CasesPage() { const response = await casesApi.getOne(caseId); setSelectedCase(response.data); } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, t('errorLoadDetail'))); + toast.showError(getUserFacingError(error, tErrors, t('errorLoadDetail'))); if (!options?.silent) { setSelectedCase(null); } @@ -218,7 +219,7 @@ export default function CasesPage() { setSelectedCase(response.data); } catch (error: unknown) { setSelectedCase(previousCase); - toast.showError(formatApiErrorMessage(error, t('errorUpdateTask'))); + toast.showError(getUserFacingError(error, tErrors, t('errorUpdateTask'))); } finally { setUpdatingImportant(false); } diff --git a/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx index 954960e..65230c9 100644 --- a/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/organizations/page.tsx @@ -24,7 +24,7 @@ import { Input } from '@/components/ui/shared/Input'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import { Table } from '@/components/ui/shared/Table'; import { ToastStack } from '@/components/ui/shared/Toast'; -import type { ApiError } from '@/types/api'; +import { getUserFacingError } from '@/components/shared/formatApiError'; function formatOrganizationStatusLabel(status: string): string { if (!status) return status; @@ -42,6 +42,7 @@ type TableMode = 'existing' | 'search'; export default function OrganizationsPage() { const t = useTranslations('organizations'); + const tErrors = useTranslations('errors'); const tNav = useTranslations('nav'); const tCommon = useTranslations('common'); const { currentOrganization } = useAuth(); @@ -49,14 +50,8 @@ export default function OrganizationsPage() { const toast = useToast(); const formatApiMessage = useCallback( - (err: unknown): string => { - if (!err || typeof err !== 'object') return tCommon('errorGeneric'); - const m = (err as ApiError).message; - if (Array.isArray(m)) return m.join(', '); - if (typeof m === 'string') return m; - return tCommon('errorGeneric'); - }, - [tCommon], + (err: unknown): string => getUserFacingError(err, tErrors, tCommon('errorGeneric')), + [tCommon, tErrors], ); const formatConnectionStatusLabel = useCallback( diff --git a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx index 9184d3e..e84ae56 100644 --- a/frontend/src/app/[locale]/(dashboard)/patients/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/patients/page.tsx @@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { ToastStack } from '@/components/ui/shared/Toast'; 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 { useToast } from '@/lib/hooks/useToast'; import { hasPermission } from '@/components/shared/permissions'; @@ -23,6 +23,7 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = { export default function PatientsPage() { const t = useTranslations('patients'); + const tErrors = useTranslations('errors'); const tCommon = useTranslations('common'); const { currentOrganization } = useAuth(); const toast = useToast(); @@ -67,7 +68,7 @@ export default function PatientsPage() { setSelectedPatient(freshSelected); } } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, t('errorLoadPatients'))); + toast.showError(getUserFacingError(error, tErrors, t('errorLoadPatients'))); } finally { setLoadingPatients(false); } @@ -98,7 +99,7 @@ export default function PatientsPage() { ); } } catch (error: unknown) { - toast.showError(formatApiErrorMessage(error, t('errorSavePatient'))); + toast.showError(getUserFacingError(error, tErrors, t('errorSavePatient'))); } finally { setSavingPatient(false); } diff --git a/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx index 0fca833..9585e41 100644 --- a/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/settings/account/page.tsx @@ -16,6 +16,7 @@ import { Input } from '@/components/ui/shared/Input'; import { Toast } from '@/components/ui/shared/Toast'; import { Checkbox } from '@/components/ui/shared/Checkbox'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { OwnerWorkingHoursDialog } from '@/components/settings/OwnerWorkingHoursDialog'; type PasswordForm = { @@ -26,6 +27,7 @@ type PasswordForm = { export default function AccountSettingsPage() { const t = useTranslations('settings'); + const tErrors = useTranslations('errors'); const tAuth = useTranslations('auth'); const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); @@ -152,8 +154,7 @@ export default function AccountSettingsPage() { await syncSessionAfterParticipationChange(); setSuccessMessage(t('participateEnabledTasks')); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('participateUpdateFailed'); - setError(message || t('participateUpdateFailed')); + setError(getUserFacingError(err, tErrors, t('participateUpdateFailed'))); setParticipatesInTasks(false); } finally { setParticipationLoading(false); @@ -180,8 +181,7 @@ export default function AccountSettingsPage() { setRevokeConfirmOpen(false); setPendingRevokeType(null); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('participateUpdateFailed'); - setError(message || t('participateUpdateFailed')); + setError(getUserFacingError(err, tErrors, t('participateUpdateFailed'))); } finally { setParticipationLoading(false); } @@ -214,8 +214,7 @@ export default function AccountSettingsPage() { try { await enableClinicParticipation(options); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('participateUpdateFailed'); - setError(message || t('participateUpdateFailed')); + setError(getUserFacingError(err, tErrors, t('participateUpdateFailed'))); throw err; } finally { setParticipationLoading(false); @@ -249,8 +248,7 @@ export default function AccountSettingsPage() { setSuccessMessage(t('passwordChanged')); router.replace('/login'); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('passwordChangeFailed'); - setError(message || t('passwordChangeFailed')); + setError(getUserFacingError(err, tErrors, t('passwordChangeFailed'))); } finally { setIsSubmitting(false); } diff --git a/frontend/src/app/[locale]/(dashboard)/staff/page.tsx b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx index bdfe947..6f4ccef 100644 --- a/frontend/src/app/[locale]/(dashboard)/staff/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/staff/page.tsx @@ -35,7 +35,7 @@ import { Input } from '@/components/ui/shared/Input'; import { Checkbox } from '@/components/ui/shared/Checkbox'; import { Table } from '@/components/ui/shared/Table'; 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 { useToast } from '@/lib/hooks/useToast'; @@ -147,6 +147,7 @@ function PermissionGrid({ export default function StaffPage() { const router = useRouter(); const t = useTranslations('staff'); + const tErrors = useTranslations('errors'); const tCommon = useTranslations('common'); const tFeatures = useTranslations('staff.features'); const tWorkingHours = useTranslations('staff.workingHours'); @@ -229,7 +230,7 @@ export default function StaffPage() { setMembers(res.data.members); setSeats(res.data.seats); } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorLoadStaff'))); + toast.showError(getUserFacingError(e, tErrors, t('errorLoadStaff'))); } finally { setLoading(false); } @@ -304,7 +305,7 @@ export default function StaffPage() { await load(); } } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorCopyInvite'))); + toast.showError(getUserFacingError(e, tErrors, t('errorCopyInvite'))); } finally { setCopyingInviteMembershipId(null); } @@ -387,7 +388,7 @@ export default function StaffPage() { resetInviteForm(); await load(); } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorSendInvite'))); + toast.showError(getUserFacingError(e, tErrors, t('errorSendInvite'))); } finally { setInviteLoading(false); } @@ -410,7 +411,7 @@ export default function StaffPage() { setEditWorkingHoursDays(state.days); setEditAutoRepeatWeekly(state.autoRepeatWeekly); } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorLoadWorkingHours'))); + toast.showError(getUserFacingError(e, tErrors, t('errorLoadWorkingHours'))); } finally { setEditLoadingWorkingHours(false); } @@ -449,7 +450,7 @@ export default function StaffPage() { setEditStep(1); await load(); } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorUpdateMember'))); + toast.showError(getUserFacingError(e, tErrors, t('errorUpdateMember'))); } finally { setEditLoading(false); } @@ -470,7 +471,7 @@ export default function StaffPage() { setDisableTarget(null); await load(); } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorDisableMember'))); + toast.showError(getUserFacingError(e, tErrors, t('errorDisableMember'))); } finally { setDisablingMembershipId(null); } @@ -487,7 +488,7 @@ export default function StaffPage() { setEnableTarget(null); await load(); } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorEnableMember'))); + toast.showError(getUserFacingError(e, tErrors, t('errorEnableMember'))); } finally { setEnablingMembershipId(null); } @@ -600,7 +601,7 @@ export default function StaffPage() { setCopiedInviteMembershipId(lastInviteInfo.membershipId); setTimeout(() => setCopiedInviteMembershipId(null), 1500); } catch (e) { - toast.showError(formatApiErrorMessage(e, t('errorCopyInvite'))); + toast.showError(getUserFacingError(e, tErrors, t('errorCopyInvite'))); } finally { setCopyingInviteMembershipId(null); } diff --git a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx index d3f2b24..9898c9d 100644 --- a/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/tasks/page.tsx @@ -17,7 +17,7 @@ import { formatToothList, prosthesisTypeBadgeStyle, } 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 { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; @@ -38,6 +38,7 @@ function formatPatientName(patient: { firstName: string; lastName: string }) { export default function TasksPage() { const t = useTranslations('tasks'); + const tErrors = useTranslations('errors'); const { currentOrganization, user, isAuthReady } = useAuth(); const { showError, setError, messages: toastMessages } = useToast(); @@ -107,7 +108,7 @@ export default function TasksPage() { setTasks(response.data.items); setPagination(response.data.pagination); } catch (error: unknown) { - showError(formatApiErrorMessage(error, tRef.current('errorLoadList'))); + showError(getUserFacingError(error, tErrors, tRef.current('errorLoadList'))); } finally { setLoading(false); } @@ -127,7 +128,7 @@ export default function TasksPage() { await tasksApi.updateStatus(taskId, status); await loadTasks(); } catch (error: unknown) { - showError(formatApiErrorMessage(error, t('errorUpdateTask'))); + showError(getUserFacingError(error, tErrors, t('errorUpdateTask'))); } finally { setUpdatingTaskId(null); } diff --git a/frontend/src/app/[locale]/(dashboard)/today/page.tsx b/frontend/src/app/[locale]/(dashboard)/today/page.tsx index b51a2a2..65e938c 100644 --- a/frontend/src/app/[locale]/(dashboard)/today/page.tsx +++ b/frontend/src/app/[locale]/(dashboard)/today/page.tsx @@ -4,7 +4,7 @@ import { useMemo } from 'react'; import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/navigation'; 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 { TodayLoadErrorBanner } from '@/components/today/TodayLoadErrorBanner'; import { TodaySectionErrorFallback } from '@/components/today/TodaySectionErrorFallback'; @@ -13,6 +13,7 @@ import { useTodaySummary } from '@/lib/hooks/useTodaySummary'; export default function TodayPage() { const t = useTranslations('today'); + const tErrors = useTranslations('errors'); const { currentOrganization } = useAuth(); const orgId = currentOrganization?.id; const { data, loading, isInitialLoad, error, reload } = useTodaySummary(orgId); @@ -54,7 +55,7 @@ export default function TodayPage() { {error ? ( void reload()} isRetrying={loading && Boolean(data)} diff --git a/frontend/src/app/[locale]/(public)/accept-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx index 15b79e2..443a137 100644 --- a/frontend/src/app/[locale]/(public)/accept-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx @@ -7,10 +7,12 @@ import { Link, useRouter } from '@/i18n/navigation'; import { useSearchParams } from 'next/navigation'; import { Button } from '@/components/ui/shared/Button'; import { Input } from '@/components/ui/shared/Input'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { staffApi } from '@/lib/api/staff'; function AcceptInviteContent() { const t = useTranslations('auth'); + const tErrors = useTranslations('errors'); const params = useSearchParams(); const router = useRouter(); const token = useMemo(() => params.get('token') || '', [params]); @@ -49,8 +51,7 @@ function AcceptInviteContent() { setSuccess(t('invitationAlreadyAccepted')); } } catch (e: unknown) { - const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; - setError(message || t('errorLoadInvitation')); + setError(getUserFacingError(e, tErrors, t('errorLoadInvitation'))); } finally { setLoading(false); } @@ -86,8 +87,7 @@ function AcceptInviteContent() { router.replace('/login'); }, 1000); } catch (e: unknown) { - const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; - setError(message || t('errorAcceptInvitation')); + setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation'))); } finally { setSubmitting(false); } diff --git a/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx index afe6515..6362110 100644 --- a/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx @@ -14,6 +14,7 @@ import { Button } from '@/components/ui/shared/Button'; import { Input } from '@/components/ui/shared/Input'; import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { organizationApi } from '@/lib/api/organization'; type AcceptOrganizationInviteForm = { @@ -27,6 +28,7 @@ type AcceptOrganizationInviteForm = { function AcceptOrganizationInviteContent() { const t = useTranslations('auth'); + const tErrors = useTranslations('errors'); const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); const params = useSearchParams(); @@ -115,8 +117,7 @@ function AcceptOrganizationInviteContent() { setSuccess(t('invitationAlreadyAccepted')); } } catch (e: unknown) { - const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; - setError(message || t('errorLoadInvitation')); + setError(getUserFacingError(e, tErrors, t('errorLoadInvitation'))); } finally { setLoading(false); } @@ -148,8 +149,7 @@ function AcceptOrganizationInviteContent() { setSuccess(t('organizationAcceptedRedirect')); setTimeout(() => router.replace('/login'), 1000); } catch (e: unknown) { - const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; - setError(message || t('errorAcceptInvitation')); + setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation'))); } finally { setSubmitting(false); } diff --git a/frontend/src/app/[locale]/(public)/forgot-password/page.tsx b/frontend/src/app/[locale]/(public)/forgot-password/page.tsx index 30442e7..29eca1d 100644 --- a/frontend/src/app/[locale]/(public)/forgot-password/page.tsx +++ b/frontend/src/app/[locale]/(public)/forgot-password/page.tsx @@ -8,6 +8,7 @@ import { useTranslations } from 'next-intl'; import { Link, useRouter } from '@/i18n/navigation'; import { Phone, ShieldCheck } from 'lucide-react'; import { authApi } from '@/lib/api/auth'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { AuthPageShell } from '@/components/ui/auth/AuthPageShell'; import { Button } from '@/components/ui/shared/Button'; @@ -29,6 +30,7 @@ export default function ForgotPasswordPage() { const t = useTranslations('auth'); const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); + const tErrors = useTranslations('errors'); const router = useRouter(); const { refreshSession } = useAuth(); const [step, setStep] = useState<'mobile' | 'code'>('mobile'); @@ -76,8 +78,7 @@ export default function ForgotPasswordPage() { setSentMobile(mobile); setStep('code'); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('codeSendFailed'); - setError(message || t('codeSendFailed')); + setError(getUserFacingError(err, tErrors, t('codeSendFailed'))); } finally { setIsSending(false); } @@ -116,8 +117,7 @@ export default function ForgotPasswordPage() { await refreshSession(); router.push('/settings/account?reset=1'); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('verifyFailed'); - setError(message || t('verifyFailed')); + setError(getUserFacingError(err, tErrors, t('verifyFailed'))); } finally { setIsVerifying(false); } diff --git a/frontend/src/app/[locale]/(public)/login/page.tsx b/frontend/src/app/[locale]/(public)/login/page.tsx index b37e80a..5e1487c 100644 --- a/frontend/src/app/[locale]/(public)/login/page.tsx +++ b/frontend/src/app/[locale]/(public)/login/page.tsx @@ -8,6 +8,7 @@ import * as z from 'zod'; import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/navigation'; import { Mail, Lock } from 'lucide-react'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { getRememberedEmail } from '@/lib/auth/rememberMe'; import { AuthPageShell } from '@/components/ui/auth/AuthPageShell'; @@ -25,6 +26,7 @@ export default function LoginPage() { const t = useTranslations('auth'); const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); + const tErrors = useTranslations('errors'); const { login, isLoading, user, isAuthReady } = useAuth(); const router = useRouter(); const [error, setError] = useState(null); @@ -67,8 +69,7 @@ export default function LoginPage() { setError(null); await login(data.email, data.password, data.rememberMe); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('invalidCredentials'); - setError(message || t('invalidCredentials')); + setError(getUserFacingError(err, tErrors, t('invalidCredentials'))); } }; diff --git a/frontend/src/app/[locale]/(public)/register/page.tsx b/frontend/src/app/[locale]/(public)/register/page.tsx index 7ebe926..36ec6ae 100644 --- a/frontend/src/app/[locale]/(public)/register/page.tsx +++ b/frontend/src/app/[locale]/(public)/register/page.tsx @@ -7,6 +7,7 @@ import * as z from 'zod'; import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/navigation'; import { Mail, Lock, User, Phone } from 'lucide-react'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { AuthPageShell } from '@/components/ui/auth/AuthPageShell'; import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; @@ -36,6 +37,7 @@ export default function RegisterPage() { const t = useTranslations('auth'); const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); + const tErrors = useTranslations('errors'); const { registerTrial, isLoading } = useAuth(); const [step, setStep] = useState(1); const [error, setError] = useState(null); @@ -110,8 +112,7 @@ export default function RegisterPage() { data.organizationType, ); } catch (err: unknown) { - const message = err instanceof Error ? err.message : t('registrationFailed'); - setError(message || t('registrationFailed')); + setError(getUserFacingError(err, tErrors, t('registrationFailed'))); } }; diff --git a/frontend/src/components/shared/formatApiError.ts b/frontend/src/components/shared/formatApiError.ts index d60c0e1..5748d97 100644 --- a/frontend/src/components/shared/formatApiError.ts +++ b/frontend/src/components/shared/formatApiError.ts @@ -1,12 +1,123 @@ -export function formatApiErrorMessage(err: unknown, fallback: string): string { - if (err && typeof err === 'object' && 'message' in err) { - const m = (err as { message: unknown }).message; - if (Array.isArray(m)) { - return m.filter(Boolean).join(', '); +import { asApiError, isApiError, type ApiError, type ApiErrorDetail } from '@/types/api'; + +type TranslateFn = (key: string) => string; + +const STATUS_FALLBACK_KEYS: Record = { + 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; + } + + 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 (typeof m === 'string' && m.trim()) { - return m; + 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; } diff --git a/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx b/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx index ff666c5..a76fbf2 100644 --- a/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx +++ b/frontend/src/components/ui/lab/LabCaseCommentsPanel.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState, type KeyboardEvent } from 'react'; import { useTranslations } from 'next-intl'; 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'; interface LabCaseCommentsPanelProps { @@ -36,6 +36,7 @@ export function LabCaseCommentsPanel({ onComposerValueChange, }: LabCaseCommentsPanelProps) { const t = useTranslations('caseComments'); + const tErrors = useTranslations('errors'); const [comments, setComments] = useState([]); const [loading, setLoading] = useState(false); const [posting, setPosting] = useState(false); @@ -48,7 +49,7 @@ export function LabCaseCommentsPanel({ const items = await loadComments(); setComments(items); } catch (error: unknown) { - onError?.(formatApiErrorMessage(error, t('errorLoad'))); + onError?.(getUserFacingError(error, tErrors, t('errorLoad'))); } finally { setLoading(false); } @@ -68,7 +69,7 @@ export function LabCaseCommentsPanel({ setBody(''); setVisibleToClinic(false); } catch (error: unknown) { - onError?.(formatApiErrorMessage(error, t('errorPost'))); + onError?.(getUserFacingError(error, tErrors, t('errorPost'))); } finally { setPosting(false); } @@ -87,7 +88,7 @@ export function LabCaseCommentsPanel({ const updated = await onToggleVisibility(comment.id, !comment.visibleToClinic); setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); } catch (error: unknown) { - onError?.(formatApiErrorMessage(error, t('errorToggle'))); + onError?.(getUserFacingError(error, tErrors, t('errorToggle'))); } } diff --git a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx index 44f1af6..fa057cf 100644 --- a/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx +++ b/frontend/src/components/ui/organizations/ConnectionCaseHistoryContent.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; -import { formatApiErrorMessage } from '@/components/shared/formatApiError'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { canEditCases } from '@/components/shared/permissions'; import { useAuth } from '@/lib/hooks/useAuth'; import { useToast } from '@/lib/hooks/useToast'; @@ -37,6 +37,7 @@ export function ConnectionCaseHistoryContent({ onBack, }: ConnectionCaseHistoryContentProps) { const t = useTranslations('organizations'); + const tErrors = useTranslations('errors'); const tCases = useTranslations('cases'); const tCommon = useTranslations('common'); const { currentOrganization, user } = useAuth(); @@ -102,7 +103,7 @@ export function ConnectionCaseHistoryContent({ setPagination(response.data.pagination); } catch (error: unknown) { if (cancelled) return; - showError(formatApiErrorMessage(error, tRef.current('caseHistoryErrorLoadList'))); + showError(getUserFacingError(error, tErrors, tRef.current('caseHistoryErrorLoadList'))); } finally { if (!cancelled) setLoadingList(false); } @@ -142,7 +143,7 @@ export function ConnectionCaseHistoryContent({ setSelectedCase(response.data); } catch (error: unknown) { if (cancelled) return; - showError(formatApiErrorMessage(error, tRef.current('caseHistoryErrorLoadDetail'))); + showError(getUserFacingError(error, tErrors, tRef.current('caseHistoryErrorLoadDetail'))); setSelectedCase(null); } finally { if (!cancelled) setLoadingDetail(false); @@ -182,7 +183,7 @@ export function ConnectionCaseHistoryContent({ setSelectedCase(response.data); } catch (error: unknown) { setSelectedCase(previousCase); - showError(formatApiErrorMessage(error, tCases('errorUpdateTask'))); + showError(getUserFacingError(error, tErrors, tCases('errorUpdateTask'))); } finally { setUpdatingImportant(false); } diff --git a/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx b/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx index a18efd6..b1505a4 100644 --- a/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx +++ b/frontend/src/components/ui/organizations/OrganizationSelectorContent.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; +import { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; import { canCreateOrganizationFromCurrentOrg } from '@/components/shared/permissions'; import { Building2, Beaker, Mail } from 'lucide-react'; @@ -12,13 +13,14 @@ export function OrganizationSelectorContent() { const t = useTranslations('organizations'); const tAuth = useTranslations('auth'); const tCommon = useTranslations('common'); + const tErrors = useTranslations('errors'); const { organizations, currentOrganization, selectOrganization, createOrganization, isLoading, - error, + apiError, clearError, } = useAuth(); const canCreateOrganization = useMemo( @@ -82,6 +84,12 @@ export function OrganizationSelectorContent() { )} + {apiError && ( +
+

{getUserFacingError(apiError, tErrors)}

+
+ )} + {canCreateOrganization && isCreateOpen && (
- {error && ( -
-

{error}

-
- )}