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:
178
backend/src/contents/contents.controller.ts
Normal file
178
backend/src/contents/contents.controller.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
DefaultValuePipe,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Header,
|
||||||
|
Param,
|
||||||
|
ParseBoolPipe,
|
||||||
|
ParseIntPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UploadedFile,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { CacheInterceptor, CacheTTL } from "@nestjs/cache-manager";
|
||||||
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||||
|
import { ContentsService } from "./contents.service";
|
||||||
|
import { CreateContentDto } from "./dto/create-content.dto";
|
||||||
|
import { UploadContentDto } from "./dto/upload-content.dto";
|
||||||
|
|
||||||
|
@Controller("contents")
|
||||||
|
export class ContentsController {
|
||||||
|
constructor(private readonly contentsService: ContentsService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
create(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Body() createContentDto: CreateContentDto,
|
||||||
|
) {
|
||||||
|
return this.contentsService.create(req.user.sub, createContentDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("upload-url")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
getUploadUrl(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Query("fileName") fileName: string,
|
||||||
|
) {
|
||||||
|
return this.contentsService.getUploadUrl(req.user.sub, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("upload")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
@UseInterceptors(FileInterceptor("file"))
|
||||||
|
upload(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@UploadedFile()
|
||||||
|
file: Express.Multer.File,
|
||||||
|
@Body() uploadContentDto: UploadContentDto,
|
||||||
|
) {
|
||||||
|
return this.contentsService.uploadAndProcess(
|
||||||
|
req.user.sub,
|
||||||
|
file,
|
||||||
|
uploadContentDto,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("explore")
|
||||||
|
@UseInterceptors(CacheInterceptor)
|
||||||
|
@CacheTTL(60)
|
||||||
|
@Header("Cache-Control", "public, max-age=60")
|
||||||
|
explore(
|
||||||
|
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
||||||
|
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||||
|
@Query("sort") sort?: "trend" | "recent",
|
||||||
|
@Query("tag") tag?: string,
|
||||||
|
@Query("category") category?: string,
|
||||||
|
@Query("author") author?: string,
|
||||||
|
@Query("query") query?: string,
|
||||||
|
@Query("favoritesOnly", new DefaultValuePipe(false), ParseBoolPipe)
|
||||||
|
favoritesOnly?: boolean,
|
||||||
|
@Query("userId") userId?: string,
|
||||||
|
) {
|
||||||
|
return this.contentsService.findAll({
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
sortBy: sort,
|
||||||
|
tag,
|
||||||
|
category,
|
||||||
|
author,
|
||||||
|
query,
|
||||||
|
favoritesOnly,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("trends")
|
||||||
|
@UseInterceptors(CacheInterceptor)
|
||||||
|
@CacheTTL(300)
|
||||||
|
@Header("Cache-Control", "public, max-age=300")
|
||||||
|
trends(
|
||||||
|
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
||||||
|
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||||
|
) {
|
||||||
|
return this.contentsService.findAll({ limit, offset, sortBy: "trend" });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("recent")
|
||||||
|
@UseInterceptors(CacheInterceptor)
|
||||||
|
@CacheTTL(60)
|
||||||
|
@Header("Cache-Control", "public, max-age=60")
|
||||||
|
recent(
|
||||||
|
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
||||||
|
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||||
|
) {
|
||||||
|
return this.contentsService.findAll({ limit, offset, sortBy: "recent" });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(":idOrSlug")
|
||||||
|
@UseInterceptors(CacheInterceptor)
|
||||||
|
@CacheTTL(3600)
|
||||||
|
@Header("Cache-Control", "public, max-age=3600")
|
||||||
|
async findOne(
|
||||||
|
@Param("idOrSlug") idOrSlug: string,
|
||||||
|
@Req() req: Request,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const content = await this.contentsService.findOne(idOrSlug);
|
||||||
|
if (!content) {
|
||||||
|
throw new NotFoundException("Contenu non trouvé");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = req.headers["user-agent"] || "";
|
||||||
|
const isBot = /bot|googlebot|crawler|spider|robot|crawling|facebookexternalhit|twitterbot/i.test(
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isBot) {
|
||||||
|
const imageUrl = this.contentsService.getFileUrl(content.storageKey);
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>${content.title}</title>
|
||||||
|
<meta property="og:title" content="${content.title}" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:image" content="${imageUrl}" />
|
||||||
|
<meta property="og:description" content="Découvrez ce meme sur Memegoat" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="${content.title}" />
|
||||||
|
<meta name="twitter:image" content="${imageUrl}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>${content.title}</h1>
|
||||||
|
<img src="${imageUrl}" alt="${content.title}" />
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
return res.send(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/view")
|
||||||
|
incrementViews(@Param("id") id: string) {
|
||||||
|
return this.contentsService.incrementViews(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":id/use")
|
||||||
|
incrementUsage(@Param("id") id: string) {
|
||||||
|
return this.contentsService.incrementUsage(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":id")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
remove(@Param("id") id: string, @Req() req: AuthenticatedRequest) {
|
||||||
|
return this.contentsService.remove(id, req.user.sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend/src/contents/contents.module.ts
Normal file
15
backend/src/contents/contents.module.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
import { CryptoModule } from "../crypto/crypto.module";
|
||||||
|
import { DatabaseModule } from "../database/database.module";
|
||||||
|
import { MediaModule } from "../media/media.module";
|
||||||
|
import { S3Module } from "../s3/s3.module";
|
||||||
|
import { ContentsController } from "./contents.controller";
|
||||||
|
import { ContentsService } from "./contents.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule, S3Module, AuthModule, CryptoModule, MediaModule],
|
||||||
|
controllers: [ContentsController],
|
||||||
|
providers: [ContentsService],
|
||||||
|
})
|
||||||
|
export class ContentsModule {}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
backend/src/contents/dto/create-content.dto.ts
Normal file
43
backend/src/contents/dto/create-content.dto.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
export enum ContentType {
|
||||||
|
MEME = "meme",
|
||||||
|
GIF = "gif",
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateContentDto {
|
||||||
|
@IsEnum(ContentType)
|
||||||
|
type!: "meme" | "gif";
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
storageKey!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
mimeType!: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
fileSize!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
19
backend/src/contents/dto/upload-content.dto.ts
Normal file
19
backend/src/contents/dto/upload-content.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator";
|
||||||
|
import { ContentType } from "./create-content.dto";
|
||||||
|
|
||||||
|
export class UploadContentDto {
|
||||||
|
@IsEnum(ContentType)
|
||||||
|
type!: "meme" | "gif";
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ each: true })
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user