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

@@ -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();
}
}