This commit is contained in:
2026-07-16 13:32:53 +03:30
parent 893035cef2
commit 61c8653af6
10 changed files with 434 additions and 37 deletions
@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLogoFields1789047500000 implements MigrationInterface {
name = 'AddLogoFields1789047500000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"
`);
}
}
+6
View File
@@ -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',
})
+6
View File
@@ -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',
@@ -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<BusinessEntity>,
) {
this.uploadRoot = join(process.cwd(), process.env.UPLOAD_DIR ?? 'uploads');
}
async upload(
businessId: string,
variant: LogoVariant,
file: Express.Multer.File,
actor: AuthUser,
): Promise<BusinessEntity> {
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<BusinessEntity> {
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<BusinessEntity, 'logoLightUrl' | 'logoDarkUrl'>): void {
for (const url of [business.logoLightUrl, business.logoDarkUrl]) {
if (url) {
this.deleteStoredFile(url);
}
}
}
private async ensureCanManage(
businessId: string,
actor: AuthUser,
): Promise<BusinessEntity> {
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);
}
}
}
+107 -2
View File
@@ -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<BusinessEntity> {
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<BusinessEntity> {
return this.service.uploadMyLogo('dark', file, user);
}
@Delete('mine/logo-light')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.BUSINESS_OWNER)
removeMyLogoLight(@CurrentUser() user: AuthUser): Promise<BusinessEntity> {
return this.service.removeMyLogo('light', user);
}
@Delete('mine/logo-dark')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.BUSINESS_OWNER)
removeMyLogoDark(@CurrentUser() user: AuthUser): Promise<BusinessEntity> {
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<BusinessEntity> {
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<BusinessEntity> {
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<BusinessEntity> {
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<BusinessEntity> {
return this.logos.remove(id, 'dark', user);
}
@Post('backfill-owners')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
+2 -1
View File
@@ -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 {}
+21 -1
View File
@@ -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<BookingReviewEntity>,
private readonly dataSource: DataSource,
private readonly images: BusinessImagesService,
private readonly logos: BusinessLogosService,
) {}
async findAll(filters: FindAllFilters): Promise<BusinessEntity[]> {
@@ -199,7 +201,7 @@ export class BusinessesService {
}
async remove(id: string): Promise<void> {
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<BusinessEntity> {
const business = await this.requireOwnedBusiness(actor);
return this.logos.upload(business.id, variant, file, actor);
}
async removeMyLogo(
variant: 'light' | 'dark',
actor: AuthUser,
): Promise<BusinessEntity> {
const business = await this.requireOwnedBusiness(actor);
return this.logos.remove(business.id, variant, actor);
}
async removeImage(
id: string,
imageId: string,
@@ -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<PlatformSettingsDto> {
@@ -48,4 +46,38 @@ export class PlatformSettingsController {
removeLoginHeroImage(): Promise<PlatformSettingsDto> {
return this.service.removeLoginHeroImage();
}
@Post('logo-light')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(imageUpload)
uploadLogoLight(
@UploadedFile() file: Express.Multer.File,
): Promise<PlatformSettingsDto> {
return this.service.uploadLogo('light', file);
}
@Delete('logo-light')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
removeLogoLight(): Promise<PlatformSettingsDto> {
return this.service.removeLogo('light');
}
@Post('logo-dark')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(imageUpload)
uploadLogoDark(
@UploadedFile() file: Express.Multer.File,
): Promise<PlatformSettingsDto> {
return this.service.uploadLogo('dark', file);
}
@Delete('logo-dark')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
removeLogoDark(): Promise<PlatformSettingsDto> {
return this.service.removeLogo('dark');
}
}
@@ -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,
};
}
@@ -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<PlatformSettingsDto> {
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<PlatformSettingsDto> {
@@ -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<PlatformSettingsDto> {
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<PlatformSettingsDto> {
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<PlatformSettingsEntity> {
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)) {