Files
backend/src/media/media.service.ts
T
Ali Reza bb59d5e9ba Initial commit: Meshkee CMS API
NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM.
2026-07-21 17:52:36 +03:30

375 lines
9.7 KiB
TypeScript

import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MediaType } from '@prisma/client';
import { randomUUID } from 'crypto';
import * as path from 'path';
import sharp from 'sharp';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { StorageService } from '../storage/storage.service';
import { ListMediaDto } from './dto/list-media.dto';
import { UpdateMediaDto } from './dto/update-media.dto';
const IMAGE_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
]);
const VIDEO_MIME_TYPES = new Set(['video/mp4', 'video/webm']);
@Injectable()
export class MediaService {
private readonly maxFileSizeBytes: number;
constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService,
private readonly permissions: PermissionsService,
private readonly config: ConfigService,
) {
const maxMb = Number(this.config.get<string>('MEDIA_MAX_FILE_SIZE_MB', '10'));
this.maxFileSizeBytes = maxMb * 1024 * 1024;
}
async list(businessIdRaw: string, query: ListMediaDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertCanRead(businessId, actor.id);
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 24;
const skip = (page - 1) * pageSize;
const where = {
businessId,
...(query.mediaType ? { mediaType: query.mediaType } : {}),
};
const [items, total] = await Promise.all([
this.prisma.media.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.media.count({ where }),
]);
return {
items: items.map((item) => this.serialize(item)),
total,
page,
pageSize,
};
}
async uploadMany(
businessIdRaw: string,
files: Express.Multer.File[],
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertCanCreate(businessId, actor.id);
if (!files.length) {
throw new BadRequestException('At least one file is required');
}
const business = await this.prisma.business.findUnique({
where: { id: businessId },
});
if (!business?.isActive) {
throw new NotFoundException('Business not found');
}
const items = [];
for (const file of files) {
items.push(await this.uploadOne(businessId, business.slug, file, actor.id));
}
return { items };
}
async update(
businessIdRaw: string,
mediaIdRaw: string,
dto: UpdateMediaDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const mediaId = BigInt(mediaIdRaw);
await this.assertCanUpdate(businessId, actor.id);
const media = await this.prisma.media.findFirst({
where: { id: mediaId, businessId },
});
if (!media) {
throw new NotFoundException('Media not found');
}
const updated = await this.prisma.media.update({
where: { id: mediaId },
data: {
altText: dto.altText?.trim() ?? undefined,
caption: dto.caption?.trim() ?? undefined,
},
});
return this.serialize(updated);
}
async remove(businessIdRaw: string, mediaIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const mediaId = BigInt(mediaIdRaw);
await this.assertCanDelete(businessId, actor.id);
const media = await this.prisma.media.findFirst({
where: { id: mediaId, businessId },
});
if (!media) {
throw new NotFoundException('Media not found');
}
await this.prisma.media.delete({ where: { id: mediaId } });
try {
await this.storage.delete(media.storagePath, media.storageDisk);
} catch {
// DB row removed; orphaned object can be cleaned later
}
return { message: 'Media deleted' };
}
private async uploadOne(
businessId: bigint,
businessSlug: string,
file: Express.Multer.File,
uploadedBy: bigint,
) {
if (!file.buffer?.length) {
throw new BadRequestException('Uploaded file is empty');
}
if (file.size > this.maxFileSizeBytes) {
throw new BadRequestException(
`File ${file.originalname} exceeds the maximum allowed size`,
);
}
if (VIDEO_MIME_TYPES.has(file.mimetype)) {
throw new BadRequestException('Video upload is not enabled for product images yet');
}
let width: number | null = null;
let height: number | null = null;
let contentType = file.mimetype;
try {
const metadata = await sharp(file.buffer).metadata();
width = metadata.width ?? null;
height = metadata.height ?? null;
if (!width || !height) {
throw new BadRequestException(
`Could not read image dimensions for ${file.originalname}`,
);
}
contentType =
this.contentTypeFromFormat(metadata.format) ??
(IMAGE_MIME_TYPES.has(file.mimetype) ? file.mimetype : 'image/jpeg');
} catch (error) {
if (error instanceof BadRequestException) {
throw error;
}
throw new BadRequestException(
`Unsupported file type: ${file.mimetype || 'unknown'}. Use JPEG, PNG, WebP, or GIF.`,
);
}
const ext = this.extensionFromContentType(contentType);
const fileName = `${randomUUID()}${ext}`;
const storageKey = `businesses/${businessSlug}/${businessId}/media/${fileName}`;
const stored = await this.storage.upload({
key: storageKey,
body: file.buffer,
contentType,
});
const created = await this.prisma.media.create({
data: {
businessId,
uploadedBy,
mediaType: MediaType.image,
storageDisk: stored.storageDisk,
storagePath: stored.storagePath,
publicUrl: stored.publicUrl,
fileName,
originalFileName: file.originalname,
mimeType: contentType,
fileSizeBytes: BigInt(file.size),
width,
height,
},
});
return this.serialize(created);
}
private contentTypeFromFormat(format?: string) {
switch (format) {
case 'jpeg':
case 'jpg':
return 'image/jpeg';
case 'png':
return 'image/png';
case 'webp':
return 'image/webp';
case 'gif':
return 'image/gif';
default:
return undefined;
}
}
private extensionFromContentType(contentType: string) {
switch (contentType) {
case 'image/jpeg':
return '.jpg';
case 'image/png':
return '.png';
case 'image/webp':
return '.webp';
case 'image/gif':
return '.gif';
default:
return '.jpg';
}
}
private resolveExtension(originalName: string, mimeType: string) {
const ext = path.extname(originalName).toLowerCase();
if (ext) {
return ext;
}
switch (mimeType) {
case 'image/jpeg':
return '.jpg';
case 'image/png':
return '.png';
case 'image/webp':
return '.webp';
case 'image/gif':
return '.gif';
case 'video/mp4':
return '.mp4';
case 'video/webm':
return '.webm';
default:
return '';
}
}
private serialize(media: {
id: bigint;
businessId: bigint;
uploadedBy: bigint | null;
mediaType: MediaType;
storageDisk: string;
storagePath: string;
publicUrl: string;
fileName: string;
originalFileName: string;
mimeType: string;
fileSizeBytes: bigint;
width: number | null;
height: number | null;
durationSeconds: { toNumber?: () => number } | null;
altText: string | null;
caption: string | null;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: media.id.toString(),
businessId: media.businessId.toString(),
uploadedBy: media.uploadedBy?.toString() ?? null,
mediaType: media.mediaType,
storageDisk: media.storageDisk,
storagePath: media.storagePath,
publicUrl: media.publicUrl,
fileName: media.fileName,
originalFileName: media.originalFileName,
mimeType: media.mimeType,
fileSizeBytes: media.fileSizeBytes.toString(),
width: media.width,
height: media.height,
durationSeconds: media.durationSeconds
? Number(media.durationSeconds)
: null,
altText: media.altText,
caption: media.caption,
createdAt: media.createdAt,
updatedAt: media.updatedAt,
};
}
private async assertCanRead(businessId: bigint, userId: bigint) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
'media.read',
);
if (!allowed) {
throw new ForbiddenException('You cannot view media for this business');
}
}
private async assertCanCreate(businessId: bigint, userId: bigint) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
'media.create',
);
if (!allowed) {
throw new ForbiddenException('You cannot upload media for this business');
}
}
private async assertCanUpdate(businessId: bigint, userId: bigint) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
'media.update',
);
if (!allowed) {
throw new ForbiddenException('You cannot update media for this business');
}
}
private async assertCanDelete(businessId: bigint, userId: bigint) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
'media.delete',
);
if (!allowed) {
throw new ForbiddenException('You cannot delete media for this business');
}
}
}