177 lines
5.0 KiB
TypeScript
177 lines
5.0 KiB
TypeScript
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<BusinessImageEntity>,
|
|
@InjectRepository(BusinessEntity)
|
|
private readonly businessRepo: Repository<BusinessEntity>,
|
|
) {
|
|
this.uploadRoot = join(process.cwd(), process.env.UPLOAD_DIR ?? 'uploads');
|
|
}
|
|
|
|
async listForBusiness(businessId: string): Promise<BusinessImageEntity[]> {
|
|
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<BusinessImageEntity[]> {
|
|
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<BusinessImageEntity> {
|
|
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<void> {
|
|
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<string[]> {
|
|
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<void> {
|
|
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<void> {
|
|
const exists = await this.businessRepo.exists({ where: { id: businessId } });
|
|
if (!exists) {
|
|
throw new NotFoundException('Business not found');
|
|
}
|
|
}
|
|
}
|