Files
stack/apps/api/src/knowledge/services/stats.service.ts
Jason Woltje 26a334c677 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
2026-01-29 23:25:29 -06:00

170 lines
3.8 KiB
TypeScript

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