feat: implement ContentsModule with controllers, services, and DTOs
Added a new ContentsModule to handle content creation, upload, and management. Includes controller endpoints for CRUD operations, content exploration, and tagging. Integrated caching, file processing, S3 storage, and database logic.
This commit is contained in:
343
backend/src/contents/contents.service.ts
Normal file
343
backend/src/contents/contents.service.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
and,
|
||||
desc,
|
||||
eq,
|
||||
exists,
|
||||
ilike,
|
||||
isNull,
|
||||
type SQL,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { DatabaseService } from "../database/database.service";
|
||||
import {
|
||||
categories,
|
||||
contents,
|
||||
contentsToTags,
|
||||
favorites,
|
||||
tags,
|
||||
users,
|
||||
} from "../database/schemas";
|
||||
import { MediaService } from "../media/media.service";
|
||||
import type { MediaProcessingResult } from "../media/interfaces/media.interface";
|
||||
import { S3Service } from "../s3/s3.service";
|
||||
import { CreateContentDto } from "./dto/create-content.dto";
|
||||
|
||||
@Injectable()
|
||||
export class ContentsService {
|
||||
constructor(
|
||||
private readonly databaseService: DatabaseService,
|
||||
private readonly s3Service: S3Service,
|
||||
private readonly mediaService: MediaService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async getUploadUrl(userId: string, fileName: string) {
|
||||
const key = `uploads/${userId}/${Date.now()}-${fileName}`;
|
||||
const url = await this.s3Service.getUploadUrl(key);
|
||||
return { url, key };
|
||||
}
|
||||
|
||||
async uploadAndProcess(
|
||||
userId: string,
|
||||
file: Express.Multer.File,
|
||||
data: { title: string; type: "meme" | "gif"; categoryId?: string; tags?: string[] },
|
||||
) {
|
||||
// 0. Validation du format et de la taille
|
||||
const allowedMimeTypes = [
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
"video/webm",
|
||||
];
|
||||
|
||||
if (!allowedMimeTypes.includes(file.mimetype)) {
|
||||
throw new BadRequestException(
|
||||
"Format de fichier non supporté. Formats acceptés: png, jpeg, jpg, webp, webm, gif.",
|
||||
);
|
||||
}
|
||||
|
||||
const isGif = file.mimetype === "image/gif";
|
||||
const maxSizeKb = isGif
|
||||
? this.configService.get<number>("MAX_GIF_SIZE_KB", 1024)
|
||||
: this.configService.get<number>("MAX_IMAGE_SIZE_KB", 512);
|
||||
|
||||
if (file.size > maxSizeKb * 1024) {
|
||||
throw new BadRequestException(
|
||||
`Fichier trop volumineux. Limite pour ${isGif ? "GIF" : "image"}: ${maxSizeKb} Ko.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Scan Antivirus
|
||||
const scanResult = await this.mediaService.scanFile(
|
||||
file.buffer,
|
||||
file.originalname,
|
||||
);
|
||||
if (scanResult.isInfected) {
|
||||
throw new BadRequestException(
|
||||
`Le fichier est infecté par ${scanResult.virusName}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Transcodage
|
||||
let processed: MediaProcessingResult;
|
||||
if (file.mimetype.startsWith("image/")) {
|
||||
// Image ou GIF -> WebP (format moderne, bien supporté)
|
||||
processed = await this.mediaService.processImage(file.buffer, "webp");
|
||||
} else if (file.mimetype.startsWith("video/")) {
|
||||
// Vidéo -> WebM
|
||||
processed = await this.mediaService.processVideo(file.buffer, "webm");
|
||||
} else {
|
||||
throw new BadRequestException("Format de fichier non supporté");
|
||||
}
|
||||
|
||||
// 3. Upload vers S3
|
||||
const key = `contents/${userId}/${Date.now()}-${uuidv4()}.${processed.extension}`;
|
||||
await this.s3Service.uploadFile(key, processed.buffer, processed.mimeType);
|
||||
|
||||
// 4. Création en base de données
|
||||
return await this.create(userId, {
|
||||
...data,
|
||||
storageKey: key,
|
||||
mimeType: processed.mimeType,
|
||||
fileSize: processed.size,
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(options: {
|
||||
limit: number;
|
||||
offset: number;
|
||||
sortBy?: "trend" | "recent";
|
||||
tag?: string;
|
||||
category?: string; // Slug ou ID
|
||||
author?: string;
|
||||
query?: string;
|
||||
favoritesOnly?: boolean;
|
||||
userId?: string; // Nécessaire si favoritesOnly est vrai
|
||||
}) {
|
||||
const {
|
||||
limit,
|
||||
offset,
|
||||
sortBy,
|
||||
tag,
|
||||
category,
|
||||
author,
|
||||
query,
|
||||
favoritesOnly,
|
||||
userId,
|
||||
} = options;
|
||||
|
||||
let whereClause: SQL | undefined = isNull(contents.deletedAt);
|
||||
|
||||
if (tag) {
|
||||
whereClause = and(
|
||||
whereClause,
|
||||
exists(
|
||||
this.databaseService.db
|
||||
.select()
|
||||
.from(contentsToTags)
|
||||
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
|
||||
.where(
|
||||
and(eq(contentsToTags.contentId, contents.id), eq(tags.name, tag)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (author) {
|
||||
whereClause = and(
|
||||
whereClause,
|
||||
exists(
|
||||
this.databaseService.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.uuid, contents.userId), eq(users.username, author))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (category) {
|
||||
whereClause = and(
|
||||
whereClause,
|
||||
exists(
|
||||
this.databaseService.db
|
||||
.select()
|
||||
.from(categories)
|
||||
.where(
|
||||
and(
|
||||
eq(categories.id, contents.categoryId),
|
||||
sql`(${categories.slug} = ${category} OR ${categories.id}::text = ${category})`,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
whereClause = and(whereClause, ilike(contents.title, `%${query}%`));
|
||||
}
|
||||
|
||||
if (favoritesOnly && userId) {
|
||||
whereClause = and(
|
||||
whereClause,
|
||||
exists(
|
||||
this.databaseService.db
|
||||
.select()
|
||||
.from(favorites)
|
||||
.where(
|
||||
and(eq(favorites.contentId, contents.id), eq(favorites.userId, userId)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Pagination Total Count
|
||||
const totalCountResult = await this.databaseService.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(contents)
|
||||
.where(whereClause);
|
||||
|
||||
const totalCount = Number(totalCountResult[0].count);
|
||||
|
||||
// Sorting
|
||||
let orderBy: SQL = desc(contents.createdAt);
|
||||
if (sortBy === "trend") {
|
||||
orderBy = desc(sql`${contents.views} + ${contents.usageCount}`);
|
||||
}
|
||||
|
||||
const data = await this.databaseService.db
|
||||
.select()
|
||||
.from(contents)
|
||||
.where(whereClause)
|
||||
.orderBy(orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return { data, totalCount };
|
||||
}
|
||||
|
||||
async create(userId: string, data: CreateContentDto) {
|
||||
const { tags: tagNames, ...contentData } = data;
|
||||
|
||||
const slug = await this.ensureUniqueSlug(contentData.title);
|
||||
|
||||
return await this.databaseService.db.transaction(async (tx) => {
|
||||
const [newContent] = await tx
|
||||
.insert(contents)
|
||||
.values({ ...contentData, userId, slug })
|
||||
.returning();
|
||||
|
||||
if (tagNames && tagNames.length > 0) {
|
||||
for (const tagName of tagNames) {
|
||||
const slug = tagName
|
||||
.toLowerCase()
|
||||
.replace(/ /g, "-")
|
||||
.replace(/[^\w-]/g, "");
|
||||
|
||||
// Trouver ou créer le tag
|
||||
let [tag] = await tx
|
||||
.select()
|
||||
.from(tags)
|
||||
.where(eq(tags.slug, slug))
|
||||
.limit(1);
|
||||
|
||||
if (!tag) {
|
||||
[tag] = await tx
|
||||
.insert(tags)
|
||||
.values({ name: tagName, slug, userId })
|
||||
.returning();
|
||||
}
|
||||
|
||||
// Lier le tag au contenu
|
||||
await tx
|
||||
.insert(contentsToTags)
|
||||
.values({ contentId: newContent.id, tagId: tag.id })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
return newContent;
|
||||
});
|
||||
}
|
||||
|
||||
async incrementViews(id: string) {
|
||||
return await this.databaseService.db
|
||||
.update(contents)
|
||||
.set({ views: sql`${contents.views} + 1` })
|
||||
.where(eq(contents.id, id))
|
||||
.returning();
|
||||
}
|
||||
|
||||
async incrementUsage(id: string) {
|
||||
return await this.databaseService.db
|
||||
.update(contents)
|
||||
.set({ usageCount: sql`${contents.usageCount} + 1` })
|
||||
.where(eq(contents.id, id))
|
||||
.returning();
|
||||
}
|
||||
|
||||
async remove(id: string, userId: string) {
|
||||
return await this.databaseService.db
|
||||
.update(contents)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(and(eq(contents.id, id), eq(contents.userId, userId)))
|
||||
.returning();
|
||||
}
|
||||
|
||||
async findOne(idOrSlug: string) {
|
||||
const [content] = await this.databaseService.db
|
||||
.select()
|
||||
.from(contents)
|
||||
.where(
|
||||
and(
|
||||
isNull(contents.deletedAt),
|
||||
sql`(${contents.id}::text = ${idOrSlug} OR ${contents.slug} = ${idOrSlug})`,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return content;
|
||||
}
|
||||
|
||||
getFileUrl(storageKey: string): string {
|
||||
const endpoint = this.configService.get("S3_ENDPOINT");
|
||||
const port = this.configService.get("S3_PORT");
|
||||
const protocol = this.configService.get("S3_USE_SSL") === true ? "https" : "http";
|
||||
const bucket = this.configService.get("S3_BUCKET_NAME");
|
||||
|
||||
if (endpoint === "localhost" || endpoint === "127.0.0.1") {
|
||||
return `${protocol}://${endpoint}:${port}/${bucket}/${storageKey}`;
|
||||
}
|
||||
return `${protocol}://${endpoint}/${bucket}/${storageKey}`;
|
||||
}
|
||||
|
||||
private generateSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/[\s_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
private async ensureUniqueSlug(title: string): Promise<string> {
|
||||
const baseSlug = this.generateSlug(title) || "content";
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
while (true) {
|
||||
const [existing] = await this.databaseService.db
|
||||
.select()
|
||||
.from(contents)
|
||||
.where(eq(contents.slug, slug))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) break;
|
||||
slug = `${baseSlug}-${counter++}`;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user