improvement: error handeling structure changed and unified all across the app. user no longer sees inappropriate messages.

This commit is contained in:
2026-07-12 18:27:38 +03:30
parent 901d838a2c
commit fab5111aa8
45 changed files with 977 additions and 241 deletions

View File

@@ -0,0 +1,17 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import type { ErrorCodeValue, ValidationErrorDetail } from './error-codes';
export interface AppErrorResponse {
code: ErrorCodeValue;
details?: ValidationErrorDetail[] | unknown;
}
export class AppException extends HttpException {
constructor(
code: ErrorCodeValue,
status: HttpStatus,
details?: ValidationErrorDetail[] | unknown,
) {
super({ code, details } satisfies AppErrorResponse, status);
}
}

View File

@@ -0,0 +1,73 @@
/** Stable API error codes — frontend maps these to translated user messages. */
export const ErrorCode = {
// Auth
AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS',
AUTH_UNAUTHORIZED: 'AUTH_UNAUTHORIZED',
AUTH_REFRESH_TOKEN_NOT_FOUND: 'AUTH_REFRESH_TOKEN_NOT_FOUND',
AUTH_REFRESH_TOKEN_INVALID: 'AUTH_REFRESH_TOKEN_INVALID',
AUTH_SESSION_EXPIRED: 'AUTH_SESSION_EXPIRED',
AUTH_USER_NOT_FOUND: 'AUTH_USER_NOT_FOUND',
AUTH_ORG_NOT_SELECTED: 'AUTH_ORG_NOT_SELECTED',
AUTH_ORG_ACCESS_DENIED: 'AUTH_ORG_ACCESS_DENIED',
AUTH_INVITATION_PENDING: 'AUTH_INVITATION_PENDING',
AUTH_REGISTRATION_EMAIL_EXISTS: 'AUTH_REGISTRATION_EMAIL_EXISTS',
AUTH_REGISTRATION_MOBILE_EXISTS: 'AUTH_REGISTRATION_MOBILE_EXISTS',
AUTH_REGISTRATION_MOBILE_INVALID: 'AUTH_REGISTRATION_MOBILE_INVALID',
AUTH_REGISTRATION_FAILED: 'AUTH_REGISTRATION_FAILED',
AUTH_PASSWORD_INCORRECT: 'AUTH_PASSWORD_INCORRECT',
AUTH_PASSWORD_RESET_EXPIRED: 'AUTH_PASSWORD_RESET_EXPIRED',
AUTH_VERIFICATION_CODE_INVALID: 'AUTH_VERIFICATION_CODE_INVALID',
AUTH_VERIFICATION_RATE_LIMIT: 'AUTH_VERIFICATION_RATE_LIMIT',
AUTH_SELECT_ORG_FIRST: 'AUTH_SELECT_ORG_FIRST',
AUTH_CREATE_ORG_OWNER_ONLY: 'AUTH_CREATE_ORG_OWNER_ONLY',
AUTH_PASSWORD_CURRENT_REQUIRED: 'AUTH_PASSWORD_CURRENT_REQUIRED',
// Permission
PERMISSION_DENIED: 'PERMISSION_DENIED',
PERMISSION_ORG_MANAGE: 'PERMISSION_ORG_MANAGE',
PERMISSION_CLINIC_ONLY: 'PERMISSION_CLINIC_ONLY',
PERMISSION_LAB_ONLY: 'PERMISSION_LAB_ONLY',
PERMISSION_NOT_MEMBER: 'PERMISSION_NOT_MEMBER',
PERMISSION_OWNER_ONLY: 'PERMISSION_OWNER_ONLY',
PERMISSION_PARTICIPATION_SUBSCRIPTION: 'PERMISSION_PARTICIPATION_SUBSCRIPTION',
PERMISSION_ENABLE_PARTICIPATION_FIRST: 'PERMISSION_ENABLE_PARTICIPATION_FIRST',
PERMISSION_CLINIC_WORKING_HOURS: 'PERMISSION_CLINIC_WORKING_HOURS',
PERMISSION_ACCESS_APPOINTMENTS: 'PERMISSION_ACCESS_APPOINTMENTS',
PERMISSION_EDIT_APPOINTMENTS: 'PERMISSION_EDIT_APPOINTMENTS',
PERMISSION_ACCESS_TREATMENTS: 'PERMISSION_ACCESS_TREATMENTS',
PERMISSION_EDIT_TREATMENTS: 'PERMISSION_EDIT_TREATMENTS',
PERMISSION_ACCESS_TASKS: 'PERMISSION_ACCESS_TASKS',
PERMISSION_EDIT_TASKS: 'PERMISSION_EDIT_TASKS',
PERMISSION_ACCESS_CASES: 'PERMISSION_ACCESS_CASES',
PERMISSION_ACCESS_STAFF: 'PERMISSION_ACCESS_STAFF',
PERMISSION_EDIT_STAFF: 'PERMISSION_EDIT_STAFF',
PERMISSION_ORG_NOT_FOUND: 'PERMISSION_ORG_NOT_FOUND',
// Validation
VALIDATION_FAILED: 'VALIDATION_FAILED',
VALIDATION_EMAIL_INVALID: 'VALIDATION_EMAIL_INVALID',
VALIDATION_PASSWORD_TOO_SHORT: 'VALIDATION_PASSWORD_TOO_SHORT',
VALIDATION_PASSWORD_REQUIRED: 'VALIDATION_PASSWORD_REQUIRED',
VALIDATION_MOBILE_INVALID: 'VALIDATION_MOBILE_INVALID',
VALIDATION_NAME_TOO_SHORT: 'VALIDATION_NAME_TOO_SHORT',
VALIDATION_ORGANIZATION_NAME_REQUIRED: 'VALIDATION_ORGANIZATION_NAME_REQUIRED',
VALIDATION_ORGANIZATION_TYPE_INVALID: 'VALIDATION_ORGANIZATION_TYPE_INVALID',
VALIDATION_TOKEN_REQUIRED: 'VALIDATION_TOKEN_REQUIRED',
VALIDATION_FIELD_REQUIRED: 'VALIDATION_FIELD_REQUIRED',
VALIDATION_LANGUAGE_INVALID: 'VALIDATION_LANGUAGE_INVALID',
VALIDATION_INVALID_REQUEST: 'VALIDATION_INVALID_REQUEST',
// Generic HTTP
NOT_FOUND: 'NOT_FOUND',
CONFLICT: 'CONFLICT',
CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS',
BAD_REQUEST: 'BAD_REQUEST',
INTERNAL_ERROR: 'INTERNAL_ERROR',
} as const;
export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode];
export interface ValidationErrorDetail {
field: string;
code: ErrorCodeValue;
}

View File

@@ -0,0 +1,184 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import type { Response } from 'express';
import { AppException, type AppErrorResponse } from './app.exception';
import { ErrorCode, type ErrorCodeValue } from './error-codes';
interface ClientErrorBody {
success: false;
error: {
code: ErrorCodeValue;
statusCode: number;
details?: unknown;
};
}
const STATUS_FALLBACK_CODES: Partial<Record<number, ErrorCodeValue>> = {
[HttpStatus.BAD_REQUEST]: ErrorCode.BAD_REQUEST,
[HttpStatus.UNAUTHORIZED]: ErrorCode.AUTH_UNAUTHORIZED,
[HttpStatus.FORBIDDEN]: ErrorCode.PERMISSION_DENIED,
[HttpStatus.NOT_FOUND]: ErrorCode.NOT_FOUND,
[HttpStatus.CONFLICT]: ErrorCode.CONFLICT,
[HttpStatus.INTERNAL_SERVER_ERROR]: ErrorCode.INTERNAL_ERROR,
};
/** Maps legacy English messages to stable codes during migration. */
const LEGACY_MESSAGE_CODES: Record<string, ErrorCodeValue> = {
'Invalid credentials': ErrorCode.AUTH_INVALID_CREDENTIALS,
Unauthorized: ErrorCode.AUTH_UNAUTHORIZED,
'Refresh token not found': ErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND,
'Invalid or expired refresh token': ErrorCode.AUTH_REFRESH_TOKEN_INVALID,
'Invalid refresh token': ErrorCode.AUTH_REFRESH_TOKEN_INVALID,
'Refresh token failed': ErrorCode.AUTH_REFRESH_TOKEN_INVALID,
'Invalid token type': ErrorCode.AUTH_SESSION_EXPIRED,
'Invalid token': ErrorCode.AUTH_SESSION_EXPIRED,
'Session not found or expired': ErrorCode.AUTH_SESSION_EXPIRED,
'User not found': ErrorCode.AUTH_USER_NOT_FOUND,
'Organization is not selected': ErrorCode.AUTH_ORG_NOT_SELECTED,
'Access denied to this organization': ErrorCode.AUTH_ORG_ACCESS_DENIED,
'Your invitation is still pending activation': ErrorCode.AUTH_INVITATION_PENDING,
'Auto-login failed': ErrorCode.AUTH_REGISTRATION_FAILED,
'User already exists. Please login and create a new organization from your account.':
ErrorCode.AUTH_REGISTRATION_EMAIL_EXISTS,
'This mobile number is already registered.': ErrorCode.AUTH_REGISTRATION_MOBILE_EXISTS,
'Please enter a valid mobile number': ErrorCode.AUTH_REGISTRATION_MOBILE_INVALID,
'Current password is incorrect': ErrorCode.AUTH_PASSWORD_INCORRECT,
'Password reset verification expired. Please verify your mobile again.':
ErrorCode.AUTH_PASSWORD_RESET_EXPIRED,
'Invalid or expired verification code': ErrorCode.AUTH_VERIFICATION_CODE_INVALID,
'Please wait before requesting another code': ErrorCode.AUTH_VERIFICATION_RATE_LIMIT,
'Current password is required': ErrorCode.AUTH_PASSWORD_CURRENT_REQUIRED,
'Select an organization before creating a new one.': ErrorCode.AUTH_SELECT_ORG_FIRST,
'Only owners of the current organization can create new organizations.':
ErrorCode.AUTH_CREATE_ORG_OWNER_ONLY,
'This action is only available for clinic organizations': ErrorCode.PERMISSION_CLINIC_ONLY,
'This action is only available for lab organizations': ErrorCode.PERMISSION_LAB_ONLY,
'Organization not found': ErrorCode.PERMISSION_ORG_NOT_FOUND,
'Unknown organization type': ErrorCode.PERMISSION_DENIED,
'You do not have permission to manage organizations': ErrorCode.PERMISSION_ORG_MANAGE,
'You are not a member of this organization': ErrorCode.PERMISSION_NOT_MEMBER,
'Only organization owners can manage participation': ErrorCode.PERMISSION_OWNER_ONLY,
'Only organization owners can manage their working hours': ErrorCode.PERMISSION_OWNER_ONLY,
'An active subscription is required to participate in treatments or tasks':
ErrorCode.PERMISSION_PARTICIPATION_SUBSCRIPTION,
'Enable treatment participation before setting working hours':
ErrorCode.PERMISSION_ENABLE_PARTICIPATION_FIRST,
'Working hours are only available for clinic organizations':
ErrorCode.PERMISSION_CLINIC_WORKING_HOURS,
'You do not have access to appointments': ErrorCode.PERMISSION_ACCESS_APPOINTMENTS,
'You cannot create or modify appointments': ErrorCode.PERMISSION_EDIT_APPOINTMENTS,
'You do not have access to treatments': ErrorCode.PERMISSION_ACCESS_TREATMENTS,
'You cannot edit treatments': ErrorCode.PERMISSION_EDIT_TREATMENTS,
'You do not have access to tasks': ErrorCode.PERMISSION_ACCESS_TASKS,
'You do not have access to staff management': ErrorCode.PERMISSION_ACCESS_STAFF,
'You cannot manage staff working hours': ErrorCode.PERMISSION_EDIT_STAFF,
};
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const { statusCode, body } = this.normalizeException(exception);
if (statusCode >= 500) {
this.logger.error(
exception instanceof Error ? exception.stack : String(exception),
);
}
response.status(statusCode).json(body);
}
private normalizeException(exception: unknown): {
statusCode: number;
body: ClientErrorBody;
} {
if (!(exception instanceof HttpException)) {
return {
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
body: this.buildBody(ErrorCode.INTERNAL_ERROR, HttpStatus.INTERNAL_SERVER_ERROR),
};
}
const statusCode = exception.getStatus();
const rawResponse = exception.getResponse();
if (exception instanceof AppException || this.isAppErrorResponse(rawResponse)) {
const appError = rawResponse as AppErrorResponse;
return {
statusCode,
body: this.buildBody(appError.code, statusCode, appError.details),
};
}
const code = this.resolveLegacyCode(rawResponse, statusCode);
return {
statusCode,
body: this.buildBody(code, statusCode),
};
}
private isAppErrorResponse(value: unknown): value is AppErrorResponse {
return (
typeof value === 'object' &&
value !== null &&
'code' in value &&
typeof (value as AppErrorResponse).code === 'string'
);
}
private resolveLegacyCode(rawResponse: string | object, statusCode: number): ErrorCodeValue {
const message = this.extractMessage(rawResponse);
if (typeof message === 'string') {
const mapped = LEGACY_MESSAGE_CODES[message.trim()];
if (mapped) {
return mapped;
}
}
if (Array.isArray(message)) {
return ErrorCode.VALIDATION_FAILED;
}
return STATUS_FALLBACK_CODES[statusCode] ?? ErrorCode.INTERNAL_ERROR;
}
private extractMessage(rawResponse: string | object): string | string[] | undefined {
if (typeof rawResponse === 'string') {
return rawResponse;
}
if (typeof rawResponse === 'object' && rawResponse !== null && 'message' in rawResponse) {
const message = (rawResponse as { message?: string | string[] }).message;
return message;
}
return undefined;
}
private buildBody(
code: ErrorCodeValue,
statusCode: number,
details?: unknown,
): ClientErrorBody {
return {
success: false,
error: {
code,
statusCode,
...(details !== undefined ? { details } : {}),
},
};
}
}

View File

@@ -0,0 +1,4 @@
export { ErrorCode, type ErrorCodeValue, type ValidationErrorDetail } from './error-codes';
export { AppException, type AppErrorResponse } from './app.exception';
export { HttpExceptionFilter } from './http-exception.filter';
export { validationExceptionFactory } from './validation-exception.factory';

View File

@@ -0,0 +1,73 @@
import { BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
import type { ValidationError } from 'class-validator';
import { AppException } from './app.exception';
import { ErrorCode, type ErrorCodeValue, type ValidationErrorDetail } from './error-codes';
const KNOWN_VALIDATION_CODES = new Set<string>(Object.values(ErrorCode));
function isKnownValidationCode(value: string): value is ErrorCodeValue {
return KNOWN_VALIDATION_CODES.has(value);
}
function constraintToCode(constraintKey: string, message: string): ErrorCodeValue {
if (isKnownValidationCode(message)) {
return message;
}
switch (constraintKey) {
case 'isEmail':
return ErrorCode.VALIDATION_EMAIL_INVALID;
case 'minLength':
return ErrorCode.VALIDATION_PASSWORD_TOO_SHORT;
case 'isEnum':
return ErrorCode.VALIDATION_ORGANIZATION_TYPE_INVALID;
case 'matches':
return ErrorCode.VALIDATION_MOBILE_INVALID;
case 'isIn':
return ErrorCode.VALIDATION_LANGUAGE_INVALID;
case 'whitelistValidation':
return ErrorCode.VALIDATION_INVALID_REQUEST;
default:
return ErrorCode.VALIDATION_FIELD_REQUIRED;
}
}
function flattenValidationErrors(
errors: ValidationError[],
parentPath = '',
): ValidationErrorDetail[] {
const details: ValidationErrorDetail[] = [];
for (const error of errors) {
const field = parentPath ? `${parentPath}.${error.property}` : error.property;
if (error.constraints) {
const [constraintKey, message] = Object.entries(error.constraints)[0];
details.push({
field,
code: constraintToCode(constraintKey, message),
});
}
if (error.children?.length) {
details.push(...flattenValidationErrors(error.children, field));
}
}
return details;
}
export function validationExceptionFactory(errors: ValidationError[]): HttpException {
const details = flattenValidationErrors(errors);
if (details.length === 0) {
return new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.BAD_REQUEST);
}
return new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.BAD_REQUEST, details);
}
/** Handles forbidNonWhitelisted errors from ValidationPipe. */
export function isValidationPipeBadRequest(exception: unknown): exception is BadRequestException {
return exception instanceof BadRequestException;
}

View File

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

View File

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

View File

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