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:
14
apps/api/src/knowledge/dto/graph-query.dto.ts
Normal file
14
apps/api/src/knowledge/dto/graph-query.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { IsOptional, IsInt, Min, Max } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
|
||||
/**
|
||||
* Query parameters for entry-centered graph view
|
||||
*/
|
||||
export class GraphQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
depth?: number = 1;
|
||||
}
|
||||
@@ -8,3 +8,4 @@ export {
|
||||
TagSearchDto,
|
||||
RecentEntriesDto,
|
||||
} from "./search-query.dto";
|
||||
export { GraphQueryDto } from "./graph-query.dto";
|
||||
|
||||
40
apps/api/src/knowledge/entities/graph.entity.ts
Normal file
40
apps/api/src/knowledge/entities/graph.entity.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Represents a node in the knowledge graph
|
||||
*/
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
tags: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color: string | null;
|
||||
}>;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an edge/link in the knowledge graph
|
||||
*/
|
||||
export interface GraphEdge {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
linkText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry-centered graph response
|
||||
*/
|
||||
export interface EntryGraphResponse {
|
||||
centerNode: GraphNode;
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
stats: {
|
||||
totalNodes: number;
|
||||
totalEdges: number;
|
||||
maxDepth: number;
|
||||
};
|
||||
}
|
||||
35
apps/api/src/knowledge/entities/stats.entity.ts
Normal file
35
apps/api/src/knowledge/entities/stats.entity.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Knowledge base statistics
|
||||
*/
|
||||
export interface KnowledgeStats {
|
||||
overview: {
|
||||
totalEntries: number;
|
||||
totalTags: number;
|
||||
totalLinks: number;
|
||||
publishedEntries: number;
|
||||
draftEntries: number;
|
||||
archivedEntries: number;
|
||||
};
|
||||
mostConnected: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
incomingLinks: number;
|
||||
outgoingLinks: number;
|
||||
totalConnections: number;
|
||||
}>;
|
||||
recentActivity: Array<{
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
updatedAt: Date;
|
||||
status: string;
|
||||
}>;
|
||||
tagDistribution: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color: string | null;
|
||||
entryCount: number;
|
||||
}>;
|
||||
}
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { KnowledgeService } from "./knowledge.service";
|
||||
import { CreateEntryDto, UpdateEntryDto, EntryQueryDto } from "./dto";
|
||||
import { CreateEntryDto, UpdateEntryDto, EntryQueryDto, GraphQueryDto } from "./dto";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
import { LinkSyncService } from "./services/link-sync.service";
|
||||
import { LinkSyncService, GraphService } from "./services";
|
||||
|
||||
/**
|
||||
* Controller for knowledge entry endpoints
|
||||
@@ -27,7 +27,8 @@ import { LinkSyncService } from "./services/link-sync.service";
|
||||
export class KnowledgeController {
|
||||
constructor(
|
||||
private readonly knowledgeService: KnowledgeService,
|
||||
private readonly linkSync: LinkSyncService
|
||||
private readonly linkSync: LinkSyncService,
|
||||
private readonly graphService: GraphService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -132,4 +133,31 @@ export class KnowledgeController {
|
||||
count: backlinks.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/entries/:slug/graph
|
||||
* Get entry-centered graph view
|
||||
* Returns the entry and connected nodes with specified depth
|
||||
* Requires: Any workspace member
|
||||
*/
|
||||
@Get(":slug/graph")
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async getEntryGraph(
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("slug") slug: string,
|
||||
@Query() query: GraphQueryDto
|
||||
) {
|
||||
// Find the entry to get its ID
|
||||
const entry = await this.knowledgeService.findOne(workspaceId, slug);
|
||||
|
||||
// Get graph
|
||||
const graph = await this.graphService.getEntryGraph(
|
||||
workspaceId,
|
||||
entry.id,
|
||||
query.depth || 1
|
||||
);
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,13 +4,26 @@ import { AuthModule } from "../auth/auth.module";
|
||||
import { KnowledgeService } from "./knowledge.service";
|
||||
import { KnowledgeController } from "./knowledge.controller";
|
||||
import { SearchController } from "./search.controller";
|
||||
import { LinkResolutionService } from "./services/link-resolution.service";
|
||||
import { SearchService } from "./services/search.service";
|
||||
import { KnowledgeStatsController } from "./stats.controller";
|
||||
import {
|
||||
LinkResolutionService,
|
||||
SearchService,
|
||||
LinkSyncService,
|
||||
GraphService,
|
||||
StatsService,
|
||||
} from "./services";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [KnowledgeController, SearchController],
|
||||
providers: [KnowledgeService, LinkResolutionService, SearchService],
|
||||
controllers: [KnowledgeController, SearchController, KnowledgeStatsController],
|
||||
providers: [
|
||||
KnowledgeService,
|
||||
LinkResolutionService,
|
||||
SearchService,
|
||||
LinkSyncService,
|
||||
GraphService,
|
||||
StatsService,
|
||||
],
|
||||
exports: [KnowledgeService, LinkResolutionService, SearchService],
|
||||
})
|
||||
export class KnowledgeModule {}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
25
apps/api/src/knowledge/stats.controller.ts
Normal file
25
apps/api/src/knowledge/stats.controller.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, RequirePermission, Permission } from "../common/decorators";
|
||||
import { StatsService } from "./services";
|
||||
|
||||
/**
|
||||
* Controller for knowledge statistics endpoints
|
||||
*/
|
||||
@Controller("knowledge/stats")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class KnowledgeStatsController {
|
||||
constructor(private readonly statsService: StatsService) {}
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/stats
|
||||
* Get knowledge base statistics
|
||||
* Requires: Any workspace member
|
||||
*/
|
||||
@Get()
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async getStats(@Workspace() workspaceId: string) {
|
||||
return this.statsService.getStats(workspaceId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user