improvement: error handeling structure changed and unified all across the app. user no longer sees inappropriate messages.
This commit is contained in:
17
backend/src/common/errors/app.exception.ts
Normal file
17
backend/src/common/errors/app.exception.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import type { ErrorCodeValue, ValidationErrorDetail } from './error-codes';
|
||||
|
||||
export interface AppErrorResponse {
|
||||
code: ErrorCodeValue;
|
||||
details?: ValidationErrorDetail[] | unknown;
|
||||
}
|
||||
|
||||
export class AppException extends HttpException {
|
||||
constructor(
|
||||
code: ErrorCodeValue,
|
||||
status: HttpStatus,
|
||||
details?: ValidationErrorDetail[] | unknown,
|
||||
) {
|
||||
super({ code, details } satisfies AppErrorResponse, status);
|
||||
}
|
||||
}
|
||||
73
backend/src/common/errors/error-codes.ts
Normal file
73
backend/src/common/errors/error-codes.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/** Stable API error codes — frontend maps these to translated user messages. */
|
||||
export const ErrorCode = {
|
||||
// Auth
|
||||
AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS',
|
||||
AUTH_UNAUTHORIZED: 'AUTH_UNAUTHORIZED',
|
||||
AUTH_REFRESH_TOKEN_NOT_FOUND: 'AUTH_REFRESH_TOKEN_NOT_FOUND',
|
||||
AUTH_REFRESH_TOKEN_INVALID: 'AUTH_REFRESH_TOKEN_INVALID',
|
||||
AUTH_SESSION_EXPIRED: 'AUTH_SESSION_EXPIRED',
|
||||
AUTH_USER_NOT_FOUND: 'AUTH_USER_NOT_FOUND',
|
||||
AUTH_ORG_NOT_SELECTED: 'AUTH_ORG_NOT_SELECTED',
|
||||
AUTH_ORG_ACCESS_DENIED: 'AUTH_ORG_ACCESS_DENIED',
|
||||
AUTH_INVITATION_PENDING: 'AUTH_INVITATION_PENDING',
|
||||
AUTH_REGISTRATION_EMAIL_EXISTS: 'AUTH_REGISTRATION_EMAIL_EXISTS',
|
||||
AUTH_REGISTRATION_MOBILE_EXISTS: 'AUTH_REGISTRATION_MOBILE_EXISTS',
|
||||
AUTH_REGISTRATION_MOBILE_INVALID: 'AUTH_REGISTRATION_MOBILE_INVALID',
|
||||
AUTH_REGISTRATION_FAILED: 'AUTH_REGISTRATION_FAILED',
|
||||
AUTH_PASSWORD_INCORRECT: 'AUTH_PASSWORD_INCORRECT',
|
||||
AUTH_PASSWORD_RESET_EXPIRED: 'AUTH_PASSWORD_RESET_EXPIRED',
|
||||
AUTH_VERIFICATION_CODE_INVALID: 'AUTH_VERIFICATION_CODE_INVALID',
|
||||
AUTH_VERIFICATION_RATE_LIMIT: 'AUTH_VERIFICATION_RATE_LIMIT',
|
||||
AUTH_SELECT_ORG_FIRST: 'AUTH_SELECT_ORG_FIRST',
|
||||
AUTH_CREATE_ORG_OWNER_ONLY: 'AUTH_CREATE_ORG_OWNER_ONLY',
|
||||
AUTH_PASSWORD_CURRENT_REQUIRED: 'AUTH_PASSWORD_CURRENT_REQUIRED',
|
||||
|
||||
// Permission
|
||||
PERMISSION_DENIED: 'PERMISSION_DENIED',
|
||||
PERMISSION_ORG_MANAGE: 'PERMISSION_ORG_MANAGE',
|
||||
PERMISSION_CLINIC_ONLY: 'PERMISSION_CLINIC_ONLY',
|
||||
PERMISSION_LAB_ONLY: 'PERMISSION_LAB_ONLY',
|
||||
PERMISSION_NOT_MEMBER: 'PERMISSION_NOT_MEMBER',
|
||||
PERMISSION_OWNER_ONLY: 'PERMISSION_OWNER_ONLY',
|
||||
PERMISSION_PARTICIPATION_SUBSCRIPTION: 'PERMISSION_PARTICIPATION_SUBSCRIPTION',
|
||||
PERMISSION_ENABLE_PARTICIPATION_FIRST: 'PERMISSION_ENABLE_PARTICIPATION_FIRST',
|
||||
PERMISSION_CLINIC_WORKING_HOURS: 'PERMISSION_CLINIC_WORKING_HOURS',
|
||||
PERMISSION_ACCESS_APPOINTMENTS: 'PERMISSION_ACCESS_APPOINTMENTS',
|
||||
PERMISSION_EDIT_APPOINTMENTS: 'PERMISSION_EDIT_APPOINTMENTS',
|
||||
PERMISSION_ACCESS_TREATMENTS: 'PERMISSION_ACCESS_TREATMENTS',
|
||||
PERMISSION_EDIT_TREATMENTS: 'PERMISSION_EDIT_TREATMENTS',
|
||||
PERMISSION_ACCESS_TASKS: 'PERMISSION_ACCESS_TASKS',
|
||||
PERMISSION_EDIT_TASKS: 'PERMISSION_EDIT_TASKS',
|
||||
PERMISSION_ACCESS_CASES: 'PERMISSION_ACCESS_CASES',
|
||||
PERMISSION_ACCESS_STAFF: 'PERMISSION_ACCESS_STAFF',
|
||||
PERMISSION_EDIT_STAFF: 'PERMISSION_EDIT_STAFF',
|
||||
PERMISSION_ORG_NOT_FOUND: 'PERMISSION_ORG_NOT_FOUND',
|
||||
|
||||
// Validation
|
||||
VALIDATION_FAILED: 'VALIDATION_FAILED',
|
||||
VALIDATION_EMAIL_INVALID: 'VALIDATION_EMAIL_INVALID',
|
||||
VALIDATION_PASSWORD_TOO_SHORT: 'VALIDATION_PASSWORD_TOO_SHORT',
|
||||
VALIDATION_PASSWORD_REQUIRED: 'VALIDATION_PASSWORD_REQUIRED',
|
||||
VALIDATION_MOBILE_INVALID: 'VALIDATION_MOBILE_INVALID',
|
||||
VALIDATION_NAME_TOO_SHORT: 'VALIDATION_NAME_TOO_SHORT',
|
||||
VALIDATION_ORGANIZATION_NAME_REQUIRED: 'VALIDATION_ORGANIZATION_NAME_REQUIRED',
|
||||
VALIDATION_ORGANIZATION_TYPE_INVALID: 'VALIDATION_ORGANIZATION_TYPE_INVALID',
|
||||
VALIDATION_TOKEN_REQUIRED: 'VALIDATION_TOKEN_REQUIRED',
|
||||
VALIDATION_FIELD_REQUIRED: 'VALIDATION_FIELD_REQUIRED',
|
||||
VALIDATION_LANGUAGE_INVALID: 'VALIDATION_LANGUAGE_INVALID',
|
||||
VALIDATION_INVALID_REQUEST: 'VALIDATION_INVALID_REQUEST',
|
||||
|
||||
// Generic HTTP
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
CONFLICT: 'CONFLICT',
|
||||
CONFLICT_FUTURE_APPOINTMENTS: 'CONFLICT_FUTURE_APPOINTMENTS',
|
||||
BAD_REQUEST: 'BAD_REQUEST',
|
||||
INTERNAL_ERROR: 'INTERNAL_ERROR',
|
||||
} as const;
|
||||
|
||||
export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode];
|
||||
|
||||
export interface ValidationErrorDetail {
|
||||
field: string;
|
||||
code: ErrorCodeValue;
|
||||
}
|
||||
184
backend/src/common/errors/http-exception.filter.ts
Normal file
184
backend/src/common/errors/http-exception.filter.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { AppException, type AppErrorResponse } from './app.exception';
|
||||
import { ErrorCode, type ErrorCodeValue } from './error-codes';
|
||||
|
||||
interface ClientErrorBody {
|
||||
success: false;
|
||||
error: {
|
||||
code: ErrorCodeValue;
|
||||
statusCode: number;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_FALLBACK_CODES: Partial<Record<number, ErrorCodeValue>> = {
|
||||
[HttpStatus.BAD_REQUEST]: ErrorCode.BAD_REQUEST,
|
||||
[HttpStatus.UNAUTHORIZED]: ErrorCode.AUTH_UNAUTHORIZED,
|
||||
[HttpStatus.FORBIDDEN]: ErrorCode.PERMISSION_DENIED,
|
||||
[HttpStatus.NOT_FOUND]: ErrorCode.NOT_FOUND,
|
||||
[HttpStatus.CONFLICT]: ErrorCode.CONFLICT,
|
||||
[HttpStatus.INTERNAL_SERVER_ERROR]: ErrorCode.INTERNAL_ERROR,
|
||||
};
|
||||
|
||||
/** Maps legacy English messages to stable codes during migration. */
|
||||
const LEGACY_MESSAGE_CODES: Record<string, ErrorCodeValue> = {
|
||||
'Invalid credentials': ErrorCode.AUTH_INVALID_CREDENTIALS,
|
||||
Unauthorized: ErrorCode.AUTH_UNAUTHORIZED,
|
||||
'Refresh token not found': ErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND,
|
||||
'Invalid or expired refresh token': ErrorCode.AUTH_REFRESH_TOKEN_INVALID,
|
||||
'Invalid refresh token': ErrorCode.AUTH_REFRESH_TOKEN_INVALID,
|
||||
'Refresh token failed': ErrorCode.AUTH_REFRESH_TOKEN_INVALID,
|
||||
'Invalid token type': ErrorCode.AUTH_SESSION_EXPIRED,
|
||||
'Invalid token': ErrorCode.AUTH_SESSION_EXPIRED,
|
||||
'Session not found or expired': ErrorCode.AUTH_SESSION_EXPIRED,
|
||||
'User not found': ErrorCode.AUTH_USER_NOT_FOUND,
|
||||
'Organization is not selected': ErrorCode.AUTH_ORG_NOT_SELECTED,
|
||||
'Access denied to this organization': ErrorCode.AUTH_ORG_ACCESS_DENIED,
|
||||
'Your invitation is still pending activation': ErrorCode.AUTH_INVITATION_PENDING,
|
||||
'Auto-login failed': ErrorCode.AUTH_REGISTRATION_FAILED,
|
||||
'User already exists. Please login and create a new organization from your account.':
|
||||
ErrorCode.AUTH_REGISTRATION_EMAIL_EXISTS,
|
||||
'This mobile number is already registered.': ErrorCode.AUTH_REGISTRATION_MOBILE_EXISTS,
|
||||
'Please enter a valid mobile number': ErrorCode.AUTH_REGISTRATION_MOBILE_INVALID,
|
||||
'Current password is incorrect': ErrorCode.AUTH_PASSWORD_INCORRECT,
|
||||
'Password reset verification expired. Please verify your mobile again.':
|
||||
ErrorCode.AUTH_PASSWORD_RESET_EXPIRED,
|
||||
'Invalid or expired verification code': ErrorCode.AUTH_VERIFICATION_CODE_INVALID,
|
||||
'Please wait before requesting another code': ErrorCode.AUTH_VERIFICATION_RATE_LIMIT,
|
||||
'Current password is required': ErrorCode.AUTH_PASSWORD_CURRENT_REQUIRED,
|
||||
'Select an organization before creating a new one.': ErrorCode.AUTH_SELECT_ORG_FIRST,
|
||||
'Only owners of the current organization can create new organizations.':
|
||||
ErrorCode.AUTH_CREATE_ORG_OWNER_ONLY,
|
||||
'This action is only available for clinic organizations': ErrorCode.PERMISSION_CLINIC_ONLY,
|
||||
'This action is only available for lab organizations': ErrorCode.PERMISSION_LAB_ONLY,
|
||||
'Organization not found': ErrorCode.PERMISSION_ORG_NOT_FOUND,
|
||||
'Unknown organization type': ErrorCode.PERMISSION_DENIED,
|
||||
'You do not have permission to manage organizations': ErrorCode.PERMISSION_ORG_MANAGE,
|
||||
'You are not a member of this organization': ErrorCode.PERMISSION_NOT_MEMBER,
|
||||
'Only organization owners can manage participation': ErrorCode.PERMISSION_OWNER_ONLY,
|
||||
'Only organization owners can manage their working hours': ErrorCode.PERMISSION_OWNER_ONLY,
|
||||
'An active subscription is required to participate in treatments or tasks':
|
||||
ErrorCode.PERMISSION_PARTICIPATION_SUBSCRIPTION,
|
||||
'Enable treatment participation before setting working hours':
|
||||
ErrorCode.PERMISSION_ENABLE_PARTICIPATION_FIRST,
|
||||
'Working hours are only available for clinic organizations':
|
||||
ErrorCode.PERMISSION_CLINIC_WORKING_HOURS,
|
||||
'You do not have access to appointments': ErrorCode.PERMISSION_ACCESS_APPOINTMENTS,
|
||||
'You cannot create or modify appointments': ErrorCode.PERMISSION_EDIT_APPOINTMENTS,
|
||||
'You do not have access to treatments': ErrorCode.PERMISSION_ACCESS_TREATMENTS,
|
||||
'You cannot edit treatments': ErrorCode.PERMISSION_EDIT_TREATMENTS,
|
||||
'You do not have access to tasks': ErrorCode.PERMISSION_ACCESS_TASKS,
|
||||
'You do not have access to staff management': ErrorCode.PERMISSION_ACCESS_STAFF,
|
||||
'You cannot manage staff working hours': ErrorCode.PERMISSION_EDIT_STAFF,
|
||||
};
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
const { statusCode, body } = this.normalizeException(exception);
|
||||
|
||||
if (statusCode >= 500) {
|
||||
this.logger.error(
|
||||
exception instanceof Error ? exception.stack : String(exception),
|
||||
);
|
||||
}
|
||||
|
||||
response.status(statusCode).json(body);
|
||||
}
|
||||
|
||||
private normalizeException(exception: unknown): {
|
||||
statusCode: number;
|
||||
body: ClientErrorBody;
|
||||
} {
|
||||
if (!(exception instanceof HttpException)) {
|
||||
return {
|
||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
body: this.buildBody(ErrorCode.INTERNAL_ERROR, HttpStatus.INTERNAL_SERVER_ERROR),
|
||||
};
|
||||
}
|
||||
|
||||
const statusCode = exception.getStatus();
|
||||
const rawResponse = exception.getResponse();
|
||||
|
||||
if (exception instanceof AppException || this.isAppErrorResponse(rawResponse)) {
|
||||
const appError = rawResponse as AppErrorResponse;
|
||||
return {
|
||||
statusCode,
|
||||
body: this.buildBody(appError.code, statusCode, appError.details),
|
||||
};
|
||||
}
|
||||
|
||||
const code = this.resolveLegacyCode(rawResponse, statusCode);
|
||||
return {
|
||||
statusCode,
|
||||
body: this.buildBody(code, statusCode),
|
||||
};
|
||||
}
|
||||
|
||||
private isAppErrorResponse(value: unknown): value is AppErrorResponse {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'code' in value &&
|
||||
typeof (value as AppErrorResponse).code === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
private resolveLegacyCode(rawResponse: string | object, statusCode: number): ErrorCodeValue {
|
||||
const message = this.extractMessage(rawResponse);
|
||||
|
||||
if (typeof message === 'string') {
|
||||
const mapped = LEGACY_MESSAGE_CODES[message.trim()];
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(message)) {
|
||||
return ErrorCode.VALIDATION_FAILED;
|
||||
}
|
||||
|
||||
return STATUS_FALLBACK_CODES[statusCode] ?? ErrorCode.INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
private extractMessage(rawResponse: string | object): string | string[] | undefined {
|
||||
if (typeof rawResponse === 'string') {
|
||||
return rawResponse;
|
||||
}
|
||||
|
||||
if (typeof rawResponse === 'object' && rawResponse !== null && 'message' in rawResponse) {
|
||||
const message = (rawResponse as { message?: string | string[] }).message;
|
||||
return message;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private buildBody(
|
||||
code: ErrorCodeValue,
|
||||
statusCode: number,
|
||||
details?: unknown,
|
||||
): ClientErrorBody {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code,
|
||||
statusCode,
|
||||
...(details !== undefined ? { details } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
4
backend/src/common/errors/index.ts
Normal file
4
backend/src/common/errors/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ErrorCode, type ErrorCodeValue, type ValidationErrorDetail } from './error-codes';
|
||||
export { AppException, type AppErrorResponse } from './app.exception';
|
||||
export { HttpExceptionFilter } from './http-exception.filter';
|
||||
export { validationExceptionFactory } from './validation-exception.factory';
|
||||
73
backend/src/common/errors/validation-exception.factory.ts
Normal file
73
backend/src/common/errors/validation-exception.factory.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import type { ValidationError } from 'class-validator';
|
||||
import { AppException } from './app.exception';
|
||||
import { ErrorCode, type ErrorCodeValue, type ValidationErrorDetail } from './error-codes';
|
||||
|
||||
const KNOWN_VALIDATION_CODES = new Set<string>(Object.values(ErrorCode));
|
||||
|
||||
function isKnownValidationCode(value: string): value is ErrorCodeValue {
|
||||
return KNOWN_VALIDATION_CODES.has(value);
|
||||
}
|
||||
|
||||
function constraintToCode(constraintKey: string, message: string): ErrorCodeValue {
|
||||
if (isKnownValidationCode(message)) {
|
||||
return message;
|
||||
}
|
||||
|
||||
switch (constraintKey) {
|
||||
case 'isEmail':
|
||||
return ErrorCode.VALIDATION_EMAIL_INVALID;
|
||||
case 'minLength':
|
||||
return ErrorCode.VALIDATION_PASSWORD_TOO_SHORT;
|
||||
case 'isEnum':
|
||||
return ErrorCode.VALIDATION_ORGANIZATION_TYPE_INVALID;
|
||||
case 'matches':
|
||||
return ErrorCode.VALIDATION_MOBILE_INVALID;
|
||||
case 'isIn':
|
||||
return ErrorCode.VALIDATION_LANGUAGE_INVALID;
|
||||
case 'whitelistValidation':
|
||||
return ErrorCode.VALIDATION_INVALID_REQUEST;
|
||||
default:
|
||||
return ErrorCode.VALIDATION_FIELD_REQUIRED;
|
||||
}
|
||||
}
|
||||
|
||||
function flattenValidationErrors(
|
||||
errors: ValidationError[],
|
||||
parentPath = '',
|
||||
): ValidationErrorDetail[] {
|
||||
const details: ValidationErrorDetail[] = [];
|
||||
|
||||
for (const error of errors) {
|
||||
const field = parentPath ? `${parentPath}.${error.property}` : error.property;
|
||||
|
||||
if (error.constraints) {
|
||||
const [constraintKey, message] = Object.entries(error.constraints)[0];
|
||||
details.push({
|
||||
field,
|
||||
code: constraintToCode(constraintKey, message),
|
||||
});
|
||||
}
|
||||
|
||||
if (error.children?.length) {
|
||||
details.push(...flattenValidationErrors(error.children, field));
|
||||
}
|
||||
}
|
||||
|
||||
return details;
|
||||
}
|
||||
|
||||
export function validationExceptionFactory(errors: ValidationError[]): HttpException {
|
||||
const details = flattenValidationErrors(errors);
|
||||
|
||||
if (details.length === 0) {
|
||||
return new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
return new AppException(ErrorCode.VALIDATION_FAILED, HttpStatus.BAD_REQUEST, details);
|
||||
}
|
||||
|
||||
/** Handles forbidNonWhitelisted errors from ValidationPipe. */
|
||||
export function isValidationPipeBadRequest(exception: unknown): exception is BadRequestException {
|
||||
return exception instanceof BadRequestException;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<any> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user