Files
serverMontime/src/modules/businesses/business-images.service.ts
T
2026-06-13 12:39:35 +03:30

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');
}
}
}