Initial commit: Meshkee CMS API
NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM.
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user