From 61c8653af6e2e1609aac9fa67b535f8d583cbaf3 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 16 Jul 2026 13:32:53 +0330 Subject: [PATCH] logo set --- .../migrations/1789047500000-AddLogoFields.ts | 33 +++++ src/entities/business.entity.ts | 6 + src/entities/platform-settings.entity.ts | 6 + .../businesses/business-logos.service.ts | 133 ++++++++++++++++++ .../businesses/businesses.controller.ts | 109 +++++++++++++- src/modules/businesses/businesses.module.ts | 3 +- src/modules/businesses/businesses.service.ts | 22 ++- .../platform-settings.controller.ts | 52 +++++-- .../platform-settings.dto.ts | 19 +++ .../platform-settings.service.ts | 88 +++++++++--- 10 files changed, 434 insertions(+), 37 deletions(-) create mode 100644 src/database/migrations/1789047500000-AddLogoFields.ts create mode 100644 src/modules/businesses/business-logos.service.ts create mode 100644 src/modules/platform-settings/platform-settings.dto.ts diff --git a/src/database/migrations/1789047500000-AddLogoFields.ts b/src/database/migrations/1789047500000-AddLogoFields.ts new file mode 100644 index 0000000..7092ca4 --- /dev/null +++ b/src/database/migrations/1789047500000-AddLogoFields.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddLogoFields1789047500000 implements MigrationInterface { + name = 'AddLogoFields1789047500000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "platform_settings" + ADD COLUMN IF NOT EXISTS "logo_light_url" character varying(500), + ADD COLUMN IF NOT EXISTS "logo_dark_url" character varying(500) + `); + + await queryRunner.query(` + ALTER TABLE "businesses" + ADD COLUMN IF NOT EXISTS "logo_light_url" character varying(500), + ADD COLUMN IF NOT EXISTS "logo_dark_url" character varying(500) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "businesses" + DROP COLUMN IF EXISTS "logo_dark_url", + DROP COLUMN IF EXISTS "logo_light_url" + `); + + await queryRunner.query(` + ALTER TABLE "platform_settings" + DROP COLUMN IF EXISTS "logo_dark_url", + DROP COLUMN IF EXISTS "logo_light_url" + `); + } +} diff --git a/src/entities/business.entity.ts b/src/entities/business.entity.ts index c1661d1..a4fdacb 100644 --- a/src/entities/business.entity.ts +++ b/src/entities/business.entity.ts @@ -85,6 +85,12 @@ export class BusinessEntity { @Column({ type: 'varchar', length: 20, default: 'first_booking' }) promoDiscountMode!: 'first_booking' | 'always'; + @Column({ name: 'logo_light_url', type: 'varchar', length: 500, nullable: true }) + logoLightUrl!: string | null; + + @Column({ name: 'logo_dark_url', type: 'varchar', length: 500, nullable: true }) + logoDarkUrl!: string | null; + @ManyToOne(() => BusinessCategoryEntity, (category) => category.businesses, { onDelete: 'RESTRICT', }) diff --git a/src/entities/platform-settings.entity.ts b/src/entities/platform-settings.entity.ts index 4c1dc99..67abc56 100644 --- a/src/entities/platform-settings.entity.ts +++ b/src/entities/platform-settings.entity.ts @@ -8,6 +8,12 @@ export class PlatformSettingsEntity { @Column({ name: 'login_hero_image_url', type: 'varchar', length: 500, nullable: true }) loginHeroImageUrl!: string | null; + @Column({ name: 'logo_light_url', type: 'varchar', length: 500, nullable: true }) + logoLightUrl!: string | null; + + @Column({ name: 'logo_dark_url', type: 'varchar', length: 500, nullable: true }) + logoDarkUrl!: string | null; + @Column({ name: 'updated_at', type: 'timestamptz', diff --git a/src/modules/businesses/business-logos.service.ts b/src/modules/businesses/business-logos.service.ts new file mode 100644 index 0000000..91ed0bd --- /dev/null +++ b/src/modules/businesses/business-logos.service.ts @@ -0,0 +1,133 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { randomUUID } from 'crypto'; +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'fs'; +import { extname, join } from 'path'; +import { Repository } from 'typeorm'; +import { BusinessEntity } from '../../entities/business.entity'; +import { UserRole } from '../../entities/user.entity'; +import { AuthUser } from '../auth/auth.types'; + +const ALLOWED_MIME = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/gif', +]); +const MAX_FILE_BYTES = 5 * 1024 * 1024; + +export type LogoVariant = 'light' | 'dark'; + +@Injectable() +export class BusinessLogosService { + private readonly uploadRoot: string; + + constructor( + @InjectRepository(BusinessEntity) + private readonly businessRepo: Repository, + ) { + this.uploadRoot = join(process.cwd(), process.env.UPLOAD_DIR ?? 'uploads'); + } + + async upload( + businessId: string, + variant: LogoVariant, + file: Express.Multer.File, + actor: AuthUser, + ): Promise { + const business = await this.ensureCanManage(businessId, actor); + this.validateFile(file); + + const dir = join(this.uploadRoot, 'businesses', businessId, 'logo'); + mkdirSync(dir, { recursive: true }); + + const field = variant === 'light' ? 'logoLightUrl' : 'logoDarkUrl'; + if (business[field]) { + this.deleteStoredFile(business[field]!); + } + + business[field] = this.saveFile(dir, businessId, variant, file); + return this.businessRepo.save(business); + } + + async remove( + businessId: string, + variant: LogoVariant, + actor: AuthUser, + ): Promise { + const business = await this.ensureCanManage(businessId, actor); + const field = variant === 'light' ? 'logoLightUrl' : 'logoDarkUrl'; + + if (business[field]) { + this.deleteStoredFile(business[field]!); + business[field] = null; + return this.businessRepo.save(business); + } + + return business; + } + + deleteLogoFiles(business: Pick): void { + for (const url of [business.logoLightUrl, business.logoDarkUrl]) { + if (url) { + this.deleteStoredFile(url); + } + } + } + + private async ensureCanManage( + businessId: string, + actor: AuthUser, + ): Promise { + const business = await this.businessRepo.findOne({ where: { id: businessId } }); + if (!business) { + throw new NotFoundException('Business not found'); + } + + if (actor.role === UserRole.ADMIN) { + return business; + } + + if (actor.role === UserRole.BUSINESS_OWNER && business.ownerId === actor.id) { + return business; + } + + throw new ForbiddenException('Not allowed to manage this business logo'); + } + + private validateFile(file: Express.Multer.File | undefined): void { + if (!file) { + throw new BadRequestException('Image file is required'); + } + 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 saveFile( + dir: string, + businessId: string, + variant: LogoVariant, + file: Express.Multer.File, + ): string { + const ext = extname(file.originalname).toLowerCase() || '.jpg'; + const filename = `logo-${variant}-${randomUUID()}${ext}`; + writeFileSync(join(dir, filename), file.buffer); + return `businesses/${businessId}/logo/${filename}`; + } + + private deleteStoredFile(relativeUrl: string): void { + const absolutePath = join(this.uploadRoot, relativeUrl); + if (existsSync(absolutePath)) { + unlinkSync(absolutePath); + } + } +} diff --git a/src/modules/businesses/businesses.controller.ts b/src/modules/businesses/businesses.controller.ts index b55bf95..5311b2d 100644 --- a/src/modules/businesses/businesses.controller.ts +++ b/src/modules/businesses/businesses.controller.ts @@ -8,11 +8,12 @@ import { Post, Put, Query, + UploadedFile, UploadedFiles, UseGuards, UseInterceptors, } from '@nestjs/common'; -import { FilesInterceptor } from '@nestjs/platform-express'; +import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; import { memoryStorage } from 'multer'; import { BusinessImageEntity } from '../../entities/business-image.entity'; import { BusinessEntity } from '../../entities/business.entity'; @@ -23,6 +24,7 @@ import { Roles } from '../auth/decorators/roles.decorator'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { BusinessesService } from './businesses.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'; @@ -30,7 +32,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 logos: BusinessLogosService, + ) {} @Get() findAll( @@ -90,6 +95,52 @@ export class BusinessesController { return this.service.setMyCoverImage(imageId, user); } + @Post('mine/logo-light') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.BUSINESS_OWNER) + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 }, + }), + ) + uploadMyLogoLight( + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: AuthUser, + ): Promise { + return this.service.uploadMyLogo('light', file, user); + } + + @Post('mine/logo-dark') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.BUSINESS_OWNER) + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 }, + }), + ) + uploadMyLogoDark( + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: AuthUser, + ): Promise { + return this.service.uploadMyLogo('dark', file, user); + } + + @Delete('mine/logo-light') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.BUSINESS_OWNER) + removeMyLogoLight(@CurrentUser() user: AuthUser): Promise { + return this.service.removeMyLogo('light', user); + } + + @Delete('mine/logo-dark') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.BUSINESS_OWNER) + removeMyLogoDark(@CurrentUser() user: AuthUser): Promise { + return this.service.removeMyLogo('dark', user); + } + @Get(':id/availability') getAvailability(@Param('id') id: string) { return this.service.getAvailability(id); @@ -147,6 +198,60 @@ export class BusinessesController { return { success: true }; } + @Post(':id/logo-light') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 }, + }), + ) + uploadLogoLight( + @Param('id') id: string, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: AuthUser, + ): Promise { + return this.logos.upload(id, 'light', file, user); + } + + @Post(':id/logo-dark') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 }, + }), + ) + uploadLogoDark( + @Param('id') id: string, + @UploadedFile() file: Express.Multer.File, + @CurrentUser() user: AuthUser, + ): Promise { + return this.logos.upload(id, 'dark', file, user); + } + + @Delete(':id/logo-light') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) + removeLogoLight( + @Param('id') id: string, + @CurrentUser() user: AuthUser, + ): Promise { + return this.logos.remove(id, 'light', user); + } + + @Delete(':id/logo-dark') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) + removeLogoDark( + @Param('id') id: string, + @CurrentUser() user: AuthUser, + ): Promise { + return this.logos.remove(id, 'dark', user); + } + @Post('backfill-owners') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) diff --git a/src/modules/businesses/businesses.module.ts b/src/modules/businesses/businesses.module.ts index da5139f..ff5582f 100644 --- a/src/modules/businesses/businesses.module.ts +++ b/src/modules/businesses/businesses.module.ts @@ -8,6 +8,7 @@ import { BookingReviewEntity } from '../../entities/booking-review.entity'; import { UserEntity } from '../../entities/user.entity'; import { AuthModule } from '../auth/auth.module'; import { BusinessImagesService } from './business-images.service'; +import { BusinessLogosService } from './business-logos.service'; import { BusinessesController } from './businesses.controller'; import { BusinessesService } from './businesses.service'; @@ -24,7 +25,7 @@ import { BusinessesService } from './businesses.service'; AuthModule, ], controllers: [BusinessesController], - providers: [BusinessesService, BusinessImagesService], + providers: [BusinessesService, BusinessImagesService, BusinessLogosService], exports: [BusinessImagesService], }) export class BusinessesModule {} diff --git a/src/modules/businesses/businesses.service.ts b/src/modules/businesses/businesses.service.ts index 6c35fac..9fd5c72 100644 --- a/src/modules/businesses/businesses.service.ts +++ b/src/modules/businesses/businesses.service.ts @@ -22,6 +22,7 @@ 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'; @@ -49,6 +50,7 @@ export class BusinessesService { private readonly reviewRepo: Repository, private readonly dataSource: DataSource, private readonly images: BusinessImagesService, + private readonly logos: BusinessLogosService, ) {} async findAll(filters: FindAllFilters): Promise { @@ -199,7 +201,7 @@ export class BusinessesService { } async remove(id: string): Promise { - await this.findOne(id); + const business = await this.findOne(id); const imageUrls = await this.images.listUrlsForBusiness(id); @@ -208,6 +210,7 @@ export class BusinessesService { }); this.images.deleteFiles(imageUrls); + this.logos.deleteLogoFiles(business); } async getAvailability(businessId: string) { @@ -424,6 +427,23 @@ export class BusinessesService { 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, diff --git a/src/modules/platform-settings/platform-settings.controller.ts b/src/modules/platform-settings/platform-settings.controller.ts index aa1f155..fb1913e 100644 --- a/src/modules/platform-settings/platform-settings.controller.ts +++ b/src/modules/platform-settings/platform-settings.controller.ts @@ -13,10 +13,13 @@ import { UserRole } from '../../entities/user.entity'; import { Roles } from '../auth/decorators/roles.decorator'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; -import { - PlatformSettingsDto, - PlatformSettingsService, -} from './platform-settings.service'; +import { PlatformSettingsDto } from './platform-settings.dto'; +import { PlatformSettingsService } from './platform-settings.service'; + +const imageUpload = FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 }, +}); @Controller('platform-settings') export class PlatformSettingsController { @@ -30,12 +33,7 @@ export class PlatformSettingsController { @Post('login-hero-image') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) - @UseInterceptors( - FileInterceptor('file', { - storage: memoryStorage(), - limits: { fileSize: 5 * 1024 * 1024 }, - }), - ) + @UseInterceptors(imageUpload) uploadLoginHeroImage( @UploadedFile() file: Express.Multer.File, ): Promise { @@ -48,4 +46,38 @@ export class PlatformSettingsController { removeLoginHeroImage(): Promise { return this.service.removeLoginHeroImage(); } + + @Post('logo-light') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @UseInterceptors(imageUpload) + uploadLogoLight( + @UploadedFile() file: Express.Multer.File, + ): Promise { + return this.service.uploadLogo('light', file); + } + + @Delete('logo-light') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + removeLogoLight(): Promise { + return this.service.removeLogo('light'); + } + + @Post('logo-dark') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @UseInterceptors(imageUpload) + uploadLogoDark( + @UploadedFile() file: Express.Multer.File, + ): Promise { + return this.service.uploadLogo('dark', file); + } + + @Delete('logo-dark') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + removeLogoDark(): Promise { + return this.service.removeLogo('dark'); + } } diff --git a/src/modules/platform-settings/platform-settings.dto.ts b/src/modules/platform-settings/platform-settings.dto.ts new file mode 100644 index 0000000..e0e06f6 --- /dev/null +++ b/src/modules/platform-settings/platform-settings.dto.ts @@ -0,0 +1,19 @@ +import { PlatformSettingsEntity } from '../../entities/platform-settings.entity'; + +export type PlatformSettingsDto = { + loginHeroImageUrl: string | null; + logoLightUrl: string | null; + logoDarkUrl: string | null; +}; + +export type LogoVariant = 'light' | 'dark'; + +export function toPlatformSettingsDto( + row: PlatformSettingsEntity, +): PlatformSettingsDto { + return { + loginHeroImageUrl: row.loginHeroImageUrl, + logoLightUrl: row.logoLightUrl, + logoDarkUrl: row.logoDarkUrl, + }; +} diff --git a/src/modules/platform-settings/platform-settings.service.ts b/src/modules/platform-settings/platform-settings.service.ts index 9a67b60..09ab20c 100644 --- a/src/modules/platform-settings/platform-settings.service.ts +++ b/src/modules/platform-settings/platform-settings.service.ts @@ -1,10 +1,15 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; -import { existsSync, mkdirSync, unlinkSync } from 'fs'; +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'fs'; import { extname, join } from 'path'; import { Repository } from 'typeorm'; import { PlatformSettingsEntity } from '../../entities/platform-settings.entity'; +import { + LogoVariant, + PlatformSettingsDto, + toPlatformSettingsDto, +} from './platform-settings.dto'; const ALLOWED_MIME = new Set([ 'image/jpeg', @@ -15,10 +20,6 @@ const ALLOWED_MIME = new Set([ const MAX_FILE_BYTES = 5 * 1024 * 1024; const SETTINGS_ID = 1; -export type PlatformSettingsDto = { - loginHeroImageUrl: string | null; -}; - @Injectable() export class PlatformSettingsService { private readonly uploadRoot: string; @@ -32,7 +33,7 @@ export class PlatformSettingsService { async getSettings(): Promise { const row = await this.ensureRow(); - return this.toDto(row); + return toPlatformSettingsDto(row); } async uploadLoginHeroImage( @@ -48,19 +49,12 @@ export class PlatformSettingsService { this.deleteStoredFile(row.loginHeroImageUrl); } - const ext = extname(file.originalname).toLowerCase() || '.jpg'; - const filename = `login-hero-${randomUUID()}${ext}`; - const relativeUrl = `platform/${filename}`; - const absolutePath = join(this.uploadRoot, relativeUrl); - - const { writeFileSync } = await import('fs'); - writeFileSync(absolutePath, file.buffer); - + const relativeUrl = this.saveFile(dir, 'login-hero', file); row.loginHeroImageUrl = relativeUrl; row.updatedAt = new Date(); await this.repo.save(row); - return this.toDto(row); + return toPlatformSettingsDto(row); } async removeLoginHeroImage(): Promise { @@ -73,24 +67,60 @@ export class PlatformSettingsService { await this.repo.save(row); } - return this.toDto(row); + return toPlatformSettingsDto(row); + } + + async uploadLogo( + variant: LogoVariant, + file: Express.Multer.File, + ): Promise { + this.validateFile(file); + + const row = await this.ensureRow(); + const dir = join(this.uploadRoot, 'platform'); + mkdirSync(dir, { recursive: true }); + + const field = variant === 'light' ? 'logoLightUrl' : 'logoDarkUrl'; + if (row[field]) { + this.deleteStoredFile(row[field]!); + } + + const relativeUrl = this.saveFile(dir, `logo-${variant}`, file); + row[field] = relativeUrl; + row.updatedAt = new Date(); + await this.repo.save(row); + + return toPlatformSettingsDto(row); + } + + async removeLogo(variant: LogoVariant): Promise { + const row = await this.ensureRow(); + const field = variant === 'light' ? 'logoLightUrl' : 'logoDarkUrl'; + + if (row[field]) { + this.deleteStoredFile(row[field]!); + row[field] = null; + row.updatedAt = new Date(); + await this.repo.save(row); + } + + return toPlatformSettingsDto(row); } private async ensureRow(): Promise { let row = await this.repo.findOne({ where: { id: SETTINGS_ID } }); if (!row) { - row = this.repo.create({ id: SETTINGS_ID, loginHeroImageUrl: null }); + row = this.repo.create({ + id: SETTINGS_ID, + loginHeroImageUrl: null, + logoLightUrl: null, + logoDarkUrl: null, + }); await this.repo.save(row); } return row; } - private toDto(row: PlatformSettingsEntity): PlatformSettingsDto { - return { - loginHeroImageUrl: row.loginHeroImageUrl, - }; - } - private validateFile(file: Express.Multer.File | undefined): void { if (!file) { throw new BadRequestException('Image file is required'); @@ -103,6 +133,18 @@ export class PlatformSettingsService { } } + private saveFile( + dir: string, + prefix: string, + file: Express.Multer.File, + ): string { + const ext = extname(file.originalname).toLowerCase() || '.jpg'; + const filename = `${prefix}-${randomUUID()}${ext}`; + const relativeUrl = `platform/${filename}`; + writeFileSync(join(dir, filename), file.buffer); + return relativeUrl; + } + private deleteStoredFile(relativeUrl: string): void { const absolutePath = join(this.uploadRoot, relativeUrl); if (existsSync(absolutePath)) {