Fix mobile validation, cascade delete for businesses/categories, and DB error handling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user