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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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++}`;
|
||||
|
||||
383
backend/src/contents/repositories/contents.repository.ts
Normal file
383
backend/src/contents/repositories/contents.repository.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user