import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { deleteBusinessCascade } from '../../common/utils/business-cascade.util'; import { parseHmToMinute } from '../../common/utils/booking-schedule.util'; import { isValidIranMobile, normalizeIranMobile, } from '../../common/utils/iran-mobile.util'; import { resolveUniqueSlug, normalizeSlugInput } from '../../common/utils/slug.util'; import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity'; import { BusinessCategoryEntity } from '../../entities/business-category.entity'; import { BusinessEntity } from '../../entities/business.entity'; import { BookingReviewEntity } from '../../entities/booking-review.entity'; import { BusinessImageEntity } from '../../entities/business-image.entity'; import { UserEntity, UserRole } from '../../entities/user.entity'; import { AuthUser } from '../auth/auth.types'; import { BusinessImagesService } from './business-images.service'; import { BusinessLogosService } from './business-logos.service'; import { AssignBusinessOwnerDto } from './dto/assign-business-owner.dto'; import { CreateBusinessDto } from './dto/create-business.dto'; import { SetAvailabilityDto } from './dto/set-availability.dto'; import { UpdateBusinessDto } from './dto/update-business.dto'; import { sanitizeBusinessHighlightKeys } from '../../common/constants/business-highlights'; type FindAllFilters = { categoryId?: string; isActive?: boolean; }; @Injectable() export class BusinessesService { constructor( @InjectRepository(BusinessEntity) private readonly repo: Repository, @InjectRepository(BusinessAvailabilitySlotEntity) private readonly availabilityRepo: Repository, @InjectRepository(BusinessCategoryEntity) private readonly categoryRepo: Repository, @InjectRepository(BusinessImageEntity) private readonly imageRepo: Repository, @InjectRepository(UserEntity) private readonly userRepo: Repository, @InjectRepository(BookingReviewEntity) private readonly reviewRepo: Repository, private readonly dataSource: DataSource, private readonly images: BusinessImagesService, private readonly logos: BusinessLogosService, ) {} async findAll(filters: FindAllFilters): Promise { const query = this.repo .createQueryBuilder('business') .leftJoinAndSelect('business.category', 'category') .orderBy('business.createdAt', 'DESC'); if (filters.categoryId) { query.andWhere('business.categoryId = :categoryId', { categoryId: filters.categoryId, }); } if (typeof filters.isActive === 'boolean') { query.andWhere('business.isActive = :isActive', { isActive: filters.isActive, }); } const businesses = await query.getMany(); await this.attachCoverImages(businesses); await this.attachReviewStats(businesses); return businesses; } async findMine(ownerId: string): Promise { const businesses = await this.repo.find({ where: { ownerId }, relations: { category: true, images: true }, order: { createdAt: 'DESC' }, }); for (const business of businesses) { business.images = this.sortImages(business.images ?? []); } return businesses; } async findOne(id: string): Promise { const business = await this.repo.findOne({ where: { id }, relations: { category: true, images: true }, }); if (!business) { throw new NotFoundException('Business not found'); } business.images = this.sortImages(business.images ?? []); await this.attachReviewStats([business]); return business; } async create(payload: CreateBusinessDto): Promise { const explicitSlug = normalizeSlugInput(payload.slug); if (explicitSlug) { const taken = await this.repo.findOne({ where: { slug: explicitSlug } }); if (taken) { throw new ConflictException('Slug already exists'); } } const slug = await resolveUniqueSlug( payload.title, async (candidate) => Boolean(await this.repo.findOne({ where: { slug: candidate } })), payload.slug, 'business', ); await this.ensureCategoryExists(payload.categoryId); const contactPhone = this.normalizeOptionalContactPhone(payload.contactPhone); const entity = this.repo.create({ title: payload.title, slug, subtitle: payload.subtitle ?? null, categoryId: payload.categoryId, description: payload.description ?? null, contactPhone, address: payload.address ?? null, latitude: payload.latitude ?? null, longitude: payload.longitude ?? null, ownerId: payload.ownerId ?? null, isActive: payload.isActive ?? true, }); const saved = await this.repo.save(entity); return this.ensureOwnerFromContactPhone(saved); } async update( id: string, payload: UpdateBusinessDto, actor?: AuthUser, ): Promise { const business = await this.findOne(id); this.assertCanUpdateBusiness(business, payload, actor); if (payload.slug && payload.slug !== business.slug) { const slugTaken = await this.repo.findOne({ where: { slug: payload.slug } }); if (slugTaken) { throw new ConflictException('Slug already exists'); } } if (payload.categoryId && payload.categoryId !== business.categoryId) { await this.ensureCategoryExists(payload.categoryId); } if (payload.contactPhone !== undefined) { payload.contactPhone = this.normalizeOptionalContactPhone(payload.contactPhone); } Object.assign(business, payload); if (payload.heroHighlightKeys !== undefined) { const sanitized = sanitizeBusinessHighlightKeys(payload.heroHighlightKeys); business.heroHighlightKeys = sanitized.length ? sanitized : null; } if (business.promoDiscountEnabled) { if ( !business.promoDiscountPercent || business.promoDiscountPercent < 1 || business.promoDiscountPercent > 100 ) { throw new BadRequestException( 'promoDiscountPercent must be between 1 and 100 when discount is enabled', ); } const title = business.promoDiscountTitle?.trim(); if (!title) { throw new BadRequestException( 'promoDiscountTitle is required when discount is enabled', ); } business.promoDiscountTitle = title; if ( business.promoDiscountMode !== 'first_booking' && business.promoDiscountMode !== 'always' ) { business.promoDiscountMode = 'first_booking'; } const code = business.promoDiscountCode?.trim(); if (!code) { business.promoDiscountCode = `NEW${business.promoDiscountPercent}`; } else { business.promoDiscountCode = code.toUpperCase(); } } const saved = await this.repo.save(business); return this.ensureOwnerFromContactPhone(saved); } async remove(id: string): Promise { const business = await this.findOne(id); const imageUrls = await this.images.listUrlsForBusiness(id); await this.dataSource.transaction(async (manager) => { await deleteBusinessCascade(manager, id); }); this.images.deleteFiles(imageUrls); this.logos.deleteLogoFiles(business); } async getAvailability(businessId: string) { await this.findOne(businessId); const rows = await this.availabilityRepo.find({ where: { businessId }, order: { dayOfWeek: 'ASC', startMinute: 'ASC' }, }); return rows.map((row) => ({ id: row.id, dayOfWeek: row.dayOfWeek, startTime: this.minuteToHm(row.startMinute), endTime: this.minuteToHm(row.endMinute), })); } async setAvailability( businessId: string, dto: SetAvailabilityDto, actor?: AuthUser, ) { const business = await this.findOne(businessId); this.assertCanManageAvailability(business, actor); const entities: BusinessAvailabilitySlotEntity[] = []; for (const slot of dto.slots) { const startMinute = parseHmToMinute(slot.startTime); const endMinute = parseHmToMinute(slot.endTime); if (endMinute <= startMinute) { throw new BadRequestException('endTime must be after startTime'); } entities.push( this.availabilityRepo.create({ businessId, dayOfWeek: slot.dayOfWeek, startMinute, endMinute, }), ); } await this.availabilityRepo.delete({ businessId }); if (entities.length) { await this.availabilityRepo.save(entities); } return this.getAvailability(businessId); } async assignOwner( id: string, dto: AssignBusinessOwnerDto, ): Promise { const business = await this.findOne(id); const mobile = normalizeIranMobile(dto.mobile); if (!isValidIranMobile(mobile)) { throw new BadRequestException('Invalid mobile number'); } const defaultName = `مدیر ${business.title}`; const resolvedName = dto.fullName === undefined ? undefined : dto.fullName?.trim() || defaultName; let user = await this.userRepo.findOne({ where: { mobile } }); if (!user) { user = this.userRepo.create({ mobile, fullName: resolvedName ?? defaultName, role: UserRole.BUSINESS_OWNER, isActive: true, }); } else { if (user.role !== UserRole.ADMIN) { user.role = UserRole.BUSINESS_OWNER; } if (resolvedName !== undefined) { user.fullName = resolvedName; } else if (!user.fullName?.trim()) { user.fullName = defaultName; } user.isActive = true; } const savedUser = await this.userRepo.save(user); business.ownerId = savedUser.id; if (!business.contactPhone) { business.contactPhone = mobile; } return this.repo.save(business); } async backfillMissingOwners(): Promise<{ filled: number; skipped: number }> { const withoutOwner = await this.repo .createQueryBuilder('business') .where('business.ownerId IS NULL') .andWhere('business.contactPhone IS NOT NULL') .andWhere("business.contactPhone <> ''") .getMany(); let filled = 0; let skipped = 0; for (const business of withoutOwner) { const mobile = normalizeIranMobile(business.contactPhone ?? ''); if (!isValidIranMobile(mobile)) { skipped += 1; continue; } const existing = await this.userRepo.findOne({ where: { mobile } }); await this.assignOwner(business.id, { mobile, ...(existing ? {} : { fullName: `مدیر ${business.title}` }), }); filled += 1; } return { filled, skipped }; } /** Create/attach owner user from contact phone when the business has none yet. */ private async ensureOwnerFromContactPhone( business: BusinessEntity, ): Promise { if (business.ownerId || !business.contactPhone) { return business; } const mobile = normalizeIranMobile(business.contactPhone); if (!isValidIranMobile(mobile)) { return business; } const existing = await this.userRepo.findOne({ where: { mobile } }); return this.assignOwner(business.id, { mobile, ...(existing ? {} : { fullName: `مدیر ${business.title}` }), }); } private normalizeOptionalContactPhone( value: string | null | undefined, ): string | null { if (value == null) return null; const trimmed = String(value).trim(); if (!trimmed) return null; const mobile = normalizeIranMobile(trimmed); return isValidIranMobile(mobile) ? mobile : trimmed; } private async attachCoverImages(businesses: BusinessEntity[]): Promise { if (!businesses.length) { return; } const ids = businesses.map((b) => b.id); const rows = (await this.imageRepo.query( `SELECT DISTINCT ON ("businessId") id, "businessId", url, "originalName", "sortOrder", "isCover", "createdAt" FROM business_images WHERE "businessId" = ANY($1::uuid[]) ORDER BY "businessId", "isCover" DESC, "sortOrder" ASC`, [ids], )) as Array<{ id: string; businessId: string; url: string; originalName: string | null; sortOrder: number; isCover: boolean; createdAt: Date; }>; const coverByBusiness = new Map(); for (const row of rows) { coverByBusiness.set(row.businessId, [ this.imageRepo.create({ id: row.id, businessId: row.businessId, url: row.url, originalName: row.originalName, sortOrder: row.sortOrder, isCover: row.isCover, createdAt: row.createdAt, }), ]); } for (const business of businesses) { business.images = coverByBusiness.get(business.id) ?? []; } } private minuteToHm(minute: number): string { const h = Math.floor(minute / 60); const m = minute % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; } async uploadImages( id: string, files: Express.Multer.File[], actor: AuthUser, ): Promise { const business = await this.findOne(id); this.assertCanManageBusiness(business, actor); return this.images.upload(id, files); } async uploadMyImages( files: Express.Multer.File[], actor: AuthUser, ): Promise { const business = await this.requireOwnedBusiness(actor); return this.images.upload(business.id, files); } async uploadMyLogo( variant: 'light' | 'dark', file: Express.Multer.File, actor: AuthUser, ): Promise { const business = await this.requireOwnedBusiness(actor); return this.logos.upload(business.id, variant, file, actor); } async removeMyLogo( variant: 'light' | 'dark', actor: AuthUser, ): Promise { const business = await this.requireOwnedBusiness(actor); return this.logos.remove(business.id, variant, actor); } async removeImage( id: string, imageId: string, actor: AuthUser, ): Promise { const business = await this.findOne(id); this.assertCanManageBusiness(business, actor); await this.images.remove(id, imageId); } async removeMyImage(imageId: string, actor: AuthUser): Promise { const business = await this.requireOwnedBusiness(actor); await this.images.remove(business.id, imageId); } async setMyCoverImage( imageId: string, actor: AuthUser, ): Promise { const business = await this.requireOwnedBusiness(actor); return this.images.setCover(business.id, imageId); } private async requireOwnedBusiness(actor: AuthUser): Promise { if (actor.role === UserRole.ADMIN) { throw new ForbiddenException('Use admin image endpoints for this account'); } if (actor.role !== UserRole.BUSINESS_OWNER) { throw new ForbiddenException('Insufficient role'); } const business = await this.repo.findOne({ where: { ownerId: actor.id }, order: { createdAt: 'DESC' }, }); if (!business) { throw new NotFoundException('No business linked to your account'); } return business; } private sortImages(images: BusinessImageEntity[]): BusinessImageEntity[] { return [...images].sort((a, b) => { if (a.isCover !== b.isCover) { return a.isCover ? -1 : 1; } return a.sortOrder - b.sortOrder; }); } private assertCanManageBusiness( business: BusinessEntity, actor?: AuthUser, ): void { if (!actor || actor.role === UserRole.ADMIN) { return; } if ( actor.role !== UserRole.BUSINESS_OWNER || business.ownerId !== actor.id ) { throw new ForbiddenException('You can only manage your own business'); } } private assertCanManageAvailability( business: BusinessEntity, actor?: AuthUser, ): void { if (!actor || actor.role === UserRole.ADMIN) { return; } if ( actor.role !== UserRole.BUSINESS_OWNER || business.ownerId !== actor.id ) { throw new ForbiddenException('You can only manage your own business'); } } private async ensureCategoryExists(categoryId: string): Promise { const category = await this.categoryRepo.findOne({ where: { id: categoryId } }); if (!category) { throw new NotFoundException('Business category not found'); } } private async attachReviewStats(businesses: BusinessEntity[]): Promise { if (!businesses.length) return; const ids = businesses.map((business) => business.id); const rows = await this.reviewRepo .createQueryBuilder('review') .select('review.businessId', 'businessId') .addSelect('AVG(review.rating)', 'averageRating') .addSelect('COUNT(review.id)', 'reviewCount') .where('review.businessId IN (:...ids)', { ids }) .groupBy('review.businessId') .getRawMany<{ businessId: string; averageRating: string; reviewCount: string; }>(); const statsById = new Map( rows.map((row) => [ row.businessId, { averageRating: Number.parseFloat(row.averageRating), reviewCount: Number.parseInt(row.reviewCount, 10), }, ]), ); for (const business of businesses) { const stats = statsById.get(business.id); (business as BusinessEntity & { averageRating?: number | null; reviewCount?: number; }).averageRating = stats?.averageRating ?? null; (business as BusinessEntity & { reviewCount?: number }).reviewCount = stats?.reviewCount ?? 0; } } private assertCanUpdateBusiness( business: BusinessEntity, payload: UpdateBusinessDto, actor?: AuthUser, ): void { if (!actor || actor.role === UserRole.ADMIN) { return; } if ( actor.role !== UserRole.BUSINESS_OWNER || business.ownerId !== actor.id ) { throw new ForbiddenException('You can only manage your own business'); } if ( payload.ownerId !== undefined || payload.categoryId !== undefined || payload.isActive !== undefined ) { throw new ForbiddenException( 'Only the super admin can change owner, category, or active status', ); } } }