logo set
This commit is contained in:
@@ -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"
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,12 @@ export class BusinessEntity {
|
|||||||
@Column({ type: 'varchar', length: 20, default: 'first_booking' })
|
@Column({ type: 'varchar', length: 20, default: 'first_booking' })
|
||||||
promoDiscountMode!: 'first_booking' | 'always';
|
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, {
|
@ManyToOne(() => BusinessCategoryEntity, (category) => category.businesses, {
|
||||||
onDelete: 'RESTRICT',
|
onDelete: 'RESTRICT',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export class PlatformSettingsEntity {
|
|||||||
@Column({ name: 'login_hero_image_url', type: 'varchar', length: 500, nullable: true })
|
@Column({ name: 'login_hero_image_url', type: 'varchar', length: 500, nullable: true })
|
||||||
loginHeroImageUrl!: string | null;
|
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({
|
@Column({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
type: 'timestamptz',
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,11 +8,12 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
|
UploadedFile,
|
||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
||||||
import { memoryStorage } from 'multer';
|
import { memoryStorage } from 'multer';
|
||||||
import { BusinessImageEntity } from '../../entities/business-image.entity';
|
import { BusinessImageEntity } from '../../entities/business-image.entity';
|
||||||
import { BusinessEntity } from '../../entities/business.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 { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
import { BusinessesService } from './businesses.service';
|
import { BusinessesService } from './businesses.service';
|
||||||
|
import { BusinessLogosService } from './business-logos.service';
|
||||||
import { AssignBusinessOwnerDto } from './dto/assign-business-owner.dto';
|
import { AssignBusinessOwnerDto } from './dto/assign-business-owner.dto';
|
||||||
import { CreateBusinessDto } from './dto/create-business.dto';
|
import { CreateBusinessDto } from './dto/create-business.dto';
|
||||||
import { SetAvailabilityDto } from './dto/set-availability.dto';
|
import { SetAvailabilityDto } from './dto/set-availability.dto';
|
||||||
@@ -30,7 +32,10 @@ import { UpdateBusinessDto } from './dto/update-business.dto';
|
|||||||
|
|
||||||
@Controller('businesses')
|
@Controller('businesses')
|
||||||
export class BusinessesController {
|
export class BusinessesController {
|
||||||
constructor(private readonly service: BusinessesService) {}
|
constructor(
|
||||||
|
private readonly service: BusinessesService,
|
||||||
|
private readonly logos: BusinessLogosService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
findAll(
|
findAll(
|
||||||
@@ -90,6 +95,52 @@ export class BusinessesController {
|
|||||||
return this.service.setMyCoverImage(imageId, user);
|
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')
|
@Get(':id/availability')
|
||||||
getAvailability(@Param('id') id: string) {
|
getAvailability(@Param('id') id: string) {
|
||||||
return this.service.getAvailability(id);
|
return this.service.getAvailability(id);
|
||||||
@@ -147,6 +198,60 @@ export class BusinessesController {
|
|||||||
return { success: true };
|
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')
|
@Post('backfill-owners')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN)
|
@Roles(UserRole.ADMIN)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { BookingReviewEntity } from '../../entities/booking-review.entity';
|
|||||||
import { UserEntity } from '../../entities/user.entity';
|
import { UserEntity } from '../../entities/user.entity';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { BusinessImagesService } from './business-images.service';
|
import { BusinessImagesService } from './business-images.service';
|
||||||
|
import { BusinessLogosService } from './business-logos.service';
|
||||||
import { BusinessesController } from './businesses.controller';
|
import { BusinessesController } from './businesses.controller';
|
||||||
import { BusinessesService } from './businesses.service';
|
import { BusinessesService } from './businesses.service';
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ import { BusinessesService } from './businesses.service';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
],
|
],
|
||||||
controllers: [BusinessesController],
|
controllers: [BusinessesController],
|
||||||
providers: [BusinessesService, BusinessImagesService],
|
providers: [BusinessesService, BusinessImagesService, BusinessLogosService],
|
||||||
exports: [BusinessImagesService],
|
exports: [BusinessImagesService],
|
||||||
})
|
})
|
||||||
export class BusinessesModule {}
|
export class BusinessesModule {}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { BusinessImageEntity } from '../../entities/business-image.entity';
|
|||||||
import { UserEntity, UserRole } from '../../entities/user.entity';
|
import { UserEntity, UserRole } from '../../entities/user.entity';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { BusinessImagesService } from './business-images.service';
|
import { BusinessImagesService } from './business-images.service';
|
||||||
|
import { BusinessLogosService } from './business-logos.service';
|
||||||
import { AssignBusinessOwnerDto } from './dto/assign-business-owner.dto';
|
import { AssignBusinessOwnerDto } from './dto/assign-business-owner.dto';
|
||||||
import { CreateBusinessDto } from './dto/create-business.dto';
|
import { CreateBusinessDto } from './dto/create-business.dto';
|
||||||
import { SetAvailabilityDto } from './dto/set-availability.dto';
|
import { SetAvailabilityDto } from './dto/set-availability.dto';
|
||||||
@@ -49,6 +50,7 @@ export class BusinessesService {
|
|||||||
private readonly reviewRepo: Repository<BookingReviewEntity>,
|
private readonly reviewRepo: Repository<BookingReviewEntity>,
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly images: BusinessImagesService,
|
private readonly images: BusinessImagesService,
|
||||||
|
private readonly logos: BusinessLogosService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filters: FindAllFilters): Promise<BusinessEntity[]> {
|
async findAll(filters: FindAllFilters): Promise<BusinessEntity[]> {
|
||||||
@@ -199,7 +201,7 @@ export class BusinessesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
await this.findOne(id);
|
const business = await this.findOne(id);
|
||||||
|
|
||||||
const imageUrls = await this.images.listUrlsForBusiness(id);
|
const imageUrls = await this.images.listUrlsForBusiness(id);
|
||||||
|
|
||||||
@@ -208,6 +210,7 @@ export class BusinessesService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.images.deleteFiles(imageUrls);
|
this.images.deleteFiles(imageUrls);
|
||||||
|
this.logos.deleteLogoFiles(business);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAvailability(businessId: string) {
|
async getAvailability(businessId: string) {
|
||||||
@@ -424,6 +427,23 @@ export class BusinessesService {
|
|||||||
return this.images.upload(business.id, files);
|
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(
|
async removeImage(
|
||||||
id: string,
|
id: string,
|
||||||
imageId: string,
|
imageId: string,
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ import { UserRole } from '../../entities/user.entity';
|
|||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
import {
|
import { PlatformSettingsDto } from './platform-settings.dto';
|
||||||
PlatformSettingsDto,
|
import { PlatformSettingsService } from './platform-settings.service';
|
||||||
PlatformSettingsService,
|
|
||||||
} from './platform-settings.service';
|
const imageUpload = FileInterceptor('file', {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 },
|
||||||
|
});
|
||||||
|
|
||||||
@Controller('platform-settings')
|
@Controller('platform-settings')
|
||||||
export class PlatformSettingsController {
|
export class PlatformSettingsController {
|
||||||
@@ -30,12 +33,7 @@ export class PlatformSettingsController {
|
|||||||
@Post('login-hero-image')
|
@Post('login-hero-image')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN)
|
@Roles(UserRole.ADMIN)
|
||||||
@UseInterceptors(
|
@UseInterceptors(imageUpload)
|
||||||
FileInterceptor('file', {
|
|
||||||
storage: memoryStorage(),
|
|
||||||
limits: { fileSize: 5 * 1024 * 1024 },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
uploadLoginHeroImage(
|
uploadLoginHeroImage(
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
): Promise<PlatformSettingsDto> {
|
): Promise<PlatformSettingsDto> {
|
||||||
@@ -48,4 +46,38 @@ export class PlatformSettingsController {
|
|||||||
removeLoginHeroImage(): Promise<PlatformSettingsDto> {
|
removeLoginHeroImage(): Promise<PlatformSettingsDto> {
|
||||||
return this.service.removeLoginHeroImage();
|
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 { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { existsSync, mkdirSync, unlinkSync } from 'fs';
|
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'fs';
|
||||||
import { extname, join } from 'path';
|
import { extname, join } from 'path';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { PlatformSettingsEntity } from '../../entities/platform-settings.entity';
|
import { PlatformSettingsEntity } from '../../entities/platform-settings.entity';
|
||||||
|
import {
|
||||||
|
LogoVariant,
|
||||||
|
PlatformSettingsDto,
|
||||||
|
toPlatformSettingsDto,
|
||||||
|
} from './platform-settings.dto';
|
||||||
|
|
||||||
const ALLOWED_MIME = new Set([
|
const ALLOWED_MIME = new Set([
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
@@ -15,10 +20,6 @@ const ALLOWED_MIME = new Set([
|
|||||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||||
const SETTINGS_ID = 1;
|
const SETTINGS_ID = 1;
|
||||||
|
|
||||||
export type PlatformSettingsDto = {
|
|
||||||
loginHeroImageUrl: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PlatformSettingsService {
|
export class PlatformSettingsService {
|
||||||
private readonly uploadRoot: string;
|
private readonly uploadRoot: string;
|
||||||
@@ -32,7 +33,7 @@ export class PlatformSettingsService {
|
|||||||
|
|
||||||
async getSettings(): Promise<PlatformSettingsDto> {
|
async getSettings(): Promise<PlatformSettingsDto> {
|
||||||
const row = await this.ensureRow();
|
const row = await this.ensureRow();
|
||||||
return this.toDto(row);
|
return toPlatformSettingsDto(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadLoginHeroImage(
|
async uploadLoginHeroImage(
|
||||||
@@ -48,19 +49,12 @@ export class PlatformSettingsService {
|
|||||||
this.deleteStoredFile(row.loginHeroImageUrl);
|
this.deleteStoredFile(row.loginHeroImageUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = extname(file.originalname).toLowerCase() || '.jpg';
|
const relativeUrl = this.saveFile(dir, 'login-hero', file);
|
||||||
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);
|
|
||||||
|
|
||||||
row.loginHeroImageUrl = relativeUrl;
|
row.loginHeroImageUrl = relativeUrl;
|
||||||
row.updatedAt = new Date();
|
row.updatedAt = new Date();
|
||||||
await this.repo.save(row);
|
await this.repo.save(row);
|
||||||
|
|
||||||
return this.toDto(row);
|
return toPlatformSettingsDto(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeLoginHeroImage(): Promise<PlatformSettingsDto> {
|
async removeLoginHeroImage(): Promise<PlatformSettingsDto> {
|
||||||
@@ -73,24 +67,60 @@ export class PlatformSettingsService {
|
|||||||
await this.repo.save(row);
|
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> {
|
private async ensureRow(): Promise<PlatformSettingsEntity> {
|
||||||
let row = await this.repo.findOne({ where: { id: SETTINGS_ID } });
|
let row = await this.repo.findOne({ where: { id: SETTINGS_ID } });
|
||||||
if (!row) {
|
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);
|
await this.repo.save(row);
|
||||||
}
|
}
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
private toDto(row: PlatformSettingsEntity): PlatformSettingsDto {
|
|
||||||
return {
|
|
||||||
loginHeroImageUrl: row.loginHeroImageUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private validateFile(file: Express.Multer.File | undefined): void {
|
private validateFile(file: Express.Multer.File | undefined): void {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
throw new BadRequestException('Image file is required');
|
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 {
|
private deleteStoredFile(relativeUrl: string): void {
|
||||||
const absolutePath = join(this.uploadRoot, relativeUrl);
|
const absolutePath = join(this.uploadRoot, relativeUrl);
|
||||||
if (existsSync(absolutePath)) {
|
if (existsSync(absolutePath)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user