diff --git a/.env.example b/.env.example index bf7900d..c5e0a36 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,9 @@ DB_HOST=127.0.0.1 +# Local Docker (docker compose up -d): 5432 +# Remote DB via SSH tunnel (-L 5433:127.0.0.1:5432): 5433 POSTGRES_PORT=5432 POSTGRES_USER=montime -POSTGRES_PASSWORD=change_me +POSTGRES_PASSWORD=montime POSTGRES_DB=montime # Dev only: auto-sync schema from entities (do not use true in production) DB_SYNC=true @@ -17,7 +19,8 @@ OTP_REQUEST_MAX_PER_HOUR=5 AUTH_RETURN_OTP=true # Comma-separated frontend origins (required in production for browser clients) -# CORS_ORIGINS=https://app.example.com,http://localhost:5173 +# Include both dev ports if calling API directly (without Vite /api proxy) +CORS_ORIGINS=https://montime.ir,https://adm.montime.ir,http://localhost:5173,http://localhost:5174 # 0098sms.com — used when AUTH_RETURN_OTP is not true # From panel: account username, password (or web service password), approved sender line (FROM) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9df3b32 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +# PostgreSQL for local development (no SSH tunnel). +# Start: docker compose up -d +# Stop: docker compose down +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + ports: + - '5432:5432' + environment: + POSTGRES_USER: montime + POSTGRES_PASSWORD: montime + POSTGRES_DB: montime + volumes: + - montime_pg_data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U montime -d montime'] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + montime_pg_data: diff --git a/src/common/decorators/is-iran-mobile.decorator.ts b/src/common/decorators/is-iran-mobile.decorator.ts new file mode 100644 index 0000000..c3c8917 --- /dev/null +++ b/src/common/decorators/is-iran-mobile.decorator.ts @@ -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)[propertyName] = normalized; + return isValidIranMobile(normalized); + }, + defaultMessage() { + return 'mobile must be a valid mobile number'; + }, + }, + }); + }; +} diff --git a/src/common/filters/typeorm-exception.filter.ts b/src/common/filters/typeorm-exception.filter.ts new file mode 100644 index 0000000..59abd9b --- /dev/null +++ b/src/common/filters/typeorm-exception.filter.ts @@ -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()); + } +} diff --git a/src/common/utils/business-cascade.util.ts b/src/common/utils/business-cascade.util.ts new file mode 100644 index 0000000..9264096 --- /dev/null +++ b/src/common/utils/business-cascade.util.ts @@ -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 { + 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 { + 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'); + } +} diff --git a/src/common/utils/iran-mobile.util.ts b/src/common/utils/iran-mobile.util.ts new file mode 100644 index 0000000..cb614e0 --- /dev/null +++ b/src/common/utils/iran-mobile.util.ts @@ -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); +} diff --git a/src/main.ts b/src/main.ts index 0d96149..f63765e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import 'reflect-metadata'; import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { TypeOrmExceptionFilter } from './common/filters/typeorm-exception.filter'; import { AppModule } from './app.module'; function configureCors(app: Awaited>) { @@ -26,6 +27,7 @@ async function bootstrap() { whitelist: true, }), ); + app.useGlobalFilters(new TypeOrmExceptionFilter()); const swaggerConfig = new DocumentBuilder() .setTitle('Montime API') diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 8869ca9..76d6313 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -12,6 +12,10 @@ import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; import { createHash, randomInt } from 'crypto'; import { IsNull, MoreThan, Repository } from 'typeorm'; +import { + isValidIranMobile, + normalizeIranMobile, +} from '../../common/utils/iran-mobile.util'; import { AuthOtpEntity } from '../../entities/auth-otp.entity'; import { UserEntity, UserRole } from '../../entities/user.entity'; import { JwtPayload } from './auth.types'; @@ -200,8 +204,8 @@ export class AuthService { } private normalizeMobile(mobile: string): string { - const normalized = String(mobile ?? '').trim(); - if (!/^(\+?\d{10,15}|09\d{9})$/.test(normalized)) { + const normalized = normalizeIranMobile(mobile); + if (!isValidIranMobile(normalized)) { throw new BadRequestException('Invalid mobile number'); } return normalized; diff --git a/src/modules/auth/dto/request-otp.dto.ts b/src/modules/auth/dto/request-otp.dto.ts index 1dde95e..851744a 100644 --- a/src/modules/auth/dto/request-otp.dto.ts +++ b/src/modules/auth/dto/request-otp.dto.ts @@ -1,11 +1,10 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsString, Matches } from 'class-validator'; +import { IsString } from 'class-validator'; +import { IsIranMobile } from '../../../common/decorators/is-iran-mobile.decorator'; export class RequestOtpDto { @ApiProperty({ example: '09122222222' }) @IsString() - @Matches(/^(\+?\d{10,15}|09\d{9})$/, { - message: 'mobile must be a valid mobile number', - }) + @IsIranMobile() mobile!: string; } diff --git a/src/modules/auth/dto/verify-otp.dto.ts b/src/modules/auth/dto/verify-otp.dto.ts index edbc558..75203dd 100644 --- a/src/modules/auth/dto/verify-otp.dto.ts +++ b/src/modules/auth/dto/verify-otp.dto.ts @@ -1,12 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsString, Matches } from 'class-validator'; +import { IsIranMobile } from '../../../common/decorators/is-iran-mobile.decorator'; export class VerifyOtpDto { @ApiProperty({ example: '09122222222' }) @IsString() - @Matches(/^(\+?\d{10,15}|09\d{9})$/, { - message: 'mobile must be a valid mobile number', - }) + @IsIranMobile() mobile!: string; @ApiProperty({ example: '123456' }) diff --git a/src/modules/business-categories/business-categories.module.ts b/src/modules/business-categories/business-categories.module.ts index 084e492..170d9f4 100644 --- a/src/modules/business-categories/business-categories.module.ts +++ b/src/modules/business-categories/business-categories.module.ts @@ -1,12 +1,16 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BusinessCategoryEntity } from '../../entities/business-category.entity'; +import { BusinessEntity } from '../../entities/business.entity'; import { AuthModule } from '../auth/auth.module'; import { BusinessCategoriesController } from './business-categories.controller'; import { BusinessCategoriesService } from './business-categories.service'; @Module({ - imports: [TypeOrmModule.forFeature([BusinessCategoryEntity]), AuthModule], + imports: [ + TypeOrmModule.forFeature([BusinessCategoryEntity, BusinessEntity]), + AuthModule, + ], controllers: [BusinessCategoriesController], providers: [BusinessCategoriesService], }) diff --git a/src/modules/business-categories/business-categories.service.ts b/src/modules/business-categories/business-categories.service.ts index 20c2225..1d855e1 100644 --- a/src/modules/business-categories/business-categories.service.ts +++ b/src/modules/business-categories/business-categories.service.ts @@ -4,8 +4,10 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { deleteCategoryCascade } from '../../common/utils/business-cascade.util'; import { BusinessCategoryEntity } from '../../entities/business-category.entity'; +import { BusinessEntity } from '../../entities/business.entity'; import { CreateBusinessCategoryDto } from './dto/create-business-category.dto'; import { UpdateBusinessCategoryDto } from './dto/update-business-category.dto'; @@ -14,6 +16,9 @@ export class BusinessCategoriesService { constructor( @InjectRepository(BusinessCategoryEntity) private readonly repo: Repository, + @InjectRepository(BusinessEntity) + private readonly businessRepo: Repository, + private readonly dataSource: DataSource, ) {} findAll(): Promise { @@ -62,9 +67,19 @@ export class BusinessCategoriesService { } async remove(id: string): Promise { - const result = await this.repo.delete(id); - if (!result.affected) { - throw new NotFoundException('Business category not found'); - } + await this.findOne(id); + + const businesses = await this.businessRepo.find({ + where: { categoryId: id }, + select: ['id'], + }); + + await this.dataSource.transaction(async (manager) => { + await deleteCategoryCascade( + manager, + id, + businesses.map((b) => b.id), + ); + }); } } diff --git a/src/modules/businesses/businesses.service.ts b/src/modules/businesses/businesses.service.ts index 2d4ae40..474880d 100644 --- a/src/modules/businesses/businesses.service.ts +++ b/src/modules/businesses/businesses.service.ts @@ -6,7 +6,8 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { deleteBusinessCascade } from '../../common/utils/business-cascade.util'; import { BusinessCategoryEntity } from '../../entities/business-category.entity'; import { BusinessEntity } from '../../entities/business.entity'; import { UserEntity, UserRole } from '../../entities/user.entity'; @@ -29,6 +30,7 @@ export class BusinessesService { private readonly categoryRepo: Repository, @InjectRepository(UserEntity) private readonly userRepo: Repository, + private readonly dataSource: DataSource, ) {} async findAll(filters: FindAllFilters): Promise { @@ -111,10 +113,11 @@ export class BusinessesService { } async remove(id: string): Promise { - const result = await this.repo.delete(id); - if (!result.affected) { - throw new NotFoundException('Business not found'); - } + await this.findOne(id); + + await this.dataSource.transaction(async (manager) => { + await deleteBusinessCascade(manager, id); + }); } async assignOwner( diff --git a/src/modules/businesses/dto/assign-business-owner.dto.ts b/src/modules/businesses/dto/assign-business-owner.dto.ts index 906cca4..c9a2e2d 100644 --- a/src/modules/businesses/dto/assign-business-owner.dto.ts +++ b/src/modules/businesses/dto/assign-business-owner.dto.ts @@ -1,12 +1,11 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString, Matches, MaxLength } from 'class-validator'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; +import { IsIranMobile } from '../../../common/decorators/is-iran-mobile.decorator'; export class AssignBusinessOwnerDto { @ApiProperty({ example: '09111111111' }) @IsString() - @Matches(/^(\+?\d{10,15}|09\d{9})$/, { - message: 'mobile must be a valid mobile number', - }) + @IsIranMobile() mobile!: string; @ApiPropertyOptional({ example: 'Business Admin', nullable: true })