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:
126
apps/api/src/knowledge/services/graph.service.spec.ts
Normal file
126
apps/api/src/knowledge/services/graph.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
164
apps/api/src/knowledge/services/graph.service.ts
Normal file
164
apps/api/src/knowledge/services/graph.service.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
122
apps/api/src/knowledge/services/stats.service.spec.ts
Normal file
122
apps/api/src/knowledge/services/stats.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
169
apps/api/src/knowledge/services/stats.service.ts
Normal file
169
apps/api/src/knowledge/services/stats.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user