mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-12 06:40:58 +04:30
NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM.
375 lines
9.7 KiB
TypeScript
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');
|
|
}
|
|
}
|
|
}
|