Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const getAllKnowledgeBaseArticlesMock = vi.fn();
const createKnowledgeBaseArticleMock = vi.fn();
const getAllKnowledgeBaseCategoriesMock = vi.fn();
const getFixedTMock = vi.fn();
const knowledgeBaseArticleCountMock = vi.fn();
const knowledgeBaseCategoryCountMock = vi.fn();
const knowledgeBaseDeleteMock = vi.fn();
const cachifiedMock = vi.fn();
vi.mock("../db/kbArticles.db.server", () => ({
createKnowledgeBaseArticle: (...args: unknown[]) => createKnowledgeBaseArticleMock(...args),
getAllKnowledgeBaseArticles: (...args: unknown[]) => getAllKnowledgeBaseArticlesMock(...args),
getFeaturedKnowledgeBaseArticles: vi.fn(),
getKbArticleById: vi.fn(),
getKbArticleByIdInKnowledgeBase: vi.fn(),
getKbArticleBySlug: vi.fn(),
}));
vi.mock("../db/kbCategories.db.server", () => ({
createKnowledgeBaseCategory: vi.fn(),
getAllKnowledgeBaseCategories: (...args: unknown[]) => getAllKnowledgeBaseCategoriesMock(...args),
getKbCategoryById: vi.fn(),
getKbCategoryBySlug: vi.fn(),
}));
vi.mock("../db/knowledgeBase.db.server", () => ({
getAllKnowledgeBases: vi.fn(),
getKnowledgeBaseById: vi.fn(),
getKnowledgeBaseBySlug: vi.fn(),
}));
vi.mock("../services/kbArticleSearch.server", () => ({
searchKnowledgeBaseArticles: vi.fn(),
}));
vi.mock("~/locale/i18next.server", () => ({
remixI18Next: {
getFixedT: (...args: unknown[]) => getFixedTMock(...args),
},
}));
vi.mock("~/utils/cache.server", () => ({
cachified: (...args: unknown[]) => cachifiedMock(...args),
}));
vi.mock("~/utils/db.server", () => ({
db: {
knowledgeBaseArticle: { count: (...args: unknown[]) => knowledgeBaseArticleCountMock(...args) },
knowledgeBaseCategory: { count: (...args: unknown[]) => knowledgeBaseCategoryCountMock(...args) },
knowledgeBase: { delete: (...args: unknown[]) => knowledgeBaseDeleteMock(...args) },
},
}));
describe("KnowledgeBaseServiceServer", () => {
beforeEach(() => {
vi.clearAllMocks();
getAllKnowledgeBaseArticlesMock.mockResolvedValue([
{
id: "article-1",
slug: "untitled",
order: 1,
},
]);
getAllKnowledgeBaseCategoriesMock.mockResolvedValue([]);
createKnowledgeBaseArticleMock.mockImplementation(async (input) => ({ id: "created", ...input }));
getFixedTMock.mockImplementation(async (locale: string) => (key: string, vars?: Record<string, unknown>) => `${locale}:${key}:${vars?.number ?? ""}`);
knowledgeBaseArticleCountMock.mockResolvedValue(0);
knowledgeBaseCategoryCountMock.mockResolvedValue(0);
knowledgeBaseDeleteMock.mockResolvedValue({ id: "kb-1" });
cachifiedMock.mockImplementation(async ({ getFreshValue }: { getFreshValue: () => unknown }) => getFreshValue());
});
test("newArticle localizes generated untitled titles and persists the resolved language", async () => {
const KnowledgeBaseServiceServer = (await import("./KnowledgeBaseService.server")).default;
const created = await KnowledgeBaseServiceServer.newArticle({
kb: {
id: "kb-1",
slug: "docs",
defaultLanguage: "de",
} as never,
params: {},
userId: "user-1",
position: "last",
});
expect(getFixedTMock).toHaveBeenCalledWith("de", "translations");
expect(createKnowledgeBaseArticleMock).toHaveBeenCalledWith(expect.objectContaining({ title: "de:knowledgeBase.adminCategories.untitledNumbered:1", language: "de" }));
expect(created).toMatchObject({ title: "de:knowledgeBase.adminCategories.untitledNumbered:1", language: "de" });
});
test("del exposes coded delete guard errors for localized route handling", async () => {
const { default: KnowledgeBaseServiceServer, getKnowledgeBaseActionErrorKey } = await import("./KnowledgeBaseService.server");
knowledgeBaseArticleCountMock.mockResolvedValueOnce(1);
try {
await KnowledgeBaseServiceServer.del({ id: "kb-1" } as never);
throw new Error("Expected delete to reject");
} catch (error) {
expect(error).toMatchObject({ code: "cannot-delete-with-articles" });
expect(getKnowledgeBaseActionErrorKey(error)).toBe("knowledgeBase.admin.errors.cannotDeleteWithArticles");
}
});
test("duplicate and new article validation errors map to semantic i18n keys", async () => {
const { default: KnowledgeBaseServiceServer, getKnowledgeBaseActionErrorKey } = await import("./KnowledgeBaseService.server");
await expect(
KnowledgeBaseServiceServer.duplicateArticle({ kb: { id: "kb-1", slug: "docs", defaultLanguage: "en" } as never, language: "en", articleId: "missing" })
).rejects.toMatchObject({ code: "invalid-article" });
try {
await KnowledgeBaseServiceServer.newArticle({
kb: { id: "kb-1", slug: "docs", defaultLanguage: "en" } as never,
params: {},
userId: "user-1",
position: "last",
categoryId: "missing",
});
} catch (error) {
expect(getKnowledgeBaseActionErrorKey(error)).toBe("knowledgeBase.adminCategories.errors.categoryNotFound");
}
});
test("public lookup errors map to public i18n keys", async () => {
const { default: KnowledgeBaseServiceServer, getKnowledgeBasePublicErrorKey } = await import("./KnowledgeBaseService.server");
cachifiedMock.mockResolvedValueOnce(null);
try {
await KnowledgeBaseServiceServer.get({ slug: "missing", enabled: true, request: new Request("https://example.com/help") });
throw new Error("Expected lookup to reject");
} catch (error) {
expect(error).toMatchObject({ code: "not-found" });
expect(getKnowledgeBasePublicErrorKey(error)).toBe("knowledgeBase.public.errors.notFound");
}
cachifiedMock.mockResolvedValueOnce({ title: "Draft docs", enabled: false });
try {
await KnowledgeBaseServiceServer.get({ slug: "draft", enabled: true, request: new Request("https://example.com/help") });
throw new Error("Expected disabled knowledge base to reject");
} catch (error) {
expect(error).toMatchObject({ code: "disabled" });
expect(getKnowledgeBasePublicErrorKey(error)).toBe("knowledgeBase.public.errors.disabled");
}
});
});
@@ -0,0 +1,648 @@
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,
};
@@ -0,0 +1,176 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createKnowledgeBaseArticle: vi.fn(),
getKbArticleBySlug: vi.fn(),
getKnowledgeBaseArticlesForTemplateExport: vi.fn(),
updateKnowledgeBaseArticle: vi.fn(),
createKnowledgeBaseCategory: vi.fn(),
getKbCategoryBySlug: vi.fn(),
getKnowledgeBaseCategoriesForTemplateExport: vi.fn(),
updateKnowledgeBaseCategory: vi.fn(),
createKnowledgeBase: vi.fn(),
getAllKnowledgeBases: vi.fn(),
getKnowledgeBaseBySlug: vi.fn(),
updateKnowledgeBase: vi.fn(),
createKnowledgeBaseCategorySection: vi.fn(),
updateKnowledgeBaseCategorySection: vi.fn(),
adminGetAllUsersNames: vi.fn(),
}));
vi.mock("../db/kbArticles.db.server", () => ({
createKnowledgeBaseArticle: mocks.createKnowledgeBaseArticle,
getKbArticleBySlug: mocks.getKbArticleBySlug,
getKnowledgeBaseArticlesForTemplateExport: mocks.getKnowledgeBaseArticlesForTemplateExport,
updateKnowledgeBaseArticle: mocks.updateKnowledgeBaseArticle,
}));
vi.mock("../db/kbCategories.db.server", () => ({
createKnowledgeBaseCategory: mocks.createKnowledgeBaseCategory,
getKbCategoryBySlug: mocks.getKbCategoryBySlug,
getKnowledgeBaseCategoriesForTemplateExport: mocks.getKnowledgeBaseCategoriesForTemplateExport,
updateKnowledgeBaseCategory: mocks.updateKnowledgeBaseCategory,
}));
vi.mock("../db/knowledgeBase.db.server", () => ({
createKnowledgeBase: mocks.createKnowledgeBase,
getAllKnowledgeBases: mocks.getAllKnowledgeBases,
getKnowledgeBaseBySlug: mocks.getKnowledgeBaseBySlug,
updateKnowledgeBase: mocks.updateKnowledgeBase,
}));
vi.mock("../db/kbCategorySections.db.server", () => ({
createKnowledgeBaseCategorySection: mocks.createKnowledgeBaseCategorySection,
updateKnowledgeBaseCategorySection: mocks.updateKnowledgeBaseCategorySection,
}));
vi.mock("~/utils/db/users.db.server", () => ({
adminGetAllUsersNames: mocks.adminGetAllUsersNames,
}));
import KnowledgeBaseTemplatesService from "./KnowledgeBaseTemplatesService.server";
function knowledgeBase(overrides: { id: string; slug: string; title: string }) {
return {
id: overrides.id,
basePath: "/help",
slug: overrides.slug,
title: overrides.title,
description: `${overrides.title} description`,
defaultLanguage: "en",
layout: "docs",
color: 1,
enabled: true,
languages: JSON.stringify(["en", "de"]),
links: JSON.stringify([{ href: "/help", title: "Help" }]),
logo: "",
seoImage: "",
};
}
describe("KnowledgeBaseTemplatesService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("exports multiple knowledge bases with flat category and article fetches", async () => {
mocks.getAllKnowledgeBases.mockResolvedValue([
knowledgeBase({ id: "kb_1", slug: "docs", title: "Docs" }),
knowledgeBase({ id: "kb_2", slug: "support", title: "Support" }),
]);
mocks.getKnowledgeBaseCategoriesForTemplateExport.mockResolvedValue([
{
id: "cat_1",
knowledgeBaseId: "kb_1",
slug: "start",
order: 1,
title: "Start",
description: "Start here",
icon: "book",
language: "en",
seoImage: "",
sections: [{ id: "section_1", order: 1, title: "Basics", description: "The basics" }],
},
{
id: "cat_2",
knowledgeBaseId: "kb_2",
slug: "faq",
order: 1,
title: "FAQ",
description: "Answers",
icon: "help",
language: "en",
seoImage: "",
sections: [],
},
]);
mocks.getKnowledgeBaseArticlesForTemplateExport.mockResolvedValue([
{
id: "article_1",
knowledgeBaseId: "kb_1",
slug: "welcome",
title: "Welcome",
description: "Welcome article",
order: 1,
contentDraft: "draft",
contentPublished: "published",
contentPublishedAsText: "published",
contentType: "markdown",
language: "en",
featuredOrder: null,
seoImage: "",
publishedAt: new Date("2026-01-01T00:00:00.000Z"),
category: { slug: "start", title: "Start" },
section: { order: 1, title: "Basics" },
createdByUser: { email: "author@example.com", firstName: "Ada", lastName: "Lovelace", avatar: null },
relatedArticles: [{ relatedArticle: { id: "article_2", order: 2, title: "Next", slug: "next" } }],
_count: { views: 0, upvotes: 0, downvotes: 0 },
},
{
id: "article_2",
knowledgeBaseId: "kb_2",
slug: "faq",
title: "FAQ",
description: "FAQ article",
order: 1,
contentDraft: "draft",
contentPublished: "published",
contentPublishedAsText: "published",
contentType: "markdown",
language: "en",
featuredOrder: 1,
seoImage: "",
publishedAt: null,
category: null,
section: null,
createdByUser: null,
relatedArticles: [],
_count: { views: 0, upvotes: 0, downvotes: 0 },
},
]);
const template = await KnowledgeBaseTemplatesService.getTemplate();
expect(mocks.getAllKnowledgeBases).toHaveBeenCalledTimes(1);
expect(mocks.getKnowledgeBaseCategoriesForTemplateExport).toHaveBeenCalledTimes(1);
expect(mocks.getKnowledgeBaseCategoriesForTemplateExport).toHaveBeenCalledWith({ knowledgeBaseIds: ["kb_1", "kb_2"] });
expect(mocks.getKnowledgeBaseArticlesForTemplateExport).toHaveBeenCalledTimes(1);
expect(mocks.getKnowledgeBaseArticlesForTemplateExport).toHaveBeenCalledWith({ knowledgeBaseIds: ["kb_1", "kb_2"] });
expect(template.knowledgeBases.map((kb) => kb.slug)).toEqual(["docs", "support"]);
expect(template.categories.map((category) => [category.knowledgeBaseSlug, category.slug])).toEqual([
["docs", "start"],
["support", "faq"],
]);
expect(template.articles.map((article) => [article.knowledgeBaseSlug, article.slug])).toEqual([
["docs", "welcome"],
["support", "faq"],
]);
expect(template.articles[0]).toMatchObject({
categorySlug: "start",
categorySectionOrder: 1,
createdByUserEmail: "author@example.com",
publishedAt: "2026-01-01T00:00:00.000Z",
relatedArticles: [{ slug: "next" }],
});
});
});
@@ -0,0 +1,291 @@
import type { KnowledgeBase, KnowledgeBaseArticle } from "@toolctrl/db/prisma-browser";
import {
createKnowledgeBaseArticle,
getKbArticleBySlug,
getKnowledgeBaseArticlesForTemplateExport,
updateKnowledgeBaseArticle,
} from "../db/kbArticles.db.server";
import {
createKnowledgeBaseCategory,
getKbCategoryBySlug,
getKnowledgeBaseCategoriesForTemplateExport,
updateKnowledgeBaseCategory,
} from "../db/kbCategories.db.server";
import { createKnowledgeBase, getAllKnowledgeBases, getKnowledgeBaseBySlug, updateKnowledgeBase } from "../db/knowledgeBase.db.server";
import { KbNavLinkDto } from "../dtos/KbNavLinkDto";
import { KnowledgeBasesTemplateDto } from "../dtos/KnowledgeBasesTemplateDto";
import { KnowledgeBaseCategoryWithDetails } from "../helpers/KbCategoryModelHelper";
import { createKnowledgeBaseCategorySection, updateKnowledgeBaseCategorySection } from "../db/kbCategorySections.db.server";
import { adminGetAllUsersNames } from "~/utils/db/users.db.server";
async function getTemplate(): Promise<KnowledgeBasesTemplateDto> {
const template: KnowledgeBasesTemplateDto = {
knowledgeBases: [],
categories: [],
articles: [],
};
const allKbs = await getAllKnowledgeBases();
const categoriesByKnowledgeBaseId = groupByKnowledgeBaseId(
await getKnowledgeBaseCategoriesForTemplateExport({ knowledgeBaseIds: allKbs.map((kb) => kb.id) })
);
const articlesByKnowledgeBaseId = groupByKnowledgeBaseId(await getKnowledgeBaseArticlesForTemplateExport({ knowledgeBaseIds: allKbs.map((kb) => kb.id) }));
for (const kb of allKbs) {
template.knowledgeBases.push({
basePath: kb.basePath,
slug: kb.slug,
title: kb.title,
description: kb.description,
defaultLanguage: kb.defaultLanguage,
layout: kb.layout,
color: kb.color,
enabled: kb.enabled,
languages: JSON.parse(kb.languages) as string[],
links: JSON.parse(kb.links) as KbNavLinkDto[],
logo: kb.logo,
seoImage: kb.seoImage,
});
const allCategories = categoriesByKnowledgeBaseId.get(kb.id) ?? [];
for (const category of allCategories) {
template.categories.push({
knowledgeBaseSlug: kb.slug,
slug: category.slug,
order: category.order,
title: category.title,
description: category.description,
icon: category.icon,
language: category.language,
seoImage: category.seoImage,
sections: category.sections.map((section) => ({
order: section.order,
title: section.title,
description: section.description,
})),
});
}
const allArticles = articlesByKnowledgeBaseId.get(kb.id) ?? [];
for (const article of allArticles) {
template.articles.push({
knowledgeBaseSlug: kb.slug,
categorySlug: article.category?.slug ?? null,
categorySectionOrder: article.section?.order ?? null,
slug: article.slug,
title: article.title,
description: article.description,
order: article.order,
contentDraft: article.contentDraft,
contentPublished: article.contentPublished,
contentPublishedAsText: article.contentPublishedAsText,
contentType: article.contentType,
language: article.language,
featuredOrder: article.featuredOrder,
seoImage: article.seoImage,
publishedAt: article.publishedAt ? article.publishedAt.toISOString() : null,
createdByUserEmail: article.createdByUser?.email ?? null,
relatedArticles: article.relatedArticles.map(({ relatedArticle }) => ({
slug: relatedArticle.slug,
})),
});
}
}
return template;
}
function groupByKnowledgeBaseId<T extends { knowledgeBaseId: string }>(items: T[]) {
const grouped = new Map<string, T[]>();
for (const item of items) {
const current = grouped.get(item.knowledgeBaseId);
if (current) {
current.push(item);
} else {
grouped.set(item.knowledgeBaseId, [item]);
}
}
return grouped;
}
async function importKbs({ template, currentUserId }: { template: KnowledgeBasesTemplateDto; currentUserId: string }) {
let created = {
kbs: 0,
categories: 0,
sections: 0,
articles: 0,
};
let updated = {
kbs: 0,
categories: 0,
sections: 0,
articles: 0,
};
const allUsers = await adminGetAllUsersNames();
for (const kb of template.knowledgeBases) {
if (!kb.basePath) {
kb.basePath = "/";
}
let existing: KnowledgeBase | null = await getKnowledgeBaseBySlug(kb.slug);
if (existing) {
await updateKnowledgeBase(existing.id, {
basePath: kb.basePath,
slug: kb.slug,
title: kb.title,
description: kb.description,
defaultLanguage: kb.defaultLanguage,
layout: kb.layout,
color: kb.color,
enabled: kb.enabled,
languages: JSON.stringify(kb.languages),
links: JSON.stringify(kb.links),
logo: kb.logo,
seoImage: kb.seoImage,
});
updated.kbs++;
} else {
existing = await createKnowledgeBase({
basePath: kb.basePath,
slug: kb.slug,
title: kb.title,
description: kb.description,
defaultLanguage: kb.defaultLanguage,
layout: kb.layout,
color: kb.color,
enabled: kb.enabled,
languages: JSON.stringify(kb.languages),
links: JSON.stringify(kb.links),
logo: kb.logo,
seoImage: kb.seoImage,
});
created.kbs++;
}
for (const category of template.categories.filter((c) => c.knowledgeBaseSlug === kb.slug)) {
let existingCategory: KnowledgeBaseCategoryWithDetails | null = await getKbCategoryBySlug({
knowledgeBaseId: existing.id,
slug: category.slug,
language: category.language,
});
if (existingCategory) {
await updateKnowledgeBaseCategory(existingCategory.id, {
title: category.title,
order: category.order,
description: category.description,
icon: category.icon,
language: category.language,
seoImage: category.seoImage,
});
updated.categories++;
} else {
existingCategory = await createKnowledgeBaseCategory({
knowledgeBaseId: existing.id,
slug: category.slug,
title: category.title,
order: category.order,
description: category.description,
icon: category.icon,
language: category.language,
seoImage: category.seoImage,
});
created.categories++;
}
for (const section of category.sections) {
let existingSection: { id: string; order: number; title: string; description: string } | null = null;
if (existingCategory) {
existingSection = existingCategory.sections.find((s) => s.order === section.order) ?? null;
}
if (existingSection) {
await updateKnowledgeBaseCategorySection(existingSection.id, {
title: section.title,
description: section.description,
});
updated.sections++;
} else {
await createKnowledgeBaseCategorySection({
categoryId: existingCategory?.id ?? null,
order: section.order,
title: section.title,
description: section.description,
});
created.sections++;
}
}
}
for (const article of template.articles.filter((a) => a.knowledgeBaseSlug === kb.slug)) {
let existingArticle: KnowledgeBaseArticle | null = await getKbArticleBySlug({
knowledgeBaseId: existing.id,
slug: article.slug,
language: article.language,
});
let category: KnowledgeBaseCategoryWithDetails | null = null;
let sectionId: string | null = null;
if (article.categorySlug) {
category = await getKbCategoryBySlug({
knowledgeBaseId: existing.id,
slug: article.categorySlug,
language: article.language,
});
if (category && article.categorySectionOrder) {
const section = category.sections.find((s) => s.order === article.categorySectionOrder);
if (section) {
sectionId = section.id;
}
}
}
let createdByUserId = currentUserId;
if (article.createdByUserEmail) {
const user = allUsers.find((f) => f.email === article.createdByUserEmail);
if (user) {
createdByUserId = user.id;
}
}
if (existingArticle) {
await updateKnowledgeBaseArticle(existingArticle.id, {
categoryId: category?.id ?? null,
sectionId: sectionId,
slug: article.slug,
title: article.title,
description: article.description,
order: article.order,
contentDraft: article.contentDraft,
contentPublished: article.contentPublished,
contentPublishedAsText: article.contentPublishedAsText,
contentType: article.contentType,
language: article.language,
featuredOrder: article.featuredOrder,
createdByUserId,
seoImage: article.seoImage,
publishedAt: article.publishedAt ? new Date(article.publishedAt) : null,
});
updated.articles++;
} else {
existingArticle = await createKnowledgeBaseArticle({
knowledgeBaseId: existing.id,
categoryId: category?.id ?? null,
sectionId: sectionId,
slug: article.slug,
title: article.title,
description: article.description,
order: article.order,
contentDraft: article.contentDraft,
contentPublished: article.contentPublished,
contentPublishedAsText: article.contentPublishedAsText,
contentType: article.contentType,
language: article.language,
featuredOrder: article.featuredOrder,
createdByUserId,
seoImage: article.seoImage,
publishedAt: article.publishedAt ? new Date(article.publishedAt) : null,
});
created.articles++;
}
}
}
return {
created,
updated,
};
}
export default {
getTemplate,
importKbs,
};