feat: add modular services and repositories for improved code organization

Introduce repository pattern across multiple services, including `favorites`, `tags`, `sessions`, `reports`, `auth`, and more. Decouple crypto functionalities into modular services like `HashingService`, `JwtService`, and `EncryptionService`. Improve testability and maintainability by simplifying dependencies and consolidating utility logic.
This commit is contained in:
Mathis HERRIOT
2026-01-14 12:11:39 +01:00
parent 9c45bf11e4
commit 514bd354bf
64 changed files with 1801 additions and 1295 deletions

View File

@@ -3,39 +3,21 @@ import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Cache } from "cache-manager";
import { Inject } 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 type { MediaProcessingResult } from "../media/interfaces/media.interface";
import type { IMediaService } from "../common/interfaces/media.interface";
import type { IStorageService } from "../common/interfaces/storage.interface";
import { MediaService } from "../media/media.service";
import { S3Service } from "../s3/s3.service";
import { CreateContentDto } from "./dto/create-content.dto";
import { ContentsRepository } from "./repositories/contents.repository";
@Injectable()
export class ContentsService {
private readonly logger = new Logger(ContentsService.name);
constructor(
private readonly databaseService: DatabaseService,
private readonly s3Service: S3Service,
private readonly mediaService: MediaService,
private readonly contentsRepository: ContentsRepository,
@Inject(S3Service) private readonly s3Service: IStorageService,
@Inject(MediaService) private readonly mediaService: IMediaService,
private readonly configService: ConfigService,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {}
@@ -104,7 +86,7 @@ export class ContentsService {
}
// 2. Transcodage
let processed: MediaProcessingResult;
let processed;
if (file.mimetype.startsWith("image/")) {
// Image ou GIF -> WebP (format moderne, bien supporté)
processed = await this.mediaService.processImage(file.buffer, "webp");
@@ -139,195 +121,71 @@ export class ContentsService {
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);
const [data, totalCount] = await Promise.all([
this.contentsRepository.findAll(options),
this.contentsRepository.count(options),
]);
return { data, totalCount };
}
async create(userId: string, data: CreateContentDto) {
async create(userId: string, data: any) {
this.logger.log(`Creating content for user ${userId}: ${data.title}`);
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();
const newContent = await this.contentsRepository.create(
{ ...contentData, userId, slug },
tagNames,
);
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();
}
}
await this.clearContentsCache();
return newContent;
});
await this.clearContentsCache();
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();
return await this.contentsRepository.incrementViews(id);
}
async incrementUsage(id: string) {
return await this.databaseService.db
.update(contents)
.set({ usageCount: sql`${contents.usageCount} + 1` })
.where(eq(contents.id, id))
.returning();
return await this.contentsRepository.incrementUsage(id);
}
async remove(id: string, userId: string) {
this.logger.log(`Removing content ${id} for user ${userId}`);
const result = await this.databaseService.db
.update(contents)
.set({ deletedAt: new Date() })
.where(and(eq(contents.id, id), eq(contents.userId, userId)))
.returning();
const deleted = await this.contentsRepository.softDelete(id, userId);
if (result.length > 0) {
if (deleted) {
await this.clearContentsCache();
}
return result;
return deleted;
}
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;
return this.contentsRepository.findOne(idOrSlug);
}
generateBotHtml(content: any): string {
const imageUrl = this.getFileUrl(content.storageKey);
return `<!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>`;
}
getFileUrl(storageKey: string): string {
@@ -359,11 +217,7 @@ export class ContentsService {
let counter = 1;
while (true) {
const [existing] = await this.databaseService.db
.select()
.from(contents)
.where(eq(contents.slug, slug))
.limit(1);
const existing = await this.contentsRepository.findBySlug(slug);
if (!existing) break;
slug = `${baseSlug}-${counter++}`;