Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
+148
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
+648
@@ -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,
|
||||
};
|
||||
+176
@@ -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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
+291
@@ -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,
|
||||
};
|
||||
Reference in new issue
Block a user