Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/knowledgeBase/service/KnowledgeBaseService.server.ts
T

649 lines
21 KiB
TypeScript

import type { KbArticleDto } from "../dtos/KbArticleDto";
import type { KbCategoryDto } from "../dtos/KbCategoryDto";
import type { KbSearchResultDto } from "../dtos/KbSearchResultDto";
import type { KnowledgeBaseDto } from "../dtos/KnowledgeBaseDto";
import { KnowledgeBaseWithDetails, getAllKnowledgeBases, getKnowledgeBaseById, getKnowledgeBaseBySlug } from "../db/knowledgeBase.db.server";
import { db } from "~/utils/db.server";
import { KbNavLinkDto } from "../dtos/KbNavLinkDto";
import {
KnowledgeBaseArticleWithDetails,
createKnowledgeBaseArticle,
getAllKnowledgeBaseArticles,
getFeaturedKnowledgeBaseArticles,
getKbArticleByIdInKnowledgeBase,
getKbArticleBySlug,
} from "../db/kbArticles.db.server";
import { searchKnowledgeBaseArticles } from "../services/kbArticleSearch.server";
import { createKnowledgeBaseCategory, getAllKnowledgeBaseCategories, getKbCategoryById, getKbCategoryBySlug } from "../db/kbCategories.db.server";
import KnowledgeBaseUtils from "../utils/KnowledgeBaseUtils";
import { KnowledgeBaseCategoryWithDetails } from "../helpers/KbCategoryModelHelper";
import { KnowledgeBaseRelatedArticle } from "@toolctrl/db/prisma-browser";
import { defaultLocale, isSupportedLocale } from "@toolctrl/i18n";
import { marked } from "marked";
import { htmlToText } from "html-to-text";
import { remixI18Next } from "~/locale/i18next.server";
import { cachified } from "~/utils/cache.server";
import { getBaseURL } from "~/utils/url.server";
export type KnowledgeBaseServiceErrorCode =
| "cannot-delete-with-articles"
| "cannot-delete-with-categories"
| "disabled"
| "invalid-category"
| "invalid-article"
| "invalid-section"
| "not-found";
const knowledgeBaseServiceErrorKeys: Record<KnowledgeBaseServiceErrorCode, string> = {
"cannot-delete-with-articles": "knowledgeBase.admin.errors.cannotDeleteWithArticles",
"cannot-delete-with-categories": "knowledgeBase.admin.errors.cannotDeleteWithCategories",
disabled: "knowledgeBase.public.errors.disabled",
"invalid-category": "knowledgeBase.adminCategories.errors.categoryNotFound",
"invalid-article": "knowledgeBase.adminCategories.errors.articleNotFound",
"invalid-section": "knowledgeBase.adminCategories.errors.sectionNotFound",
"not-found": "knowledgeBase.public.errors.notFound",
};
export class KnowledgeBaseServiceError extends Error {
constructor(readonly code: KnowledgeBaseServiceErrorCode) {
super(code);
this.name = "KnowledgeBaseServiceError";
}
}
export function getKnowledgeBaseActionErrorKey(error: unknown): string {
if (error instanceof KnowledgeBaseServiceError) {
return knowledgeBaseServiceErrorKeys[error.code];
}
return "knowledgeBase.admin.errors.actionFailed";
}
export function getKnowledgeBasePublicErrorKey(error: unknown): string {
if (error instanceof KnowledgeBaseServiceError && (error.code === "not-found" || error.code === "disabled")) {
return knowledgeBaseServiceErrorKeys[error.code];
}
return "knowledgeBase.public.errors.unavailable";
}
async function getAll({ enabled, request }: { enabled?: boolean; request: Request }): Promise<KnowledgeBaseDto[]> {
const items = await getAllKnowledgeBases({ enabled });
return items.map((item) => kbToDto(item, request));
}
async function get({ slug, enabled, request }: { slug: string; enabled?: boolean; request: Request }): Promise<KnowledgeBaseDto> {
const item = await cachified({
key: `kb:${slug}`,
ttl: 60 * 60 * 24,
getFreshValue: () => getKnowledgeBaseBySlug(slug),
});
if (!item) {
throw new KnowledgeBaseServiceError("not-found");
}
if (enabled !== undefined && item.enabled !== enabled) {
throw new KnowledgeBaseServiceError("disabled");
}
return kbToDto(item, request);
}
async function getById({ id, request }: { id: string; request: Request }): Promise<KnowledgeBaseDto | null> {
const item = await getKnowledgeBaseById(id);
if (!item) {
return null;
}
return kbToDto(item, request);
}
function kbToDto(kb: KnowledgeBaseWithDetails, request: Request) {
const baseUrl = getBaseURL(request, { https: true });
const item: KnowledgeBaseDto = {
id: kb.id,
createdAt: kb.createdAt,
updatedAt: kb.updatedAt,
basePath: kb.basePath,
slug: kb.slug,
title: kb.title,
description: kb.description,
defaultLanguage: kb.defaultLanguage,
layout: kb.layout as "list" | "articles" | "grid",
languages: JSON.parse(kb.languages) as string[],
links: JSON.parse(kb.links) as KbNavLinkDto[],
color: kb.color,
enabled: kb.enabled,
count: {
articles: kb._count.articles,
categories: kb._count.categories,
views: kb._count.views,
},
logo: kb.logo,
seoImage: kb.seoImage,
metatags: [
{ title: `${kb.title}` },
{ name: "description", content: kb.description },
// { name: "keywords", content: kb.keywords },
{ property: "og:title", content: `${kb.title}` },
{ property: "og:description", content: kb.description },
{ property: "og:image", content: kb.seoImage },
{ property: "og:type", content: "website" },
{ property: "og:url", content: `${baseUrl}${KnowledgeBaseUtils.getKbUrl({ kb, params: {} })}` },
{ property: "twitter:title", content: `${kb.title}` },
{ property: "twitter:description", content: kb.description },
{ property: "twitter:image", content: kb.seoImage },
{ property: "twitter:card", content: "summary_large_image" },
{
tagName: "link",
rel: "canonical",
href: `${baseUrl}${KnowledgeBaseUtils.getKbUrl({ kb, params: {} })}`,
},
],
};
return item;
}
async function getCategories({
kb,
params,
request,
}: {
kb: KnowledgeBaseDto;
params: {
lang?: string;
};
request: Request;
}): Promise<KbCategoryDto[]> {
const items = await getAllKnowledgeBaseCategories({
knowledgeBaseSlug: kb.slug,
language: params.lang || kb.defaultLanguage,
});
items.forEach((category) => {
category.articles = category.articles.filter((f) => f.publishedAt);
});
return items.map((f) => categoryToDto({ kb, category: f, params, request }));
// let allItems = await generateFakeData(kb);
// allItems.categories = allItems.categories.filter((f) => f.language === language);
// const result = await fakeSearch({ original: allItems, query });
// allItems.categories.forEach((category) => {
// category.articles = allItems.articles;
// });
// return result.categories;
}
function categoryToDto({
kb,
category,
params,
request,
}: {
kb: KnowledgeBaseDto;
category: KnowledgeBaseCategoryWithDetails;
params: { lang?: string };
request: Request;
}) {
const baseUrl = getBaseURL(request, { https: true });
const item: KbCategoryDto = {
id: category.id,
slug: category.slug,
order: category.order,
title: category.title,
description: category.description,
icon: category.icon,
language: category.language,
seoImage: category.seoImage,
href: KnowledgeBaseUtils.getCategoryUrl({ kb, category, params }),
sections: category.sections.map((f) => ({
id: f.id,
order: f.order,
title: f.title,
description: f.description,
})),
articles: category.articles.map((f) => ({
id: f.id,
order: f.order,
title: f.title,
description: f.description,
language: f.language,
slug: f.slug,
href: KnowledgeBaseUtils.getArticleUrl({ kb, article: f, params }),
sectionId: f.sectionId,
publishedAt: f.publishedAt,
categoryId: f.id,
})),
metatags: [
{ title: `${category.title} | ${kb.title}` },
{ name: "description", content: category.description },
// { name: "keywords", content: category.keywords },
{ property: "og:title", content: `${category.title} | ${kb.title}` },
{ property: "og:description", content: category.description },
{ property: "og:image", content: category.seoImage },
{ property: "og:type", content: "website" },
{ property: "og:url", content: `${baseUrl}${KnowledgeBaseUtils.getCategoryUrl({ kb, category, params })}` },
{ property: "twitter:title", content: `${category.title} | ${kb.title}` },
{ property: "twitter:description", content: category.description },
{ property: "twitter:image", content: category.seoImage },
{ property: "twitter:card", content: "summary_large_image" },
{
tagName: "link",
rel: "canonical",
href: `${baseUrl}${KnowledgeBaseUtils.getCategoryUrl({ kb, category, params })}`,
},
],
};
return item;
}
async function getArticles({
kb,
categoryId,
language,
params,
request,
}: {
kb: KnowledgeBaseDto;
categoryId: string;
language: string;
query: string | undefined;
params: { lang?: string };
request: Request;
}): Promise<KbArticleDto[]> {
const items = await getAllKnowledgeBaseArticles({
knowledgeBaseSlug: kb.slug,
categoryId,
language,
});
return items.map((f) => articleToDto({ kb, article: f, relatedArticles: f.relatedArticles, params, request }));
// let allItems = await generateFakeData(kb);
// allItems.articles = allItems.articles.filter((f) => f.language === language);
// const result = await fakeSearch({ original: allItems, query });
// return result.articles;
}
async function getArticle({ kb, slug, params, request }: { kb: KnowledgeBaseDto; slug: string; params: { lang?: string }; request: Request }): Promise<{
article: KbArticleDto;
category: KbCategoryDto;
} | null> {
let language = params.lang || kb.defaultLanguage;
const item = await getKbArticleBySlug({
knowledgeBaseId: kb.id,
slug,
language,
});
if (!item || !item.publishedAt) {
return null;
}
if (!item.categoryId) {
return null;
}
const category = await getKbCategoryById(item.categoryId);
if (!category) {
return null;
}
return {
article: articleToDto({ kb, article: item, relatedArticles: item.relatedArticles, params, request }),
category: categoryToDto({ kb, category, params, request }),
};
// const getIdFromSlug = (slug: string) => {
// return slug.split("-").pop();
// };
// const id = getIdFromSlug(slug);
// const allItems = await generateFakeData(kb);
// const article = allItems.articles.find((f) => f.id === id && f.language === language);
// if (!article) {
// return null;
// }
// const category = allItems.categories[0];
// return { article, category };
}
async function getArticleById({
kb,
id,
params,
request,
}: {
kb: KnowledgeBaseDto;
id: string;
params?: { lang?: string };
request: Request;
}): Promise<KbArticleDto | null> {
const item = await getKbArticleByIdInKnowledgeBase({ id, knowledgeBaseId: kb.id, language: params?.lang || kb.defaultLanguage });
if (!item) {
return null;
}
return articleToDto({ kb, article: item, relatedArticles: item.relatedArticles, params: {}, request });
}
async function getFeaturedArticles({ kb, params, request }: { kb: KnowledgeBaseDto; params: { lang?: string }; request: Request }) {
const items = await getFeaturedKnowledgeBaseArticles({
knowledgeBaseId: kb.id,
language: params.lang || kb.defaultLanguage,
});
return items.map((f) => articleToDto({ kb, article: f, relatedArticles: f.relatedArticles, params, request }));
// let allItems = await generateFakeData(kb);
// allItems.articles = allItems.articles.filter((f) => f.language === language && f.featuredOrder);
// // return first 6
// if (allItems.articles.length <= 4) {
// return allItems.articles;
// }
// return allItems.articles.slice(0, 4);
}
function articleToDto({
kb,
article,
relatedArticles,
params,
request,
}: {
kb: KnowledgeBaseDto;
article: KnowledgeBaseArticleWithDetails;
relatedArticles: (KnowledgeBaseRelatedArticle & {
relatedArticle: { id: string; order: number; title: string; slug: string };
})[];
params: { lang?: string };
request: Request;
}) {
const baseUrl = getBaseURL(request, { https: true });
const item: KbArticleDto = {
id: article.id,
createdAt: article.createdAt,
updatedAt: article.updatedAt,
slug: article.slug,
title: article.title,
description: article.description,
order: article.order,
contentDraft: article.contentDraft,
contentPublished: article.contentPublished,
contentPublishedAsText: article.contentPublishedAsText,
contentType: article.contentType as "wysiwyg" | "markdown",
language: article.language,
featuredOrder: article.featuredOrder,
createdByUser: article.createdByUser,
publishedAt: article.publishedAt,
href: KnowledgeBaseUtils.getArticleUrl({ kb, article, params }),
sectionId: article.sectionId,
relatedArticles: relatedArticles.map((f) => ({
order: f.relatedArticle.order,
title: f.relatedArticle.title,
slug: f.relatedArticle.slug,
href: KnowledgeBaseUtils.getArticleUrl({ kb, article: f.relatedArticle, params }),
})),
seoImage: article.seoImage,
metatags: [
{ title: `${article.title} | ${kb.title}` },
{ name: "description", content: article.description },
// { name: "keywords", content: article.keywords },
{ property: "og:title", content: `${article.title}` },
{ property: "og:description", content: article.description },
{ property: "og:image", content: article.seoImage },
{ property: "og:type", content: "article" },
{ property: "og:url", content: `${baseUrl}${KnowledgeBaseUtils.getArticleUrl({ kb, article, params })}` },
{ property: "twitter:title", content: `${article.title}` },
{ property: "twitter:description", content: article.description },
{ property: "twitter:image", content: article.seoImage },
{ property: "twitter:card", content: "summary_large_image" },
{
tagName: "link",
rel: "canonical",
href: `${baseUrl}${KnowledgeBaseUtils.getArticleUrl({ kb, article, params })}`,
},
],
};
return item;
}
async function getCategory({
kb,
category,
language,
params,
request,
}: {
kb: KnowledgeBaseDto;
category: string;
language: string;
params: { lang?: string };
request: Request;
}): Promise<KbCategoryDto | null> {
const item = await getKbCategoryBySlug({
knowledgeBaseId: kb.id,
slug: category,
language,
});
if (!item) {
return null;
}
item.articles = item.articles.filter((f) => f.publishedAt);
return categoryToDto({ kb, category: item, params, request });
}
async function del(item: KnowledgeBaseDto) {
const articlesCount = await db.knowledgeBaseArticle.count({
where: { knowledgeBaseId: item.id },
});
const categoriesCount = await db.knowledgeBaseCategory.count({
where: { knowledgeBaseId: item.id },
});
if (articlesCount > 0) {
throw new KnowledgeBaseServiceError("cannot-delete-with-articles");
}
if (categoriesCount > 0) {
throw new KnowledgeBaseServiceError("cannot-delete-with-categories");
}
return await db.knowledgeBase.delete({
where: { id: item.id },
});
}
async function duplicateCategory({ kb, language, categoryId }: { kb: KnowledgeBaseDto; language: string; categoryId: string }) {
const allCategories = await getAllKnowledgeBaseCategories({
knowledgeBaseSlug: kb.slug,
language,
});
const existing = allCategories.find((p) => p.id === categoryId);
if (!existing) {
throw new KnowledgeBaseServiceError("invalid-category");
}
let number = 2;
let slug = "";
const findExistingSlug = (slug: string) => {
return allCategories.find((p) => p.slug === slug);
};
do {
slug = `${existing.slug}${number}`;
const existingWithSlug = findExistingSlug(slug);
if (!existingWithSlug) {
break;
}
// if (number > 10) {
// throw Error("Too many duplicates");
// }
number++;
} while (true);
let maxOrder = 0;
if (allCategories.length > 0) {
maxOrder = Math.max(...allCategories.map((p) => p.order));
}
const item = await createKnowledgeBaseCategory({
knowledgeBaseId: kb.id,
slug,
order: maxOrder + 1,
title: existing.title + " " + number,
description: existing.description,
icon: existing.icon,
language: existing.language,
seoImage: existing.seoImage,
});
return item;
}
async function duplicateArticle({ kb, language, articleId }: { kb: KnowledgeBaseDto; language: string; articleId: string }) {
const allArticles = await getAllKnowledgeBaseArticles({
knowledgeBaseSlug: kb.slug,
language,
});
const existing = allArticles.find((p) => p.id === articleId);
if (!existing) {
throw new KnowledgeBaseServiceError("invalid-article");
}
const { slug, maxOrder, number } = KnowledgeBaseUtils.getAvailableArticleSlug({
allArticles,
initialSlug: existing.slug,
});
const item = await createKnowledgeBaseArticle({
knowledgeBaseId: kb.id,
categoryId: existing.categoryId,
sectionId: existing.sectionId,
slug,
title: existing.title + " " + number,
description: existing.description,
order: maxOrder + 1,
contentDraft: existing.contentDraft,
contentPublished: existing.contentPublished,
contentPublishedAsText: existing.contentPublishedAsText,
contentType: existing.contentType,
language: existing.language,
featuredOrder: existing.featuredOrder,
createdByUserId: existing.createdByUserId,
seoImage: existing.seoImage,
publishedAt: null,
});
return item;
}
async function search({
query,
kb,
params,
request,
}: {
query: string | undefined;
kb: KnowledgeBaseDto;
params: { lang?: string };
request: Request;
}): Promise<KbSearchResultDto | undefined> {
if (!query) {
return undefined;
}
// Hot-path: routes the read through @toolctrl/search's tenant-scoped
// wrapper when MEILI_URL is set; transparently falls back to the SQL
// LIKE-based implementation on connectivity errors or when no
// Meilisearch backend is configured. See docs/features/search.md.
const kbWithTenant = (await getKnowledgeBaseById(kb.id)) as { tenantId?: string | null } | null;
const articles = await searchKnowledgeBaseArticles({
knowledgeBaseSlug: kb.slug,
language: params.lang || kb.defaultLanguage,
query,
tenantId: kbWithTenant?.tenantId ?? undefined,
});
const result: KbSearchResultDto = {
articles: articles.map((article) => articleToDto({ kb, article, relatedArticles: [], params, request })),
query,
};
return result;
}
async function newArticle({
kb,
title,
categoryId,
sectionId,
initialSlug = "untitled",
params,
userId,
position,
}: {
kb: KnowledgeBaseDto;
title?: string;
categoryId?: string | null;
sectionId?: string | null;
initialSlug?: string;
params: { lang?: string };
userId: string;
position: "first" | "last";
}) {
const language = params.lang || kb.defaultLanguage;
const allArticles = await getAllKnowledgeBaseArticles({
knowledgeBaseSlug: kb.slug,
language,
});
if (categoryId || sectionId) {
const allCategories = await getAllKnowledgeBaseCategories({
knowledgeBaseSlug: kb.slug,
language,
});
const category = categoryId ? allCategories.find((item) => item.id === categoryId) : null;
if (categoryId && !category) {
throw new KnowledgeBaseServiceError("invalid-category");
}
if (sectionId && !category?.sections.some((section) => section.id === sectionId)) {
throw new KnowledgeBaseServiceError("invalid-section");
}
}
const { slug, maxOrder, firstOrder, number } = KnowledgeBaseUtils.getAvailableArticleSlug({
allArticles,
initialSlug,
});
const created = await createKnowledgeBaseArticle({
knowledgeBaseId: kb.id,
categoryId: categoryId ?? null,
sectionId: sectionId ?? null,
slug,
title: title ?? (await getUntitledArticleTitle({ language, number })),
description: "",
order: position === "first" ? firstOrder - 1 : maxOrder + 1,
contentDraft: "",
contentPublished: "",
contentPublishedAsText: "",
contentType: "wysiwyg",
language,
featuredOrder: null,
createdByUserId: userId,
seoImage: "",
publishedAt: null,
});
return created;
}
async function getUntitledArticleTitle({ language, number }: { language: string | undefined; number: number }) {
const locale = language && isSupportedLocale(language) ? language : defaultLocale;
const t = await remixI18Next.getFixedT(locale, "translations");
return t("knowledgeBase.adminCategories.untitledNumbered", { number }) as string;
}
function contentAsText(text: string) {
return htmlToText(marked.parse(text, { async: false }) as string, {
wordwrap: 130,
});
}
export default {
getAll,
get,
getById,
getCategories,
getArticles,
getArticle,
getArticleById,
getFeaturedArticles,
getCategory,
del,
duplicateCategory,
duplicateArticle,
search,
newArticle,
contentAsText,
};