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

@@ -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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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