Fix mobile validation, cascade delete for businesses/categories, and DB error handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-21 14:20:46 +03:30
co-authored by Cursor
parent 97abb808b8
commit 2d6bae2ba1
14 changed files with 237 additions and 26 deletions
@@ -0,0 +1,36 @@
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
} from 'class-validator';
import {
isValidIranMobile,
normalizeIranMobile,
} from '../utils/iran-mobile.util';
export function IsIranMobile(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isIranMobile',
target: object.constructor,
propertyName,
options: {
message: 'mobile must be a valid mobile number',
...validationOptions,
},
validator: {
validate(value: unknown, args: ValidationArguments) {
if (value === undefined || value === null || value === '') {
return false;
}
const normalized = normalizeIranMobile(String(value));
(args.object as Record<string, unknown>)[propertyName] = normalized;
return isValidIranMobile(normalized);
},
defaultMessage() {
return 'mobile must be a valid mobile number';
},
},
});
};
}
@@ -0,0 +1,42 @@
import {
ArgumentsHost,
Catch,
ConflictException,
ExceptionFilter,
HttpStatus,
InternalServerErrorException,
Logger,
} from '@nestjs/common';
import { QueryFailedError } from 'typeorm';
@Catch(QueryFailedError)
export class TypeOrmExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(TypeOrmExceptionFilter.name);
catch(exception: QueryFailedError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const driver = exception.driverError as { code?: string; detail?: string };
if (driver?.code === '23503') {
response.status(HttpStatus.CONFLICT).json({
statusCode: HttpStatus.CONFLICT,
message:
'Cannot delete: related records still exist. Remove bookings and linked data first.',
});
return;
}
if (driver?.code === '23505') {
response.status(HttpStatus.CONFLICT).json({
statusCode: HttpStatus.CONFLICT,
message: 'A record with the same unique value already exists.',
});
return;
}
this.logger.error(exception.message, exception.stack);
const internal = new InternalServerErrorException();
response.status(internal.getStatus()).json(internal.getResponse());
}
}
+43
View File
@@ -0,0 +1,43 @@
import { NotFoundException } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { AttributeDefinitionEntity } from '../../entities/attribute-definition.entity';
import { BookingEntity } from '../../entities/booking.entity';
import { BusinessAttributeValueEntity } from '../../entities/business-attribute-value.entity';
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
import { BusinessServiceEntity } from '../../entities/business-service.entity';
import { BusinessEntity } from '../../entities/business.entity';
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
/** Remove a business and all rows that block FK deletes in production DB. */
export async function deleteBusinessCascade(
manager: EntityManager,
businessId: string,
): Promise<void> {
await manager.delete(BookingEntity, { businessId });
await manager.delete(BusinessServiceEntity, { businessId });
await manager.delete(BusinessAttributeValueEntity, { businessId });
const result = await manager.delete(BusinessEntity, businessId);
if (!result.affected) {
throw new NotFoundException('Business not found');
}
}
/** Remove a category after all its businesses and catalog rows are cleared. */
export async function deleteCategoryCascade(
manager: EntityManager,
categoryId: string,
businessIds: string[],
): Promise<void> {
for (const businessId of businessIds) {
await deleteBusinessCascade(manager, businessId);
}
await manager.delete(ServiceTemplateEntity, { categoryId });
await manager.delete(AttributeDefinitionEntity, { categoryId });
const result = await manager.delete(BusinessCategoryEntity, categoryId);
if (!result.affected) {
throw new NotFoundException('Business category not found');
}
}
+39
View File
@@ -0,0 +1,39 @@
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
const INVISIBLE_CHARS = /[\u200c\u200d\u202a-\u202e\ufeff]/g;
export const IRAN_MOBILE_REGEX = /^09\d{9}$/;
function toAsciiDigits(value: string): string {
return value.replace(/[۰-۹٠-٩]/g, (ch) => {
const persian = PERSIAN_DIGITS.indexOf(ch);
if (persian >= 0) return String(persian);
const arabic = ARABIC_DIGITS.indexOf(ch);
if (arabic >= 0) return String(arabic);
return ch;
});
}
/** Normalize Iranian mobile to 09xxxxxxxxx (11 digits). */
export function normalizeIranMobile(raw: string): string {
let mobile = toAsciiDigits(String(raw ?? '').trim());
mobile = mobile.replace(INVISIBLE_CHARS, '');
let digits = mobile.replace(/\D/g, '');
if (!digits) return '';
if (digits.startsWith('0098')) {
digits = digits.slice(2);
}
if (digits.startsWith('98') && digits.length >= 12) {
digits = `0${digits.slice(2)}`;
} else if (digits.startsWith('9') && digits.length === 10) {
digits = `0${digits}`;
}
return digits;
}
export function isValidIranMobile(mobile: string): boolean {
return IRAN_MOBILE_REGEX.test(mobile);
}