Files
memegoat/backend/src/contents/repositories/contents.repository.ts
Mathis HERRIOT 07cdb741b3 feat(contents): add updateAdmin endpoint and repository method
- Introduced `PATCH /:id/admin` endpoint to update admin-specific content.
- Added `update` method to `ContentsRepository` for data updates with timestamp.
2026-01-21 13:18:41 +01:00

437 lines
9.8 KiB
TypeScript

import { Injectable } from "@nestjs/common";
import {
and,
desc,
eq,
exists,
ilike,
isNull,
lte,
type SQL,
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,
favoritesCount:
sql<number>`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith(
Number,
),
isLiked: userId
? sql<boolean>`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})`
: sql<boolean>`false`,
createdAt: contents.createdAt,
updatedAt: contents.updatedAt,
author: {
id: users.uuid,
username: users.username,
displayName: users.displayName,
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, userId?: 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,
favoritesCount:
sql<number>`(SELECT count(*) FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id})`.mapWith(
Number,
),
isLiked: userId
? sql<boolean>`EXISTS(SELECT 1 FROM ${favorites} WHERE ${favorites.contentId} = ${contents.id} AND ${favorites.userId} = ${userId})`
: sql<boolean>`false`,
createdAt: contents.createdAt,
updatedAt: contents.updatedAt,
userId: contents.userId,
author: {
id: users.uuid,
username: users.username,
displayName: users.displayName,
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(
and(
isNull(contents.deletedAt),
sql`(${contents.id}::text = ${idOrSlug} OR ${contents.slug} = ${idOrSlug})`,
),
)
.limit(1);
if (!result) return null;
const tagsForContent = await this.databaseService.db
.select({
name: tags.name,
})
.from(contentsToTags)
.innerJoin(tags, eq(contentsToTags.tagId, tags.id))
.where(eq(contentsToTags.contentId, result.id));
return {
...result,
tags: tagsForContent.map((t) => t.name),
};
}
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 softDeleteAdmin(id: string) {
const [deleted] = await this.databaseService.db
.update(contents)
.set({ deletedAt: new Date() })
.where(eq(contents.id, id))
.returning();
return deleted;
}
async update(id: string, data: Partial<typeof contents.$inferInsert>) {
const [updated] = await this.databaseService.db
.update(contents)
.set({ ...data, updatedAt: new Date() })
.where(eq(contents.id, id))
.returning();
return updated;
}
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();
}
}