feat: add knowledge graph views and stats (closes #73, closes #74)

Issue #73 - Entry-Centered Graph View:
- Added GET /api/knowledge/entries/:id/graph endpoint with depth parameter
- Returns entry + connected nodes with link relationships
- Created GraphService for graph traversal using BFS
- Added EntryGraphViewer component for frontend
- Integrated graph view tab into entry detail page

Issue #74 - Graph Statistics Dashboard:
- Added GET /api/knowledge/stats endpoint
- Returns overview stats (entries, tags, links by status)
- Includes most connected entries, recent activity, tag distribution
- Created StatsDashboard component with visual stats
- Added route at /knowledge/stats

Backend:
- GraphService: BFS-based graph traversal with configurable depth
- StatsService: Parallel queries for comprehensive statistics
- GraphQueryDto: Validation for depth parameter (1-5)
- Entity types for graph nodes/edges and statistics
- Unit tests for both services

Frontend:
- EntryGraphViewer: Entry-centered graph visualization
- StatsDashboard: Statistics overview with charts
- Graph view tab on entry detail page
- API client functions for new endpoints
- TypeScript strict typing throughout
This commit is contained in:
Jason Woltje
2026-01-29 23:25:29 -06:00
parent 59aec28d5c
commit 26a334c677
18 changed files with 1351 additions and 12 deletions

View File

@@ -0,0 +1,126 @@
import { Test, TestingModule } from "@nestjs/testing";
import { NotFoundException } from "@nestjs/common";
import { GraphService } from "./graph.service";
import { PrismaService } from "../../prisma/prisma.service";
describe("GraphService", () => {
let service: GraphService;
let prisma: PrismaService;
const mockEntry = {
id: "entry-1",
workspaceId: "workspace-1",
slug: "test-entry",
title: "Test Entry",
content: "Test content",
contentHtml: "<p>Test content</p>",
summary: "Test summary",
status: "PUBLISHED",
visibility: "WORKSPACE",
createdAt: new Date(),
updatedAt: new Date(),
createdBy: "user-1",
updatedBy: "user-1",
tags: [],
outgoingLinks: [],
incomingLinks: [],
};
const mockPrismaService = {
knowledgeEntry: {
findUnique: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
GraphService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
service = module.get<GraphService>(GraphService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("getEntryGraph", () => {
it("should throw NotFoundException if entry does not exist", async () => {
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(null);
await expect(
service.getEntryGraph("workspace-1", "non-existent", 1)
).rejects.toThrow(NotFoundException);
});
it("should throw NotFoundException if entry belongs to different workspace", async () => {
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue({
...mockEntry,
workspaceId: "different-workspace",
});
await expect(
service.getEntryGraph("workspace-1", "entry-1", 1)
).rejects.toThrow(NotFoundException);
});
it("should return graph with center node when depth is 0", async () => {
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(mockEntry);
const result = await service.getEntryGraph("workspace-1", "entry-1", 0);
expect(result.centerNode.id).toBe("entry-1");
expect(result.nodes).toHaveLength(1);
expect(result.edges).toHaveLength(0);
expect(result.stats.totalNodes).toBe(1);
expect(result.stats.totalEdges).toBe(0);
});
it("should build graph with connected nodes at depth 1", async () => {
const linkedEntry = {
id: "entry-2",
slug: "linked-entry",
title: "Linked Entry",
summary: null,
tags: [],
};
mockPrismaService.knowledgeEntry.findUnique
.mockResolvedValueOnce({
...mockEntry,
outgoingLinks: [
{
id: "link-1",
sourceId: "entry-1",
targetId: "entry-2",
linkText: "link to entry 2",
target: linkedEntry,
},
],
incomingLinks: [],
})
.mockResolvedValueOnce({
...linkedEntry,
tags: [],
outgoingLinks: [],
incomingLinks: [],
});
const result = await service.getEntryGraph("workspace-1", "entry-1", 1);
expect(result.nodes).toHaveLength(2);
expect(result.edges).toHaveLength(1);
expect(result.stats.totalNodes).toBe(2);
expect(result.stats.totalEdges).toBe(1);
});
});
});

View File

@@ -0,0 +1,164 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../../prisma/prisma.service";
import type { EntryGraphResponse, GraphNode, GraphEdge } from "../entities/graph.entity";
/**
* Service for knowledge graph operations
*/
@Injectable()
export class GraphService {
constructor(private readonly prisma: PrismaService) {}
/**
* Get entry-centered graph view
* Returns the entry and all connected nodes up to specified depth
*/
async getEntryGraph(
workspaceId: string,
entryId: string,
maxDepth: number = 1
): Promise<EntryGraphResponse> {
// Verify entry exists
const centerEntry = await this.prisma.knowledgeEntry.findUnique({
where: { id: entryId },
include: {
tags: {
include: {
tag: true,
},
},
},
});
if (!centerEntry || centerEntry.workspaceId !== workspaceId) {
throw new NotFoundException("Entry not found");
}
// Build graph using BFS
const visitedNodes = new Set<string>();
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];
const nodeDepths = new Map<string, number>();
// Queue: [entryId, depth]
const queue: Array<[string, number]> = [[entryId, 0]];
visitedNodes.add(entryId);
nodeDepths.set(entryId, 0);
while (queue.length > 0) {
const [currentId, depth] = queue.shift()!;
// Fetch current entry with related data
const currentEntry = await this.prisma.knowledgeEntry.findUnique({
where: { id: currentId },
include: {
tags: {
include: {
tag: true,
},
},
outgoingLinks: {
include: {
target: {
select: {
id: true,
slug: true,
title: true,
summary: true,
},
},
},
},
incomingLinks: {
include: {
source: {
select: {
id: true,
slug: true,
title: true,
summary: true,
},
},
},
},
},
});
if (!currentEntry) continue;
// Add current node
const graphNode: GraphNode = {
id: currentEntry.id,
slug: currentEntry.slug,
title: currentEntry.title,
summary: currentEntry.summary,
tags: currentEntry.tags.map((et) => ({
id: et.tag.id,
name: et.tag.name,
slug: et.tag.slug,
color: et.tag.color,
})),
depth,
};
nodes.push(graphNode);
// Continue BFS if not at max depth
if (depth < maxDepth) {
// Process outgoing links
for (const link of currentEntry.outgoingLinks) {
// Add edge
edges.push({
id: link.id,
sourceId: link.sourceId,
targetId: link.targetId,
linkText: link.linkText,
});
// Add target to queue if not visited
if (!visitedNodes.has(link.targetId)) {
visitedNodes.add(link.targetId);
nodeDepths.set(link.targetId, depth + 1);
queue.push([link.targetId, depth + 1]);
}
}
// Process incoming links
for (const link of currentEntry.incomingLinks) {
// Add edge
const edgeExists = edges.some(
(e) => e.sourceId === link.sourceId && e.targetId === link.targetId
);
if (!edgeExists) {
edges.push({
id: link.id,
sourceId: link.sourceId,
targetId: link.targetId,
linkText: link.linkText,
});
}
// Add source to queue if not visited
if (!visitedNodes.has(link.sourceId)) {
visitedNodes.add(link.sourceId);
nodeDepths.set(link.sourceId, depth + 1);
queue.push([link.sourceId, depth + 1]);
}
}
}
}
// Find center node
const centerNode = nodes.find((n) => n.id === entryId)!;
return {
centerNode,
nodes,
edges,
stats: {
totalNodes: nodes.length,
totalEdges: edges.length,
maxDepth,
},
};
}
}

View File

@@ -4,3 +4,7 @@ export type {
ResolvedLink,
Backlink,
} from "./link-resolution.service";
export { LinkSyncService } from "./link-sync.service";
export { SearchService } from "./search.service";
export { GraphService } from "./graph.service";
export { StatsService } from "./stats.service";

View File

@@ -0,0 +1,122 @@
import { Test, TestingModule } from "@nestjs/testing";
import { StatsService } from "./stats.service";
import { PrismaService } from "../../prisma/prisma.service";
import { EntryStatus } from "@prisma/client";
describe("StatsService", () => {
let service: StatsService;
let prisma: PrismaService;
const mockPrismaService = {
knowledgeEntry: {
count: jest.fn(),
findMany: jest.fn(),
},
knowledgeTag: {
count: jest.fn(),
findMany: jest.fn(),
},
knowledgeLink: {
count: jest.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
StatsService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
service = module.get<StatsService>(StatsService);
prisma = module.get<PrismaService>(PrismaService);
jest.clearAllMocks();
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("getStats", () => {
it("should return knowledge base statistics", async () => {
// Mock all the parallel queries
mockPrismaService.knowledgeEntry.count
.mockResolvedValueOnce(10) // total entries
.mockResolvedValueOnce(5) // published
.mockResolvedValueOnce(3) // drafts
.mockResolvedValueOnce(2); // archived
mockPrismaService.knowledgeTag.count.mockResolvedValue(7);
mockPrismaService.knowledgeLink.count.mockResolvedValue(15);
mockPrismaService.knowledgeEntry.findMany
.mockResolvedValueOnce([
// most connected
{
id: "entry-1",
slug: "test-entry",
title: "Test Entry",
_count: { incomingLinks: 5, outgoingLinks: 3 },
},
])
.mockResolvedValueOnce([
// recent activity
{
id: "entry-2",
slug: "recent-entry",
title: "Recent Entry",
updatedAt: new Date(),
status: EntryStatus.PUBLISHED,
},
]);
mockPrismaService.knowledgeTag.findMany.mockResolvedValue([
{
id: "tag-1",
name: "Test Tag",
slug: "test-tag",
color: "#FF0000",
_count: { entries: 3 },
},
]);
const result = await service.getStats("workspace-1");
expect(result.overview.totalEntries).toBe(10);
expect(result.overview.totalTags).toBe(7);
expect(result.overview.totalLinks).toBe(15);
expect(result.overview.publishedEntries).toBe(5);
expect(result.overview.draftEntries).toBe(3);
expect(result.overview.archivedEntries).toBe(2);
expect(result.mostConnected).toHaveLength(1);
expect(result.mostConnected[0].totalConnections).toBe(8);
expect(result.recentActivity).toHaveLength(1);
expect(result.tagDistribution).toHaveLength(1);
});
it("should handle empty knowledge base", async () => {
// Mock all counts as 0
mockPrismaService.knowledgeEntry.count.mockResolvedValue(0);
mockPrismaService.knowledgeTag.count.mockResolvedValue(0);
mockPrismaService.knowledgeLink.count.mockResolvedValue(0);
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]);
mockPrismaService.knowledgeTag.findMany.mockResolvedValue([]);
const result = await service.getStats("workspace-1");
expect(result.overview.totalEntries).toBe(0);
expect(result.overview.totalTags).toBe(0);
expect(result.overview.totalLinks).toBe(0);
expect(result.mostConnected).toHaveLength(0);
expect(result.recentActivity).toHaveLength(0);
expect(result.tagDistribution).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,169 @@
import { Injectable } from "@nestjs/common";
import { EntryStatus } from "@prisma/client";
import { PrismaService } from "../../prisma/prisma.service";
import type { KnowledgeStats } from "../entities/stats.entity";
/**
* Service for knowledge base statistics
*/
@Injectable()
export class StatsService {
constructor(private readonly prisma: PrismaService) {}
/**
* Get comprehensive knowledge base statistics
*/
async getStats(workspaceId: string): Promise<KnowledgeStats> {
// Run queries in parallel for better performance
const [
totalEntries,
totalTags,
totalLinks,
publishedEntries,
draftEntries,
archivedEntries,
entriesWithLinkCounts,
recentEntries,
tagsWithCounts,
] = await Promise.all([
// Total entries
this.prisma.knowledgeEntry.count({
where: { workspaceId },
}),
// Total tags
this.prisma.knowledgeTag.count({
where: { workspaceId },
}),
// Total links
this.prisma.knowledgeLink.count({
where: {
source: { workspaceId },
},
}),
// Published entries
this.prisma.knowledgeEntry.count({
where: {
workspaceId,
status: EntryStatus.PUBLISHED,
},
}),
// Draft entries
this.prisma.knowledgeEntry.count({
where: {
workspaceId,
status: EntryStatus.DRAFT,
},
}),
// Archived entries
this.prisma.knowledgeEntry.count({
where: {
workspaceId,
status: EntryStatus.ARCHIVED,
},
}),
// Most connected entries
this.prisma.knowledgeEntry.findMany({
where: { workspaceId },
include: {
_count: {
select: {
incomingLinks: true,
outgoingLinks: true,
},
},
},
orderBy: {
incomingLinks: {
_count: "desc",
},
},
take: 10,
}),
// Recent activity
this.prisma.knowledgeEntry.findMany({
where: { workspaceId },
orderBy: {
updatedAt: "desc",
},
take: 10,
select: {
id: true,
slug: true,
title: true,
updatedAt: true,
status: true,
},
}),
// Tag distribution
this.prisma.knowledgeTag.findMany({
where: { workspaceId },
include: {
_count: {
select: {
entries: true,
},
},
},
orderBy: {
entries: {
_count: "desc",
},
},
}),
]);
// Transform most connected entries
const mostConnected = entriesWithLinkCounts.map((entry) => {
const incomingLinks = entry._count.incomingLinks;
const outgoingLinks = entry._count.outgoingLinks;
return {
id: entry.id,
slug: entry.slug,
title: entry.title,
incomingLinks,
outgoingLinks,
totalConnections: incomingLinks + outgoingLinks,
};
});
// Sort by total connections
mostConnected.sort((a, b) => b.totalConnections - a.totalConnections);
// Transform tag distribution
const tagDistribution = tagsWithCounts.map((tag) => ({
id: tag.id,
name: tag.name,
slug: tag.slug,
color: tag.color,
entryCount: tag._count.entries,
}));
return {
overview: {
totalEntries,
totalTags,
totalLinks,
publishedEntries,
draftEntries,
archivedEntries,
},
mostConnected,
recentActivity: recentEntries.map((entry) => ({
id: entry.id,
slug: entry.slug,
title: entry.title,
updatedAt: entry.updatedAt,
status: entry.status,
})),
tagDistribution,
};
}
}