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 { resolveUniqueSlug, normalizeSlugInput } from '../../common/utils/slug.util'; import { BusinessImagesService } from './business-images.service'; import { BusinessCategoryEntity } from '../../entities/business-category.entity'; import { normalizeHmTimeInput, parseHmToMinute, } from '../../common/utils/booking-schedule.util'; import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity'; import { BusinessEntity } from '../../entities/business.entity'; import { UserEntity, UserRole } from '../../entities/user.entity'; import { AuthUser } from '../auth/auth.types'; 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'; 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(UserEntity) private readonly userRepo: Repository, private readonly dataSource: DataSource, private readonly images: BusinessImagesService, ) {} async findAll(filters: FindAllFilters): Promise { const query = this.repo .createQueryBuilder('business') .leftJoinAndSelect('business.category', 'category') .leftJoinAndSelect('business.images', 'images') .orderBy('business.createdAt', 'DESC') .addOrderBy('images.isCover', 'DESC') .addOrderBy('images.sortOrder', 'ASC'); if (filters.categoryId) { query.andWhere('business.categoryId = :categoryId', { categoryId: filters.categoryId, }); } if (typeof filters.isActive === 'boolean') { query.andWhere('business.isActive = :isActive', { isActive: filters.isActive, }); } return query.getMany(); } 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'); } return business; } async findMine(ownerId: string): Promise { return this.repo.find({ where: { ownerId }, relations: { category: true, images: true }, order: { createdAt: 'DESC' }, }); } 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 entity = this.repo.create({ title: payload.title, slug, categoryId: payload.categoryId, description: payload.description ?? null, contactPhone: payload.contactPhone ?? null, address: payload.address ?? null, latitude: payload.latitude ?? null, longitude: payload.longitude ?? null, ownerId: payload.ownerId ?? null, isActive: payload.isActive ?? true, }); return this.repo.save(entity); } 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); } Object.assign(business, payload); return this.repo.save(business); } async remove(id: string): Promise { 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); } 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 startTime = normalizeHmTimeInput(slot.startTime); const endTime = normalizeHmTimeInput(slot.endTime); if (!startTime || !endTime) { throw new BadRequestException( 'ساعت باید به صورت HH:mm باشد (مثلاً 09:00)', ); } const startMinute = parseHmToMinute(startTime); const endMinute = parseHmToMinute(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 = this.normalizeMobile(dto.mobile); let user = await this.userRepo.findOne({ where: { mobile } }); if (!user) { user = this.userRepo.create({ mobile, fullName: dto.fullName ?? null, role: UserRole.BUSINESS_OWNER, isActive: true, }); } else { if (user.role !== UserRole.ADMIN) { user.role = UserRole.BUSINESS_OWNER; } if (dto.fullName !== undefined) { user.fullName = dto.fullName; } user.isActive = true; } const savedUser = await this.userRepo.save(user); business.ownerId = savedUser.id; return this.repo.save(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 normalizeMobile(mobile: string): string { const normalized = String(mobile ?? '').trim(); if (!/^(\+?\d{10,15}|09\d{9})$/.test(normalized)) { throw new BadRequestException('Invalid mobile number'); } return normalized; } 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')}`; } 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 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', ); } } }