import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; import { existsSync, mkdirSync, unlinkSync } from 'fs'; import { extname, join } from 'path'; import { Repository } from 'typeorm'; import { BusinessImageEntity } from '../../entities/business-image.entity'; import { BusinessEntity } from '../../entities/business.entity'; const ALLOWED_MIME = new Set([ 'image/jpeg', 'image/png', 'image/webp', 'image/gif', ]); const MAX_FILE_BYTES = 5 * 1024 * 1024; const MAX_IMAGES_PER_BUSINESS = 10; @Injectable() export class BusinessImagesService { private readonly uploadRoot: string; constructor( @InjectRepository(BusinessImageEntity) private readonly imageRepo: Repository, @InjectRepository(BusinessEntity) private readonly businessRepo: Repository, ) { this.uploadRoot = join(process.cwd(), process.env.UPLOAD_DIR ?? 'uploads'); } async listForBusiness(businessId: string): Promise { await this.ensureBusiness(businessId); return this.imageRepo.find({ where: { businessId }, order: { isCover: 'DESC', sortOrder: 'ASC', createdAt: 'ASC' }, }); } async upload( businessId: string, files: Express.Multer.File[], ): Promise { await this.ensureBusiness(businessId); if (!files?.length) { throw new BadRequestException('At least one image file is required'); } const existing = await this.imageRepo.count({ where: { businessId } }); if (existing + files.length > MAX_IMAGES_PER_BUSINESS) { throw new BadRequestException( `Maximum ${MAX_IMAGES_PER_BUSINESS} images per business`, ); } const dir = join(this.uploadRoot, 'businesses', businessId); mkdirSync(dir, { recursive: true }); const hasCover = await this.imageRepo.exists({ where: { businessId, isCover: true }, }); const saved: BusinessImageEntity[] = []; let sortOrder = existing; for (const file of files) { this.validateFile(file); const ext = extname(file.originalname).toLowerCase() || '.jpg'; const filename = `${randomUUID()}${ext}`; const relativeUrl = `businesses/${businessId}/${filename}`; const absolutePath = join(this.uploadRoot, relativeUrl); const { writeFileSync } = await import('fs'); writeFileSync(absolutePath, file.buffer); const entity = this.imageRepo.create({ businessId, url: relativeUrl, originalName: file.originalname, sortOrder, isCover: !hasCover && saved.length === 0, }); saved.push(await this.imageRepo.save(entity)); sortOrder += 1; } return saved; } async setCover(businessId: string, imageId: string): Promise { const image = await this.imageRepo.findOne({ where: { id: imageId, businessId }, }); if (!image) { throw new NotFoundException('Image not found'); } await this.imageRepo.update({ businessId }, { isCover: false }); image.isCover = true; return this.imageRepo.save(image); } async remove(businessId: string, imageId: string): Promise { const image = await this.imageRepo.findOne({ where: { id: imageId, businessId }, }); if (!image) { throw new NotFoundException('Image not found'); } this.deleteFile(image.url); const wasCover = image.isCover; await this.imageRepo.remove(image); if (wasCover) { const next = await this.imageRepo.findOne({ where: { businessId }, order: { sortOrder: 'ASC', createdAt: 'ASC' }, }); if (next) { next.isCover = true; await this.imageRepo.save(next); } } } async listUrlsForBusiness(businessId: string): Promise { const images = await this.imageRepo.find({ where: { businessId }, select: ['url'], }); return images.map((image) => image.url); } deleteFiles(urls: string[]): void { for (const url of urls) { this.deleteFile(url); } } async removeAllForBusiness(businessId: string): Promise { const urls = await this.listUrlsForBusiness(businessId); this.deleteFiles(urls); await this.imageRepo.delete({ businessId }); } private validateFile(file: Express.Multer.File): void { if (!ALLOWED_MIME.has(file.mimetype)) { throw new BadRequestException( 'Only JPEG, PNG, WebP and GIF images are allowed', ); } if (file.size > MAX_FILE_BYTES) { throw new BadRequestException('Image must be at most 5 MB'); } } private deleteFile(relativeUrl: string): void { const absolutePath = join(this.uploadRoot, relativeUrl); if (existsSync(absolutePath)) { unlinkSync(absolutePath); } } private async ensureBusiness(businessId: string): Promise { const exists = await this.businessRepo.exists({ where: { id: businessId } }); if (!exists) { throw new NotFoundException('Business not found'); } } }