From 715ca585a84b727d7969bb5fa94ae9ba9a8a5784 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 21 May 2026 17:57:27 +0330 Subject: [PATCH] Add business images upload and cascade delete improvements. --- .env.example | 3 + .gitignore | 3 + src/common/utils/business-cascade.util.ts | 9 +- src/entities/business-image.entity.ts | 40 +++++ src/entities/business.entity.ts | 6 + src/main.ts | 6 +- .../business-categories.module.ts | 4 +- .../business-categories.service.ts | 11 +- .../businesses/business-images.service.ts | 151 ++++++++++++++++++ .../businesses/businesses.controller.ts | 38 ++++- src/modules/businesses/businesses.module.ts | 12 +- src/modules/businesses/businesses.service.ts | 13 +- src/types/multer.d.ts | 21 +++ 13 files changed, 303 insertions(+), 14 deletions(-) create mode 100644 src/entities/business-image.entity.ts create mode 100644 src/modules/businesses/business-images.service.ts create mode 100644 src/types/multer.d.ts diff --git a/.env.example b/.env.example index c5e0a36..053b632 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,9 @@ OTP_REQUEST_MAX_PER_HOUR=5 # Dev/test only: returns OTP code in API response instead of sending SMS AUTH_RETURN_OTP=true +# Local folder for business images (served at /uploads/...) +UPLOAD_DIR=uploads + # Comma-separated frontend origins (required in production for browser clients) # 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 diff --git a/.gitignore b/.gitignore index 4866903..d0713e5 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ npm-debug.log* # Test / coverage coverage/ .nyc_output/ + +# User uploads +uploads/ diff --git a/src/common/utils/business-cascade.util.ts b/src/common/utils/business-cascade.util.ts index 9264096..93c034e 100644 --- a/src/common/utils/business-cascade.util.ts +++ b/src/common/utils/business-cascade.util.ts @@ -5,6 +5,7 @@ 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 { BusinessImageEntity } from '../../entities/business-image.entity'; import { BusinessEntity } from '../../entities/business.entity'; import { ServiceTemplateEntity } from '../../entities/service-template.entity'; @@ -12,10 +13,15 @@ import { ServiceTemplateEntity } from '../../entities/service-template.entity'; export async function deleteBusinessCascade( manager: EntityManager, businessId: string, + onBeforeDeleteBusiness?: (businessId: string) => Promise, ): Promise { await manager.delete(BookingEntity, { businessId }); await manager.delete(BusinessServiceEntity, { businessId }); await manager.delete(BusinessAttributeValueEntity, { businessId }); + await manager.delete(BusinessImageEntity, { businessId }); + if (onBeforeDeleteBusiness) { + await onBeforeDeleteBusiness(businessId); + } const result = await manager.delete(BusinessEntity, businessId); if (!result.affected) { @@ -28,9 +34,10 @@ export async function deleteCategoryCascade( manager: EntityManager, categoryId: string, businessIds: string[], + onBeforeDeleteBusiness?: (businessId: string) => Promise, ): Promise { for (const businessId of businessIds) { - await deleteBusinessCascade(manager, businessId); + await deleteBusinessCascade(manager, businessId, onBeforeDeleteBusiness); } await manager.delete(ServiceTemplateEntity, { categoryId }); diff --git a/src/entities/business-image.entity.ts b/src/entities/business-image.entity.ts new file mode 100644 index 0000000..38af30e --- /dev/null +++ b/src/entities/business-image.entity.ts @@ -0,0 +1,40 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { BusinessEntity } from './business.entity'; + +@Entity('business_images') +export class BusinessImageEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => BusinessEntity, (business) => business.images, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'businessId' }) + business!: BusinessEntity; + + @Column('uuid') + businessId!: string; + + /** Relative path under uploads root, e.g. businesses/{id}/{file}.jpg */ + @Column({ length: 500 }) + url!: string; + + @Column({ type: 'varchar', nullable: true, length: 255 }) + originalName!: string | null; + + @Column({ default: 0 }) + sortOrder!: number; + + @Column({ default: false }) + isCover!: boolean; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/src/entities/business.entity.ts b/src/entities/business.entity.ts index 5e62517..8e568fe 100644 --- a/src/entities/business.entity.ts +++ b/src/entities/business.entity.ts @@ -11,6 +11,7 @@ import { import { BookingEntity } from './booking.entity'; import { BusinessAttributeValueEntity } from './business-attribute-value.entity'; import { BusinessCategoryEntity } from './business-category.entity'; +import { BusinessImageEntity } from './business-image.entity'; import { BusinessServiceEntity } from './business-service.entity'; import { UserEntity } from './user.entity'; @@ -62,6 +63,11 @@ export class BusinessEntity { @Column({ type: 'uuid', nullable: true }) ownerId!: string | null; + @OneToMany(() => BusinessImageEntity, (image) => image.business, { + cascade: true, + }) + images!: BusinessImageEntity[]; + @OneToMany(() => BusinessServiceEntity, (service) => service.business) services!: BusinessServiceEntity[]; diff --git a/src/main.ts b/src/main.ts index f63765e..45fc8a9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,9 @@ import 'reflect-metadata'; import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { join } from 'path'; import { TypeOrmExceptionFilter } from './common/filters/typeorm-exception.filter'; import { AppModule } from './app.module'; @@ -18,7 +20,9 @@ function configureCors(app: Awaited>) { } async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); + const uploadDir = process.env.UPLOAD_DIR ?? 'uploads'; + app.useStaticAssets(join(process.cwd(), uploadDir), { prefix: '/uploads' }); configureCors(app); app.useGlobalPipes( new ValidationPipe({ diff --git a/src/modules/business-categories/business-categories.module.ts b/src/modules/business-categories/business-categories.module.ts index 170d9f4..d46b47e 100644 --- a/src/modules/business-categories/business-categories.module.ts +++ b/src/modules/business-categories/business-categories.module.ts @@ -1,14 +1,16 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } 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 { BusinessesModule } from '../businesses/businesses.module'; import { BusinessCategoriesController } from './business-categories.controller'; import { BusinessCategoriesService } from './business-categories.service'; @Module({ imports: [ TypeOrmModule.forFeature([BusinessCategoryEntity, BusinessEntity]), + forwardRef(() => BusinessesModule), AuthModule, ], controllers: [BusinessCategoriesController], diff --git a/src/modules/business-categories/business-categories.service.ts b/src/modules/business-categories/business-categories.service.ts index 1d855e1..57c2de4 100644 --- a/src/modules/business-categories/business-categories.service.ts +++ b/src/modules/business-categories/business-categories.service.ts @@ -6,6 +6,7 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { deleteCategoryCascade } from '../../common/utils/business-cascade.util'; +import { BusinessImagesService } from '../businesses/business-images.service'; import { BusinessCategoryEntity } from '../../entities/business-category.entity'; import { BusinessEntity } from '../../entities/business.entity'; import { CreateBusinessCategoryDto } from './dto/create-business-category.dto'; @@ -19,6 +20,7 @@ export class BusinessCategoriesService { @InjectRepository(BusinessEntity) private readonly businessRepo: Repository, private readonly dataSource: DataSource, + private readonly images: BusinessImagesService, ) {} findAll(): Promise { @@ -74,12 +76,11 @@ export class BusinessCategoriesService { select: ['id'], }); + const businessIds = businesses.map((b) => b.id); await this.dataSource.transaction(async (manager) => { - await deleteCategoryCascade( - manager, - id, - businesses.map((b) => b.id), - ); + await deleteCategoryCascade(manager, id, businessIds, async (businessId) => { + await this.images.removeAllForBusiness(businessId); + }); }); } } diff --git a/src/modules/businesses/business-images.service.ts b/src/modules/businesses/business-images.service.ts new file mode 100644 index 0000000..120ce86 --- /dev/null +++ b/src/modules/businesses/business-images.service.ts @@ -0,0 +1,151 @@ +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 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 removeAllForBusiness(businessId: string): Promise { + const images = await this.imageRepo.find({ where: { businessId } }); + for (const image of images) { + this.deleteFile(image.url); + } + 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'); + } + } +} diff --git a/src/modules/businesses/businesses.controller.ts b/src/modules/businesses/businesses.controller.ts index 3650e8e..ea6edd5 100644 --- a/src/modules/businesses/businesses.controller.ts +++ b/src/modules/businesses/businesses.controller.ts @@ -7,8 +7,13 @@ import { Patch, Post, Query, + UploadedFiles, UseGuards, + UseInterceptors, } from '@nestjs/common'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import { BusinessImageEntity } from '../../entities/business-image.entity'; import { BusinessEntity } from '../../entities/business.entity'; import { UserRole } from '../../entities/user.entity'; import { AuthUser } from '../auth/auth.types'; @@ -16,6 +21,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; +import { BusinessImagesService } from './business-images.service'; import { BusinessesService } from './businesses.service'; import { AssignBusinessOwnerDto } from './dto/assign-business-owner.dto'; import { CreateBusinessDto } from './dto/create-business.dto'; @@ -23,7 +29,10 @@ import { UpdateBusinessDto } from './dto/update-business.dto'; @Controller('businesses') export class BusinessesController { - constructor(private readonly service: BusinessesService) {} + constructor( + private readonly service: BusinessesService, + private readonly images: BusinessImagesService, + ) {} @Get() findAll( @@ -51,6 +60,33 @@ export class BusinessesController { return this.service.create(payload); } + @Post(':id/images') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @UseInterceptors( + FilesInterceptor('files', 10, { + storage: memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 }, + }), + ) + uploadImages( + @Param('id') id: string, + @UploadedFiles() files: Express.Multer.File[], + ): Promise { + return this.images.upload(id, files); + } + + @Delete(':id/images/:imageId') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + async removeImage( + @Param('id') id: string, + @Param('imageId') imageId: string, + ): Promise<{ success: true }> { + await this.images.remove(id, imageId); + return { success: true }; + } + @Post(':id/assign-owner') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) diff --git a/src/modules/businesses/businesses.module.ts b/src/modules/businesses/businesses.module.ts index ae407ec..da78201 100644 --- a/src/modules/businesses/businesses.module.ts +++ b/src/modules/businesses/businesses.module.ts @@ -1,18 +1,26 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BusinessCategoryEntity } from '../../entities/business-category.entity'; +import { BusinessImageEntity } from '../../entities/business-image.entity'; import { BusinessEntity } from '../../entities/business.entity'; import { UserEntity } from '../../entities/user.entity'; import { AuthModule } from '../auth/auth.module'; +import { BusinessImagesService } from './business-images.service'; import { BusinessesController } from './businesses.controller'; import { BusinessesService } from './businesses.service'; @Module({ imports: [ - TypeOrmModule.forFeature([BusinessEntity, BusinessCategoryEntity, UserEntity]), + TypeOrmModule.forFeature([ + BusinessEntity, + BusinessCategoryEntity, + UserEntity, + BusinessImageEntity, + ]), AuthModule, ], controllers: [BusinessesController], - providers: [BusinessesService], + providers: [BusinessesService, BusinessImagesService], + exports: [BusinessImagesService], }) export class BusinessesModule {} diff --git a/src/modules/businesses/businesses.service.ts b/src/modules/businesses/businesses.service.ts index 474880d..9c96704 100644 --- a/src/modules/businesses/businesses.service.ts +++ b/src/modules/businesses/businesses.service.ts @@ -8,6 +8,7 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { deleteBusinessCascade } from '../../common/utils/business-cascade.util'; +import { BusinessImagesService } from './business-images.service'; import { BusinessCategoryEntity } from '../../entities/business-category.entity'; import { BusinessEntity } from '../../entities/business.entity'; import { UserEntity, UserRole } from '../../entities/user.entity'; @@ -31,13 +32,17 @@ export class BusinessesService { @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') - .orderBy('business.createdAt', 'DESC'); + .leftJoinAndSelect('business.images', 'images') + .orderBy('business.createdAt', 'DESC') + .addOrderBy('images.isCover', 'DESC') + .addOrderBy('images.sortOrder', 'ASC'); if (filters.categoryId) { query.andWhere('business.categoryId = :categoryId', { @@ -57,7 +62,7 @@ export class BusinessesService { async findOne(id: string): Promise { const business = await this.repo.findOne({ where: { id }, - relations: { category: true }, + relations: { category: true, images: true }, }); if (!business) { throw new NotFoundException('Business not found'); @@ -116,7 +121,9 @@ export class BusinessesService { await this.findOne(id); await this.dataSource.transaction(async (manager) => { - await deleteBusinessCascade(manager, id); + await deleteBusinessCascade(manager, id, async (businessId) => { + await this.images.removeAllForBusiness(businessId); + }); }); } diff --git a/src/types/multer.d.ts b/src/types/multer.d.ts new file mode 100644 index 0000000..3d8ceac --- /dev/null +++ b/src/types/multer.d.ts @@ -0,0 +1,21 @@ +declare namespace Express { + namespace Multer { + interface File { + fieldname: string; + originalname: string; + encoding: string; + mimetype: string; + size: number; + buffer: Buffer; + } + } +} + +declare module 'multer' { + import { RequestHandler } from 'express'; + export function memoryStorage(): unknown; + export interface Options { + storage?: unknown; + limits?: { fileSize?: number }; + } +}