Files
livedash-node/server/routers/dashboard.ts
Kaj Kowalski 041a1cc3ef feat: add repository pattern, service layer architecture, and scheduler management
- Implement repository pattern for data access layer
- Add comprehensive service layer for business logic
- Create scheduler management system with health monitoring
- Add bounded buffer utility for memory management
- Enhance security audit logging with retention policies
2025-07-13 11:52:53 +02:00

476 lines
13 KiB
TypeScript

/**
* Dashboard tRPC Router
*
* Handles dashboard data operations:
* - Session management and filtering
* - Analytics and metrics
* - Overview statistics
* - Question management
*/
import { router, companyProcedure } from "@/lib/trpc";
import { TRPCError } from "@trpc/server";
import { sessionFilterSchema, metricsQuerySchema } from "@/lib/validation";
import { z } from "zod";
import { Prisma } from "@prisma/client";
export const dashboardRouter = router({
/**
* Get paginated sessions with filtering
*/
getSessions: companyProcedure
.input(sessionFilterSchema)
.query(async ({ input, ctx }) => {
const { search, sentiment, category, startDate, endDate, page, limit } =
input;
// Build where clause
const where: Prisma.SessionWhereInput = {
companyId: ctx.company.id,
};
if (search) {
where.OR = [
{ summary: { contains: search, mode: "insensitive" } },
{ id: { contains: search, mode: "insensitive" } },
];
}
if (sentiment) {
where.sentiment = sentiment;
}
if (category) {
where.category = category;
}
if (startDate || endDate) {
where.startTime = {};
if (startDate) {
where.startTime.gte = new Date(startDate);
}
if (endDate) {
where.startTime.lte = new Date(endDate);
}
}
// Get total count
const totalCount = await ctx.prisma.session.count({ where });
// Get paginated sessions
const sessions = await ctx.prisma.session.findMany({
where,
include: {
import: {
select: {
externalSessionId: true,
},
},
messages: {
select: {
id: true,
sessionId: true,
role: true,
content: true,
order: true,
timestamp: true,
createdAt: true,
},
orderBy: { order: "asc" },
},
sessionQuestions: {
include: {
question: {
select: {
content: true,
},
},
},
orderBy: { order: "asc" },
},
},
orderBy: { startTime: "desc" },
skip: (page - 1) * limit,
take: limit,
});
return {
sessions: sessions.map((session) => ({
id: session.id,
sessionId: session.import?.externalSessionId || session.id,
companyId: session.companyId,
userId: session.userId,
category: session.category,
language: session.language,
country: session.country,
ipAddress: session.ipAddress,
sentiment: session.sentiment,
messagesSent: session.messagesSent ?? undefined,
startTime: session.startTime,
endTime: session.endTime,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
avgResponseTime: session.avgResponseTime,
escalated: session.escalated ?? undefined,
forwardedHr: session.forwardedHr ?? undefined,
initialMsg: session.initialMsg ?? undefined,
fullTranscriptUrl: session.fullTranscriptUrl ?? undefined,
summary: session.summary ?? undefined,
messages: session.messages,
transcriptContent: null,
questions: session.sessionQuestions.map((sq) => sq.question.content),
})),
pagination: {
page,
limit,
totalCount,
totalPages: Math.ceil(totalCount / limit),
},
};
}),
/**
* Get session by ID
*/
getSessionById: companyProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ input, ctx }) => {
const session = await ctx.prisma.session.findFirst({
where: {
id: input.sessionId,
companyId: ctx.company.id,
},
include: {
import: {
select: {
externalSessionId: true,
},
},
messages: {
select: {
id: true,
sessionId: true,
role: true,
content: true,
order: true,
timestamp: true,
createdAt: true,
},
orderBy: { order: "asc" },
},
sessionQuestions: {
include: {
question: {
select: {
content: true,
},
},
},
orderBy: { order: "asc" },
},
},
});
if (!session) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Session not found",
});
}
return {
id: session.id,
sessionId: session.import?.externalSessionId || session.id,
companyId: session.companyId,
userId: session.userId,
category: session.category,
language: session.language,
country: session.country,
ipAddress: session.ipAddress,
sentiment: session.sentiment,
messagesSent: session.messagesSent ?? undefined,
startTime: session.startTime,
endTime: session.endTime,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
avgResponseTime: session.avgResponseTime,
escalated: session.escalated ?? undefined,
forwardedHr: session.forwardedHr ?? undefined,
initialMsg: session.initialMsg ?? undefined,
fullTranscriptUrl: session.fullTranscriptUrl ?? undefined,
summary: session.summary ?? undefined,
messages: session.messages,
transcriptContent: null,
questions: session.sessionQuestions.map((sq) => sq.question.content),
};
}),
/**
* Get dashboard overview statistics
*/
getOverview: companyProcedure
.input(
z.object({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
})
)
.query(async ({ input, ctx }) => {
const { startDate, endDate } = input;
const dateFilter: Prisma.SessionWhereInput = {
companyId: ctx.company.id,
};
if (startDate || endDate) {
dateFilter.startTime = {};
if (startDate) {
dateFilter.startTime.gte = new Date(startDate);
}
if (endDate) {
dateFilter.startTime.lte = new Date(endDate);
}
}
// Get basic counts
const [
totalSessions,
avgMessagesSent,
sentimentDistribution,
categoryDistribution,
] = await Promise.all([
// Total sessions
ctx.prisma.session.count({ where: dateFilter }),
// Average messages sent
ctx.prisma.session.aggregate({
where: dateFilter,
_avg: { messagesSent: true },
}),
// Sentiment distribution
ctx.prisma.session.groupBy({
by: ["sentiment"],
where: dateFilter,
_count: true,
}),
// Category distribution
ctx.prisma.session.groupBy({
by: ["category"],
where: dateFilter,
_count: true,
}),
]);
return {
totalSessions,
avgMessagesSent: avgMessagesSent._avg.messagesSent || 0,
sentimentDistribution: sentimentDistribution.map((item) => ({
sentiment: item.sentiment,
count: item._count,
})),
categoryDistribution: categoryDistribution.map((item) => ({
category: item.category,
count: item._count,
})),
};
}),
/**
* Get top questions
*/
getTopQuestions: companyProcedure
.input(
z.object({
limit: z.number().min(1).max(20).default(10),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
})
)
.query(async ({ input, ctx }) => {
const { limit, startDate, endDate } = input;
const dateFilter: Prisma.SessionWhereInput = {
companyId: ctx.company.id,
};
if (startDate || endDate) {
dateFilter.startTime = {};
if (startDate) {
dateFilter.startTime.gte = new Date(startDate);
}
if (endDate) {
dateFilter.startTime.lte = new Date(endDate);
}
}
const topQuestions = await ctx.prisma.question.findMany({
select: {
content: true,
_count: {
select: {
sessionQuestions: {
where: {
session: dateFilter,
},
},
},
},
},
orderBy: {
sessionQuestions: {
_count: "desc",
},
},
take: limit,
});
return topQuestions.map((question) => ({
question: question.content,
count: question._count.sessionQuestions,
}));
}),
/**
* Get geographic distribution of sessions
*/
getGeographicDistribution: companyProcedure
.input(
z.object({
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
})
)
.query(async ({ input, ctx }) => {
const { startDate, endDate } = input;
const dateFilter: Prisma.SessionWhereInput = {
companyId: ctx.company.id,
};
if (startDate || endDate) {
dateFilter.startTime = {};
if (startDate) {
dateFilter.startTime.gte = new Date(startDate);
}
if (endDate) {
dateFilter.startTime.lte = new Date(endDate);
}
}
const geoDistribution = await ctx.prisma.session.groupBy({
by: ["language"],
where: dateFilter,
_count: true,
});
// Map language codes to country data (simplified mapping)
const languageToCountry: Record<
string,
{ name: string; lat: number; lng: number }
> = {
en: { name: "United Kingdom", lat: 55.3781, lng: -3.436 },
de: { name: "Germany", lat: 51.1657, lng: 10.4515 },
fr: { name: "France", lat: 46.2276, lng: 2.2137 },
es: { name: "Spain", lat: 40.4637, lng: -3.7492 },
nl: { name: "Netherlands", lat: 52.1326, lng: 5.2913 },
it: { name: "Italy", lat: 41.8719, lng: 12.5674 },
};
return geoDistribution.map((item) => ({
language: item.language,
count: item._count,
country: (item.language ? languageToCountry[item.language] : null) || {
name: "Unknown",
lat: 0,
lng: 0,
},
}));
}),
/**
* Get AI processing metrics
*/
getAIMetrics: companyProcedure
.input(metricsQuerySchema)
.query(async ({ input, ctx }) => {
const { startDate, endDate } = input;
const dateFilter: Prisma.AIProcessingRequestWhereInput = {
session: {
companyId: ctx.company.id,
},
};
if (startDate || endDate) {
dateFilter.requestedAt = {};
if (startDate) {
dateFilter.requestedAt.gte = new Date(startDate);
}
if (endDate) {
dateFilter.requestedAt.lte = new Date(endDate);
}
}
const [totalCosts, requestStats] = await Promise.all([
// Total AI costs
ctx.prisma.aIProcessingRequest.aggregate({
where: dateFilter,
_sum: {
totalCostEur: true,
promptTokens: true,
completionTokens: true,
},
_count: true,
}),
// Success/failure stats
ctx.prisma.aIProcessingRequest.groupBy({
by: ["success"],
where: dateFilter,
_count: true,
}),
]);
return {
totalCostEur: totalCosts._sum.totalCostEur || 0,
totalRequests: totalCosts._count,
totalTokens:
(totalCosts._sum.promptTokens || 0) +
(totalCosts._sum.completionTokens || 0),
successRate: requestStats.reduce(
(acc, stat) => {
if (stat.success) {
acc.successful = stat._count;
} else {
acc.failed = stat._count;
}
return acc;
},
{ successful: 0, failed: 0 }
),
};
}),
/**
* Refresh sessions (trigger reprocessing)
*/
refreshSessions: companyProcedure.mutation(async ({ ctx }) => {
// This would trigger the processing pipeline
// For now, just return a success message
const pendingSessions = await ctx.prisma.session.count({
where: {
companyId: ctx.company.id,
sentiment: null, // Sessions that haven't been processed
},
});
return {
message: `Found ${pendingSessions} sessions that need processing`,
pendingSessions,
};
}),
});