Files
stack/apps/web/src/lib/api/knowledge.ts
Jason Woltje 0e64dc8525 feat(#72): implement interactive graph visualization component
- Create KnowledgeGraphViewer component with @xyflow/react
- Implement three layout types: force-directed, hierarchical (ELK), circular
- Add node sizing based on connection count (40px-120px range)
- Apply PDA-friendly status colors (green=published, blue=draft, gray=archived)
- Highlight orphan nodes with distinct color
- Add interactive features: zoom, pan, click-to-navigate
- Implement filters: status, tags, show/hide orphans
- Add statistics display and legend panel
- Create comprehensive test suite (16 tests, all passing)
- Add fetchKnowledgeGraph API function
- Create /knowledge/graph page
- Performance tested with 500+ nodes
- All quality gates passed (tests, typecheck, lint)

Refs #72

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 15:38:16 -06:00

611 lines
14 KiB
TypeScript

/**
* Knowledge API Client
* Handles knowledge entry-related API requests
*/
import type {
KnowledgeEntryWithTags,
KnowledgeTag,
KnowledgeEntryVersionWithAuthor,
PaginatedResponse,
} from "@mosaic/shared";
import { EntryStatus, Visibility } from "@mosaic/shared";
import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client";
export interface EntryFilters {
status?: EntryStatus;
visibility?: Visibility;
tag?: string;
tags?: string[];
page?: number;
limit?: number;
search?: string;
sortBy?: "updatedAt" | "createdAt" | "title";
sortOrder?: "asc" | "desc";
}
export interface EntriesResponse {
data: KnowledgeEntryWithTags[];
meta?: {
total?: number;
page?: number;
limit?: number;
};
}
export interface CreateEntryData {
title: string;
content: string;
summary?: string;
status?: EntryStatus;
visibility?: Visibility;
tags?: string[];
}
export interface UpdateEntryData {
title?: string;
content?: string;
summary?: string;
status?: EntryStatus;
visibility?: Visibility;
tags?: string[];
changeNote?: string;
}
export interface RestoreVersionData {
changeNote?: string;
}
/**
* Fetch knowledge entries with optional filters
*/
export async function fetchEntries(filters?: EntryFilters): Promise<EntriesResponse> {
const params = new URLSearchParams();
if (filters?.status) {
params.append("status", filters.status);
}
if (filters?.visibility) {
params.append("visibility", filters.visibility);
}
if (filters?.tag) {
params.append("tag", filters.tag);
}
if (filters?.tags && filters.tags.length > 0) {
filters.tags.forEach((tag) => {
params.append("tags", tag);
});
}
if (filters?.page) {
params.append("page", filters.page.toString());
}
if (filters?.limit) {
params.append("limit", filters.limit.toString());
}
if (filters?.search) {
params.append("search", filters.search);
}
if (filters?.sortBy) {
params.append("sortBy", filters.sortBy);
}
if (filters?.sortOrder) {
params.append("sortOrder", filters.sortOrder);
}
const queryString = params.toString();
const endpoint = queryString ? `/api/knowledge/entries?${queryString}` : "/api/knowledge/entries";
const response = await apiGet<EntriesResponse>(endpoint);
return response;
}
/**
* Fetch a single knowledge entry by slug
*/
export async function fetchEntry(slug: string): Promise<KnowledgeEntryWithTags> {
return apiGet<KnowledgeEntryWithTags>(`/api/knowledge/entries/${slug}`);
}
/**
* Create a new knowledge entry
*/
export async function createEntry(data: CreateEntryData): Promise<KnowledgeEntryWithTags> {
return apiPost<KnowledgeEntryWithTags>("/api/knowledge/entries", data);
}
/**
* Update an existing knowledge entry
*/
export async function updateEntry(
slug: string,
data: UpdateEntryData
): Promise<KnowledgeEntryWithTags> {
return apiPatch<KnowledgeEntryWithTags>(`/api/knowledge/entries/${slug}`, data);
}
/**
* Delete (archive) a knowledge entry
*/
export async function deleteEntry(slug: string): Promise<void> {
await apiDelete(`/api/knowledge/entries/${slug}`);
}
/**
* Fetch all knowledge tags
*/
export async function fetchTags(): Promise<KnowledgeTag[]> {
const response = await apiGet<ApiResponse<KnowledgeTag[]>>("/api/knowledge/tags");
return response.data;
}
/**
* Fetch version history for an entry
*/
export async function fetchVersions(
slug: string,
page = 1,
limit = 20
): Promise<PaginatedResponse<KnowledgeEntryVersionWithAuthor>> {
const params = new URLSearchParams();
params.append("page", page.toString());
params.append("limit", limit.toString());
return apiGet<PaginatedResponse<KnowledgeEntryVersionWithAuthor>>(
`/api/knowledge/entries/${slug}/versions?${params.toString()}`
);
}
/**
* Fetch a specific version of an entry
*/
export async function fetchVersion(
slug: string,
version: number
): Promise<KnowledgeEntryVersionWithAuthor> {
return apiGet<KnowledgeEntryVersionWithAuthor>(
`/api/knowledge/entries/${slug}/versions/${version.toString()}`
);
}
/**
* Restore a previous version of an entry
*/
export async function restoreVersion(
slug: string,
version: number,
data?: RestoreVersionData
): Promise<KnowledgeEntryWithTags> {
return apiPost<KnowledgeEntryWithTags>(
`/api/knowledge/entries/${slug}/restore/${version.toString()}`,
data ?? {}
);
}
/**
* Fetch backlinks for an entry (entries that link to this entry)
*/
export async function fetchBacklinks(slug: string): Promise<{
entry: { id: string; slug: string; title: string };
backlinks: {
id: string;
sourceId: string;
targetId: string;
linkText: string;
displayText: string;
positionStart: number;
positionEnd: number;
resolved: boolean;
context: string | null;
createdAt: Date;
source: {
id: string;
title: string;
slug: string;
summary?: string | null;
};
}[];
count: number;
}> {
return apiGet<{
entry: { id: string; slug: string; title: string };
backlinks: {
id: string;
sourceId: string;
targetId: string;
linkText: string;
displayText: string;
positionStart: number;
positionEnd: number;
resolved: boolean;
context: string | null;
createdAt: Date;
source: {
id: string;
title: string;
slug: string;
summary?: string | null;
};
}[];
count: number;
}>(`/api/knowledge/entries/${slug}/backlinks`);
}
/**
* Fetch knowledge base statistics
*/
export async function fetchKnowledgeStats(): Promise<{
overview: {
totalEntries: number;
totalTags: number;
totalLinks: number;
publishedEntries: number;
draftEntries: number;
archivedEntries: number;
};
mostConnected: {
id: string;
slug: string;
title: string;
incomingLinks: number;
outgoingLinks: number;
totalConnections: number;
}[];
recentActivity: {
id: string;
slug: string;
title: string;
updatedAt: string;
status: string;
}[];
tagDistribution: {
id: string;
name: string;
slug: string;
color: string | null;
entryCount: number;
}[];
}> {
return apiGet(`/api/knowledge/stats`);
}
/**
* Fetch entry graph (network of connected entries)
*/
export async function fetchEntryGraph(
slug: string,
depth = 1
): Promise<{
centerNode: {
id: string;
slug: string;
title: string;
summary: string | null;
tags: {
id: string;
name: string;
slug: string;
color: string | null;
}[];
depth: number;
};
nodes: {
id: string;
slug: string;
title: string;
summary: string | null;
tags: {
id: string;
name: string;
slug: string;
color: string | null;
}[];
depth: number;
}[];
edges: {
id: string;
sourceId: string;
targetId: string;
linkText: string;
}[];
stats: {
totalNodes: number;
totalEdges: number;
maxDepth: number;
};
}> {
const params = new URLSearchParams();
params.append("depth", depth.toString());
return apiGet(`/api/knowledge/entries/${slug}/graph?${params.toString()}`);
}
/**
* Fetch full knowledge graph
*/
export async function fetchKnowledgeGraph(filters?: {
tags?: string[];
status?: string;
limit?: number;
}): Promise<{
nodes: {
id: string;
slug: string;
title: string;
summary: string | null;
status?: string;
tags: {
id: string;
name: string;
slug: string;
color: string | null;
}[];
depth: number;
isOrphan?: boolean;
}[];
edges: {
id: string;
sourceId: string;
targetId: string;
linkText: string;
}[];
stats: {
totalNodes: number;
totalEdges: number;
orphanCount: number;
};
}> {
const params = new URLSearchParams();
if (filters?.tags && filters.tags.length > 0) {
filters.tags.forEach((tag) => {
params.append("tags", tag);
});
}
if (filters?.status) {
params.append("status", filters.status);
}
if (filters?.limit !== undefined) {
params.append("limit", filters.limit.toString());
}
const queryString = params.toString();
const endpoint = queryString ? `/api/knowledge/graph?${queryString}` : "/api/knowledge/graph";
return apiGet(endpoint);
}
/**
* Mock entries for development (until backend endpoints are ready)
*/
export const mockEntries: KnowledgeEntryWithTags[] = [
{
id: "entry-1",
workspaceId: "workspace-1",
slug: "getting-started",
title: "Getting Started with Mosaic Stack",
content: "# Getting Started\n\nWelcome to Mosaic Stack...",
contentHtml: "<h1>Getting Started</h1><p>Welcome to Mosaic Stack...</p>",
summary: "A comprehensive guide to getting started with the Mosaic Stack platform.",
status: EntryStatus.PUBLISHED,
visibility: Visibility.PUBLIC,
createdBy: "user-1",
updatedBy: "user-1",
createdAt: new Date("2026-01-20"),
updatedAt: new Date("2026-01-28"),
tags: [
{
id: "tag-1",
workspaceId: "workspace-1",
name: "Tutorial",
slug: "tutorial",
color: "#3B82F6",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-2",
workspaceId: "workspace-1",
name: "Onboarding",
slug: "onboarding",
color: "#10B981",
createdAt: new Date(),
updatedAt: new Date(),
},
],
},
{
id: "entry-2",
workspaceId: "workspace-1",
slug: "architecture-overview",
title: "Architecture Overview",
content: "# Architecture\n\nThe Mosaic Stack architecture...",
contentHtml: "<h1>Architecture</h1><p>The Mosaic Stack architecture...</p>",
summary: "Overview of the system architecture and design patterns used in Mosaic Stack.",
status: EntryStatus.PUBLISHED,
visibility: Visibility.WORKSPACE,
createdBy: "user-1",
updatedBy: "user-1",
createdAt: new Date("2026-01-15"),
updatedAt: new Date("2026-01-27"),
tags: [
{
id: "tag-3",
workspaceId: "workspace-1",
name: "Architecture",
slug: "architecture",
color: "#8B5CF6",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-4",
workspaceId: "workspace-1",
name: "Technical",
slug: "technical",
color: "#F59E0B",
createdAt: new Date(),
updatedAt: new Date(),
},
],
},
{
id: "entry-3",
workspaceId: "workspace-1",
slug: "api-documentation-draft",
title: "API Documentation (Draft)",
content: "# API Docs\n\nWork in progress...",
contentHtml: "<h1>API Docs</h1><p>Work in progress...</p>",
summary: "Comprehensive API documentation for developers.",
status: EntryStatus.DRAFT,
visibility: Visibility.PRIVATE,
createdBy: "user-1",
updatedBy: "user-1",
createdAt: new Date("2026-01-29"),
updatedAt: new Date("2026-01-29"),
tags: [
{
id: "tag-4",
workspaceId: "workspace-1",
name: "Technical",
slug: "technical",
color: "#F59E0B",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-5",
workspaceId: "workspace-1",
name: "API",
slug: "api",
color: "#EF4444",
createdAt: new Date(),
updatedAt: new Date(),
},
],
},
{
id: "entry-4",
workspaceId: "workspace-1",
slug: "deployment-guide",
title: "Deployment Guide",
content: "# Deployment\n\nHow to deploy Mosaic Stack...",
contentHtml: "<h1>Deployment</h1><p>How to deploy Mosaic Stack...</p>",
summary: "Step-by-step guide for deploying Mosaic Stack to production.",
status: EntryStatus.PUBLISHED,
visibility: Visibility.WORKSPACE,
createdBy: "user-1",
updatedBy: "user-1",
createdAt: new Date("2026-01-18"),
updatedAt: new Date("2026-01-25"),
tags: [
{
id: "tag-6",
workspaceId: "workspace-1",
name: "DevOps",
slug: "devops",
color: "#14B8A6",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-1",
workspaceId: "workspace-1",
name: "Tutorial",
slug: "tutorial",
color: "#3B82F6",
createdAt: new Date(),
updatedAt: new Date(),
},
],
},
{
id: "entry-5",
workspaceId: "workspace-1",
slug: "old-meeting-notes",
title: "Q4 2025 Meeting Notes",
content: "# Meeting Notes\n\nOld archived notes...",
contentHtml: "<h1>Meeting Notes</h1><p>Old archived notes...</p>",
summary: "Meeting notes from Q4 2025 - archived for reference.",
status: EntryStatus.ARCHIVED,
visibility: Visibility.PRIVATE,
createdBy: "user-1",
updatedBy: "user-1",
createdAt: new Date("2025-12-15"),
updatedAt: new Date("2026-01-05"),
tags: [
{
id: "tag-7",
workspaceId: "workspace-1",
name: "Meetings",
slug: "meetings",
color: "#6B7280",
createdAt: new Date(),
updatedAt: new Date(),
},
],
},
];
export const mockTags: KnowledgeTag[] = [
{
id: "tag-1",
workspaceId: "workspace-1",
name: "Tutorial",
slug: "tutorial",
color: "#3B82F6",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-2",
workspaceId: "workspace-1",
name: "Onboarding",
slug: "onboarding",
color: "#10B981",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-3",
workspaceId: "workspace-1",
name: "Architecture",
slug: "architecture",
color: "#8B5CF6",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-4",
workspaceId: "workspace-1",
name: "Technical",
slug: "technical",
color: "#F59E0B",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-5",
workspaceId: "workspace-1",
name: "API",
slug: "api",
color: "#EF4444",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-6",
workspaceId: "workspace-1",
name: "DevOps",
slug: "devops",
color: "#14B8A6",
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "tag-7",
workspaceId: "workspace-1",
name: "Meetings",
slug: "meetings",
color: "#6B7280",
createdAt: new Date(),
updatedAt: new Date(),
},
];