635 lines
16 KiB
TypeScript
635 lines
16 KiB
TypeScript
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<KnowledgeBaseArticleWithDetails[]> {
|
|
// 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<KnowledgeBaseArticleWithDetails[]> {
|
|
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<KnowledgeBaseArticleSimple[]> {
|
|
// 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<KnowledgeBaseArticleSimple[]> {
|
|
// 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<KnowledgeBaseArticleWithDetails[]> {
|
|
// 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<KnowledgeBaseArticleWithDetails[]> {
|
|
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<KnowledgeBaseArticleWithDetails[]> {
|
|
// 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<KnowledgeBaseArticleWithDetails | null> {
|
|
// 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<boolean> {
|
|
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<KnowledgeBaseArticleWithDetails | null> {
|
|
// 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<KnowledgeBaseArticleWithDetails | null> {
|
|
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();
|
|
}
|