import { KnowledgeBaseArticle, KnowledgeBaseRelatedArticle, Prisma } from "@toolctrl/db/prisma-browser"; import { PaginationDto } from "~/application/dtos/data/PaginationDto"; import { cachified, clearCacheKey } from "~/utils/cache.server"; import { db, dbRead } from "~/utils/db.server"; export type KnowledgeBaseArticleWithDetails = KnowledgeBaseArticle & { knowledgeBase: { id: string; slug: string; title: string }; category: { slug: string; title: string } | null; section: { order: number; title: string } | null; relatedArticles: (KnowledgeBaseRelatedArticle & { relatedArticle: { id: string; order: number; title: string; slug: string }; })[]; createdByUser: { email: string; firstName: string; lastName: string; avatar: string | null; } | null; _count: { views: number; upvotes: number; downvotes: number; }; }; export type KnowledgeBaseArticleSimple = { id: string; knowledgeBaseId: string; title: string; language: string; category: { id: string; slug: string; title: string } | null; section: { id: string; order: number; title: string } | null; }; const include = { knowledgeBase: { select: { slug: true, title: true, id: true }, }, category: { select: { slug: true, title: true }, }, section: { select: { order: true, title: true }, }, relatedArticles: { include: { relatedArticle: { select: { id: true, order: true, title: true, slug: true }, }, }, }, createdByUser: { select: { email: true, firstName: true, lastName: true, avatar: true }, }, _count: { select: { views: true, upvotes: true, downvotes: true }, }, }; export async function getAllKnowledgeBaseArticles({ knowledgeBaseSlug, categoryId, language, }: { knowledgeBaseSlug: string; categoryId?: string; language: string | undefined; }): Promise { // Public KB listing — eventually-consistent read is fine. return await dbRead.knowledgeBaseArticle.findMany({ where: { knowledgeBase: { slug: knowledgeBaseSlug }, categoryId, language, }, include, orderBy: [ { category: { order: "asc" }, }, { section: { order: "asc" }, }, { order: "asc", }, ], }); } export async function getKnowledgeBaseArticlesForTemplateExport({ knowledgeBaseIds, }: { knowledgeBaseIds: string[]; }): Promise { if (knowledgeBaseIds.length === 0) { return []; } return await dbRead.knowledgeBaseArticle.findMany({ where: { knowledgeBaseId: { in: knowledgeBaseIds }, }, include, orderBy: [ { knowledgeBaseId: "asc", }, { category: { order: "asc" }, }, { section: { order: "asc" }, }, { order: "asc", }, ], }); } export async function getAllKnowledgeBaseArticlesSimple(): Promise { // Admin sidebar enumeration — read-only, replica-safe. return await dbRead.knowledgeBaseArticle.findMany({ select: { id: true, knowledgeBaseId: true, title: true, language: true, category: { select: { id: true, slug: true, title: true } }, section: { select: { id: true, order: true, title: true } }, }, orderBy: [ { knowledgeBaseId: "asc", }, { category: { order: "asc" }, }, { section: { order: "asc" }, }, { order: "asc", }, ], }); } export async function getKnowledgeBaseArticlesSimple({ knowledgeBaseId, language, }: { knowledgeBaseId: string; language: string; }): Promise { // Per-KB simple listing — read-only. return await dbRead.knowledgeBaseArticle.findMany({ where: { knowledgeBaseId, language }, select: { id: true, knowledgeBaseId: true, title: true, language: true, category: { select: { id: true, slug: true, title: true } }, section: { select: { id: true, order: true, title: true } }, }, orderBy: [{ category: { order: "asc" } }, { section: { order: "asc" } }, { order: "asc" }], }); } export async function getAllKnowledgeBaseArticlesWithPagination({ knowledgeBaseSlug, language, pagination, filters, }: { knowledgeBaseSlug: string | undefined; language: string | undefined; pagination: { page: number; pageSize: number; }; filters: { title?: string; description?: string; categoryId?: string | null; content?: string; }; }): Promise<{ items: KnowledgeBaseArticleWithDetails[]; pagination: PaginationDto; }> { const where: Prisma.KnowledgeBaseArticleWhereInput = { knowledgeBase: { slug: knowledgeBaseSlug }, language, }; if (filters.title !== undefined) { where.title = { contains: filters.title, mode: "insensitive" }; } if (filters.description !== undefined) { where.description = { contains: filters.description, mode: "insensitive" }; } if (filters.categoryId !== undefined) { where.categoryId = filters.categoryId; } if (filters.content !== undefined) { where.contentPublishedAsText = { contains: filters.content, mode: "insensitive" }; } // Paginated listing for admin grid — read-only, replica-safe. const items = await dbRead.knowledgeBaseArticle.findMany({ take: pagination.pageSize, skip: (pagination.page - 1) * pagination.pageSize, where, include, orderBy: [{ updatedAt: "desc" }, { createdAt: "desc" }], }); const totalItems = await dbRead.knowledgeBaseArticle.count({ where, }); return { items, pagination: { page: pagination.page, pageSize: pagination.pageSize, totalItems, totalPages: Math.ceil(totalItems / pagination.pageSize), }, }; } export async function findKnowledgeBaseArticles({ knowledgeBaseSlug, language, query, }: { knowledgeBaseSlug: string; language: string | undefined; query: string; }): Promise { // Public KB search — read-only, replica-safe. return await dbRead.knowledgeBaseArticle.findMany({ where: { knowledgeBase: { slug: knowledgeBaseSlug }, language, publishedAt: { not: null }, OR: [ { title: { contains: query, mode: "insensitive" } }, { description: { contains: query, mode: "insensitive" } }, { contentPublishedAsText: { contains: query, mode: "insensitive" }, }, ], }, include, orderBy: [ { category: { order: "asc" }, }, { section: { order: "asc" }, }, { order: "asc", }, ], }); } export async function findKnowledgeBaseArticlesByIds({ knowledgeBaseSlug, language, ids, }: { knowledgeBaseSlug: string; language: string | undefined; ids: string[]; }): Promise { if (ids.length === 0) return []; // Public KB search hydration — read-only, replica-safe. return await dbRead.knowledgeBaseArticle.findMany({ where: { id: { in: ids }, knowledgeBase: { slug: knowledgeBaseSlug }, language, publishedAt: { not: null }, }, include, }); } export async function getFeaturedKnowledgeBaseArticles({ knowledgeBaseId, language, }: { knowledgeBaseId: string; language: string; }): Promise { // Featured-article enumeration for public KB landing — read-only. return await dbRead.knowledgeBaseArticle.findMany({ where: { knowledgeBaseId, language, featuredOrder: { not: null }, publishedAt: { not: null }, }, include, orderBy: [{ featuredOrder: "asc" }], }); } export async function getKbArticleById(id: string): Promise { // Read-only lookup; admin edit views read this then save via a separate // request through the write client. return await dbRead.knowledgeBaseArticle.findUnique({ where: { id }, include, }); } /** * Returns true only when the article exists AND its parent knowledge base is * owned by the given tenant. Used to scope resource access (e.g. realtime-collab * token issuance) so a token can never be minted for a global or other-tenant * article id via a tenant context. */ export async function knowledgeBaseArticleBelongsToTenant(articleId: string, tenantId: string): Promise { const article = await dbRead.knowledgeBaseArticle.findUnique({ where: { id: articleId }, select: { knowledgeBase: { select: { tenantId: true } } }, }); return article?.knowledgeBase?.tenantId === tenantId; } export async function getKbArticleByIdInKnowledgeBase({ id, knowledgeBaseId, language, }: { id: string; knowledgeBaseId: string; language: string; }): Promise { // Read-only lookup, replica-safe. return await dbRead.knowledgeBaseArticle.findFirst({ where: { id, knowledgeBaseId, language }, include, }); } export async function getKbArticleBySlug({ knowledgeBaseId, slug, language, }: { knowledgeBaseId: string; slug: string; language: string; }): Promise { return await cachified({ key: `kb-article:${knowledgeBaseId}:${slug}:${language}`, ttl: 60 * 60 * 24, getFreshValue: () => // Public KB article fetch (cached for 24h) — replica-safe; lag is // well under the cache TTL. dbRead.knowledgeBaseArticle .findFirstOrThrow({ where: { knowledgeBaseId, slug, language, }, include: { ...include, }, }) .catch(() => { return null; }), }); } export async function createKnowledgeBaseArticle(data: { knowledgeBaseId: string; categoryId: string | null; sectionId: string | null; slug: string; title: string; description: string; order: number; contentDraft: string; contentPublished: string; contentPublishedAsText: string; contentType: string; language: string; featuredOrder: number | null; createdByUserId: string | null; seoImage: string; publishedAt: Date | null; }) { const article = await db.knowledgeBaseArticle.create({ data: { knowledgeBaseId: data.knowledgeBaseId, categoryId: data.categoryId, sectionId: data.sectionId, slug: data.slug, title: data.title, description: data.description, order: data.order, contentDraft: data.contentDraft, contentPublished: data.contentPublished, contentPublishedAsText: data.contentPublishedAsText, contentType: data.contentType, language: data.language, featuredOrder: data.featuredOrder, createdByUserId: data.createdByUserId, seoImage: data.seoImage, publishedAt: data.publishedAt, }, }); return article; } export async function updateKnowledgeBaseArticle( id: string, data: { categoryId?: string | null; sectionId?: string | null; slug?: string; title?: string; description?: string; order?: number; contentDraft?: string; contentPublished?: string; contentPublishedAsText?: string; contentType?: string; language?: string; featuredOrder?: number | null; createdByUserId?: string | null; seoImage?: string; publishedAt?: Date | null; relatedArticles?: string[]; } ) { const update: Prisma.KnowledgeBaseArticleUncheckedUpdateInput = { categoryId: data.categoryId, sectionId: data.sectionId, slug: data.slug, title: data.title, description: data.description, order: data.order, contentDraft: data.contentDraft, contentPublished: data.contentPublished, contentPublishedAsText: data.contentPublishedAsText, contentType: data.contentType, language: data.language, featuredOrder: data.featuredOrder, createdByUserId: data.createdByUserId, seoImage: data.seoImage, publishedAt: data.publishedAt, }; const updated = await db.knowledgeBaseArticle .update({ where: { id }, data: update, }) .then((item) => { clearCacheKey(`kb-article:${item.knowledgeBaseId}:${item.slug}:${item.language}`); return item; }); if (data.relatedArticles) { await db.knowledgeBaseRelatedArticle.deleteMany({ where: { articleId: id, }, }); await db.knowledgeBaseRelatedArticle.createMany({ data: data.relatedArticles.map((f) => { return { articleId: id, relatedArticleId: f, }; }), }); } } export async function updateKnowledgeBaseArticleInKnowledgeBase( { id, knowledgeBaseId, language, }: { id: string; knowledgeBaseId: string; language: string; }, data: { categoryId?: string | null; sectionId?: string | null; slug?: string; title?: string; description?: string; order?: number; contentDraft?: string; contentPublished?: string; contentPublishedAsText?: string; contentType?: string; language?: string; featuredOrder?: number | null; createdByUserId?: string | null; seoImage?: string; publishedAt?: Date | null; relatedArticles?: string[]; } ) { const update: Prisma.KnowledgeBaseArticleUncheckedUpdateInput = { categoryId: data.categoryId, sectionId: data.sectionId, slug: data.slug, title: data.title, description: data.description, order: data.order, contentDraft: data.contentDraft, contentPublished: data.contentPublished, contentPublishedAsText: data.contentPublishedAsText, contentType: data.contentType, language: data.language, featuredOrder: data.featuredOrder, createdByUserId: data.createdByUserId, seoImage: data.seoImage, publishedAt: data.publishedAt, }; const result = await db.knowledgeBaseArticle.updateMany({ where: { id, knowledgeBaseId, language }, data: update, }); if (result.count !== 1) { return result; } const item = await db.knowledgeBaseArticle.findUnique({ where: { id }, select: { knowledgeBaseId: true, slug: true, language: true } }); if (item) { clearCacheKey(`kb-article:${item.knowledgeBaseId}:${item.slug}:${item.language}`); } if (data.relatedArticles) { await db.knowledgeBaseRelatedArticle.deleteMany({ where: { articleId: id, article: { knowledgeBaseId, language }, }, }); await db.knowledgeBaseRelatedArticle.createMany({ data: data.relatedArticles.map((f) => { return { articleId: id, relatedArticleId: f, }; }), }); } return result; } export async function updateKnowledgeBaseArticleOrderInCategory({ id, categoryId, order }: { id: string; categoryId: string; order: number }) { return await db.knowledgeBaseArticle.updateMany({ where: { id, categoryId }, data: { order }, }); } export async function updateKnowledgeBaseArticleOrderInSection({ id, sectionId, order }: { id: string; sectionId: string; order: number }) { return await db.knowledgeBaseArticle.updateMany({ where: { id, sectionId }, data: { order }, }); } export async function updateKnowledgeBaseArticleOrderInKnowledgeBase({ id, knowledgeBaseId, language, order, }: { id: string; knowledgeBaseId: string; language: string; order: number; }) { return await db.knowledgeBaseArticle.updateMany({ where: { id, knowledgeBaseId, language }, data: { order }, }); } export async function deleteKnowledgeBaseArticle(id: string) { const result = await db.knowledgeBaseArticle .delete({ where: { id }, }) .then((item) => { clearCacheKey(`kb-article:${item.knowledgeBaseId}:${item.slug}:${item.language}`); return item; }); return result; } export async function deleteKnowledgeBaseArticleInKnowledgeBase({ id, knowledgeBaseId, language, }: { id: string; knowledgeBaseId: string; language: string; }) { const item = await db.knowledgeBaseArticle.findFirst({ where: { id, knowledgeBaseId, language }, select: { id: true, slug: true }, }); if (!item) { return { count: 0 }; } const result = await db.knowledgeBaseArticle.deleteMany({ where: { id, knowledgeBaseId, language }, }); if (result.count === 1) { clearCacheKey(`kb-article:${knowledgeBaseId}:${item.slug}:${language}`); } return result; } export async function countKnowledgeBaseArticles() { // Dashboard count — read-only, replica-safe. return await dbRead.knowledgeBaseArticle.count(); }