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