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,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);
});
});
});