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

@@ -136,25 +136,7 @@ export class ContentsController {
);
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>`;
const html = this.contentsService.generateBotHtml(content);
return res.send(html);
}

View File

@@ -6,10 +6,11 @@ import { MediaModule } from "../media/media.module";
import { S3Module } from "../s3/s3.module";
import { ContentsController } from "./contents.controller";
import { ContentsService } from "./contents.service";
import { ContentsRepository } from "./repositories/contents.repository";
@Module({
imports: [DatabaseModule, S3Module, AuthModule, CryptoModule, MediaModule],
controllers: [ContentsController],
providers: [ContentsService],
providers: [ContentsService, ContentsRepository],
})
export class ContentsModule {}

View File

@@ -4,33 +4,28 @@ jest.mock("uuid", () => ({
import { BadRequestException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Test, TestingModule } from "@nestjs/testing";
import { DatabaseService } from "../database/database.service";
import { MediaService } from "../media/media.service";
import { S3Service } from "../s3/s3.service";
import { ContentsService } from "./contents.service";
import { ContentsRepository } from "./repositories/contents.repository";
describe("ContentsService", () => {
let service: ContentsService;
let s3Service: S3Service;
let mediaService: MediaService;
const mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue([]),
onConflictDoNothing: jest.fn().mockReturnThis(),
transaction: jest.fn().mockImplementation((cb) => cb(mockDb)),
execute: jest.fn().mockResolvedValue([]),
const mockContentsRepository = {
findAll: jest.fn(),
count: jest.fn(),
create: jest.fn(),
incrementViews: jest.fn(),
incrementUsage: jest.fn(),
softDelete: jest.fn(),
findOne: jest.fn(),
findBySlug: jest.fn(),
};
const mockS3Service = {
@@ -48,46 +43,24 @@ describe("ContentsService", () => {
get: jest.fn(),
};
const mockCacheManager = {
store: {
keys: jest.fn().mockResolvedValue([]),
},
del: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const chain = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
onConflictDoNothing: jest.fn().mockReturnThis(),
};
const mockImplementation = () => {
return Object.assign(Promise.resolve([]), chain);
};
for (const mock of Object.values(chain)) {
//TODO Fix : TS2774: This condition will always return true since this function is always defined. Did you mean to call it instead?
if (mock.mockReturnValue) {
mock.mockImplementation(mockImplementation);
}
}
Object.assign(mockDb, chain);
const module: TestingModule = await Test.createTestingModule({
providers: [
ContentsService,
{ provide: DatabaseService, useValue: { db: mockDb } },
{ provide: ContentsRepository, useValue: mockContentsRepository },
{ provide: S3Service, useValue: mockS3Service },
{ provide: MediaService, useValue: mockMediaService },
{ provide: ConfigService, useValue: mockConfigService },
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
],
}).compile();
@@ -127,7 +100,8 @@ describe("ContentsService", () => {
mimeType: "image/webp",
size: 500,
});
mockDb.returning.mockResolvedValue([{ id: "content-id" }]);
mockContentsRepository.findBySlug.mockResolvedValue(null);
mockContentsRepository.create.mockResolvedValue({ id: "content-id" });
const result = await service.uploadAndProcess("user1", file, {
title: "Meme",
@@ -155,8 +129,8 @@ describe("ContentsService", () => {
describe("findAll", () => {
it("should return contents and total count", async () => {
mockDb.where.mockResolvedValueOnce([{ count: 10 }]); // for count
mockDb.offset.mockResolvedValueOnce([{ id: "1" }]); // for data
mockContentsRepository.count.mockResolvedValue(10);
mockContentsRepository.findAll.mockResolvedValue([{ id: "1" }]);
const result = await service.findAll({ limit: 10, offset: 0 });
@@ -167,9 +141,9 @@ describe("ContentsService", () => {
describe("incrementViews", () => {
it("should increment views", async () => {
mockDb.returning.mockResolvedValue([{ id: "1", views: 1 }]);
mockContentsRepository.incrementViews.mockResolvedValue([{ id: "1", views: 1 }]);
const result = await service.incrementViews("1");
expect(mockDb.update).toHaveBeenCalled();
expect(mockContentsRepository.incrementViews).toHaveBeenCalledWith("1");
expect(result[0].views).toBe(1);
});
});

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++}`;

View File

@@ -0,0 +1,383 @@
import { Injectable } from "@nestjs/common";
import {
and,
desc,
eq,
exists,
ilike,
isNull,
sql,
lte,
type SQL,
} from "drizzle-orm";
import { DatabaseService } from "../../database/database.service";
import {
categories,
contents,
contentsToTags,
favorites,
tags,
users,
} from "../../database/schemas";
import type { NewContentInDb } from "../../database/schemas/content";
export interface FindAllOptions {
limit: number;
offset: number;
sortBy?: "trend" | "recent";
tag?: string;
category?: string;
author?: string;
query?: string;
favoritesOnly?: boolean;
userId?: string;
}
@Injectable()
export class ContentsRepository {
constructor(private readonly databaseService: DatabaseService) {}
async findAll(options: FindAllOptions) {
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 (category) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(categories)
.where(
and(
eq(contents.categoryId, categories.id),
sql`(${categories.id}::text = ${category} OR ${categories.slug} = ${category})`,
),
),
),
);
}
if (author) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(users)
.where(
and(
eq(contents.userId, users.uuid),
sql`(${users.uuid}::text = ${author} OR ${users.username} = ${author})`,
),
),
),
);
}
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),
),
),
),
);
}
let orderBy = desc(contents.createdAt);
if (sortBy === "trend") {
orderBy = desc(sql`${contents.views} + ${contents.usageCount} * 2`);
}
const results = await this.databaseService.db
.select({
id: contents.id,
title: contents.title,
slug: contents.slug,
type: contents.type,
storageKey: contents.storageKey,
mimeType: contents.mimeType,
fileSize: contents.fileSize,
views: contents.views,
usageCount: contents.usageCount,
createdAt: contents.createdAt,
updatedAt: contents.updatedAt,
author: {
id: users.uuid,
username: users.username,
avatarUrl: users.avatarUrl,
},
category: {
id: categories.id,
name: categories.name,
slug: categories.slug,
},
})
.from(contents)
.leftJoin(users, eq(contents.userId, users.uuid))
.leftJoin(categories, eq(contents.categoryId, categories.id))
.where(whereClause)
.orderBy(orderBy)
.limit(limit)
.offset(offset);
const contentIds = results.map((r) => r.id);
const tagsForContents = contentIds.length
? await this.databaseService.db
.select({
contentId: contentsToTags.contentId,
name: tags.name,
})
.from(contentsToTags)
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
.where(sql`${contentsToTags.contentId} IN ${contentIds}`)
: [];
return results.map((r) => ({
...r,
tags: tagsForContents
.filter((t) => t.contentId === r.id)
.map((t) => t.name),
}));
}
async create(data: NewContentInDb & { userId: string }, tagNames?: string[]) {
return await this.databaseService.db.transaction(async (tx) => {
const [newContent] = await tx
.insert(contents)
.values(data)
.returning();
if (tagNames && tagNames.length > 0) {
for (const tagName of tagNames) {
const slug = tagName
.toLowerCase()
.replace(/ /g, "-")
.replace(/[^\w-]/g, "");
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: data.userId,
})
.returning();
}
await tx
.insert(contentsToTags)
.values({
contentId: newContent.id,
tagId: tag.id,
})
.onConflictDoNothing();
}
}
return newContent;
});
}
async findOne(idOrSlug: string) {
const [result] = await this.databaseService.db
.select({
id: contents.id,
title: contents.title,
slug: contents.slug,
type: contents.type,
storageKey: contents.storageKey,
mimeType: contents.mimeType,
fileSize: contents.fileSize,
views: contents.views,
usageCount: contents.usageCount,
createdAt: contents.createdAt,
updatedAt: contents.updatedAt,
userId: contents.userId,
})
.from(contents)
.where(
and(
isNull(contents.deletedAt),
sql`(${contents.id}::text = ${idOrSlug} OR ${contents.slug} = ${idOrSlug})`,
),
)
.limit(1);
return result;
}
async count(options: {
tag?: string;
category?: string;
author?: string;
query?: string;
favoritesOnly?: boolean;
userId?: string;
}) {
const { 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 (category) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(categories)
.where(
and(
eq(contents.categoryId, categories.id),
sql`(${categories.id}::text = ${category} OR ${categories.slug} = ${category})`,
),
),
),
);
}
if (author) {
whereClause = and(
whereClause,
exists(
this.databaseService.db
.select()
.from(users)
.where(
and(
eq(contents.userId, users.uuid),
sql`(${users.uuid}::text = ${author} OR ${users.username} = ${author})`,
),
),
),
);
}
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),
),
),
),
);
}
const [result] = await this.databaseService.db
.select({ count: sql<number>`count(*)` })
.from(contents)
.where(whereClause);
return Number(result.count);
}
async incrementViews(id: string) {
await this.databaseService.db
.update(contents)
.set({ views: sql`${contents.views} + 1` })
.where(eq(contents.id, id));
}
async incrementUsage(id: string) {
await this.databaseService.db
.update(contents)
.set({ usageCount: sql`${contents.usageCount} + 1` })
.where(eq(contents.id, id));
}
async softDelete(id: string, userId: string) {
const [deleted] = await this.databaseService.db
.update(contents)
.set({ deletedAt: new Date() })
.where(and(eq(contents.id, id), eq(contents.userId, userId)))
.returning();
return deleted;
}
async findBySlug(slug: string) {
const [result] = await this.databaseService.db
.select()
.from(contents)
.where(eq(contents.slug, slug))
.limit(1);
return result;
}
async purgeSoftDeleted(before: Date) {
return await this.databaseService.db
.delete(contents)
.where(and(sql`${contents.deletedAt} IS NOT NULL`, lte(contents.deletedAt, before)))
.returning();
}
}