mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 12:12:09 +01:00
feat: complete tRPC integration and fix platform UI issues
- Implement comprehensive tRPC setup with type-safe API - Create tRPC routers for dashboard, admin, and auth endpoints - Migrate frontend components to use tRPC client - Fix platform dashboard Settings button functionality - Add platform settings page with profile and security management - Create OpenAI API mocking infrastructure for cost-safe testing - Update tests to work with new tRPC architecture - Sync database schema to fix AIBatchRequest table errors
This commit is contained in:
23
server/routers/_app.ts
Normal file
23
server/routers/_app.ts
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Main tRPC Application Router
|
||||
*
|
||||
* This file combines all individual routers into a single app router.
|
||||
* All tRPC endpoints are organized and exported from here.
|
||||
*/
|
||||
|
||||
import { router } from "@/lib/trpc";
|
||||
import { authRouter } from "./auth";
|
||||
import { dashboardRouter } from "./dashboard";
|
||||
import { adminRouter } from "./admin";
|
||||
|
||||
/**
|
||||
* Main application router that combines all feature routers
|
||||
*/
|
||||
export const appRouter = router({
|
||||
auth: authRouter,
|
||||
dashboard: dashboardRouter,
|
||||
admin: adminRouter,
|
||||
});
|
||||
|
||||
// Export type definition for use in client
|
||||
export type AppRouter = typeof appRouter;
|
||||
399
server/routers/admin.ts
Normal file
399
server/routers/admin.ts
Normal file
@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Admin tRPC Router
|
||||
*
|
||||
* Handles administrative operations:
|
||||
* - User management
|
||||
* - Company settings
|
||||
* - System administration
|
||||
*/
|
||||
|
||||
import { router, adminProcedure } from "@/lib/trpc";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { companySettingsSchema, userUpdateSchema } from "@/lib/validation";
|
||||
import { z } from "zod";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
export const adminRouter = router({
|
||||
/**
|
||||
* Get all users in the company
|
||||
*/
|
||||
getUsers: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
page: z.number().min(1).default(1),
|
||||
limit: z.number().min(1).max(100).default(20),
|
||||
search: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { page, limit, search } = input;
|
||||
|
||||
const where = {
|
||||
companyId: ctx.company!.id,
|
||||
...(search && {
|
||||
OR: [
|
||||
{ email: { contains: search, mode: "insensitive" as const } },
|
||||
// For role, search by exact enum match
|
||||
...(search.toUpperCase() === "ADMIN"
|
||||
? [{ role: "ADMIN" as const }]
|
||||
: []),
|
||||
...(search.toUpperCase() === "USER"
|
||||
? [{ role: "USER" as const }]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const [users, totalCount] = await Promise.all([
|
||||
ctx.prisma.user.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
name: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
ctx.prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
users,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalCount,
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
*/
|
||||
createUser: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(12),
|
||||
role: z.enum(["ADMIN", "USER", "AUDITOR"]),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { email, password, role } = input;
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await ctx.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "User with this email already exists",
|
||||
});
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
const user = await ctx.prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashedPassword,
|
||||
role,
|
||||
companyId: ctx.company!.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
message: "User created successfully",
|
||||
user,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update user details
|
||||
*/
|
||||
updateUser: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
updates: userUpdateSchema,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { userId, updates } = input;
|
||||
|
||||
// Verify user belongs to same company
|
||||
const targetUser = await ctx.prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
companyId: ctx.company!.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
|
||||
if (updates.email) {
|
||||
// Check if new email is already taken
|
||||
const existingUser = await ctx.prisma.user.findUnique({
|
||||
where: { email: updates.email },
|
||||
});
|
||||
|
||||
if (existingUser && existingUser.id !== userId) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Email is already taken",
|
||||
});
|
||||
}
|
||||
|
||||
updateData.email = updates.email;
|
||||
}
|
||||
|
||||
if (updates.password) {
|
||||
updateData.password = await bcrypt.hash(updates.password, 12);
|
||||
}
|
||||
|
||||
if (updates.role) {
|
||||
updateData.role = updates.role;
|
||||
}
|
||||
|
||||
const updatedUser = await ctx.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: updateData,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
message: "User updated successfully",
|
||||
user: updatedUser,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a user
|
||||
*/
|
||||
deleteUser: adminProcedure
|
||||
.input(z.object({ userId: z.string() }))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { userId } = input;
|
||||
|
||||
// Verify user belongs to same company
|
||||
const targetUser = await ctx.prisma.user.findFirst({
|
||||
where: {
|
||||
id: userId,
|
||||
companyId: ctx.company!.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent deleting the last admin
|
||||
if (targetUser.role === "ADMIN") {
|
||||
const adminCount = await ctx.prisma.user.count({
|
||||
where: {
|
||||
companyId: ctx.company!.id,
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
|
||||
if (adminCount <= 1) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Cannot delete the last admin user",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.prisma.user.delete({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
return {
|
||||
message: "User deleted successfully",
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get company settings
|
||||
*/
|
||||
getCompanySettings: adminProcedure.query(async ({ ctx }) => {
|
||||
const company = await ctx.prisma.company.findUnique({
|
||||
where: { id: ctx.company!.id },
|
||||
});
|
||||
|
||||
if (!company) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Company not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: company.id,
|
||||
name: company.name,
|
||||
csvUrl: company.csvUrl,
|
||||
csvUsername: company.csvUsername,
|
||||
dashboardOpts: company.dashboardOpts,
|
||||
status: company.status,
|
||||
maxUsers: company.maxUsers,
|
||||
createdAt: company.createdAt,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update company settings
|
||||
*/
|
||||
updateCompanySettings: adminProcedure
|
||||
.input(companySettingsSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const updateData: any = {
|
||||
name: input.name,
|
||||
csvUrl: input.csvUrl,
|
||||
};
|
||||
|
||||
if (input.csvUsername !== undefined) {
|
||||
updateData.csvUsername = input.csvUsername;
|
||||
}
|
||||
|
||||
if (input.csvPassword !== undefined) {
|
||||
updateData.csvPassword = input.csvPassword;
|
||||
}
|
||||
|
||||
if (input.sentimentAlert !== undefined) {
|
||||
updateData.sentimentAlert = input.sentimentAlert;
|
||||
}
|
||||
|
||||
if (input.dashboardOpts !== undefined) {
|
||||
updateData.dashboardOpts = input.dashboardOpts;
|
||||
}
|
||||
|
||||
const updatedCompany = await ctx.prisma.company.update({
|
||||
where: { id: ctx.company!.id },
|
||||
data: updateData,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
csvUrl: true,
|
||||
csvUsername: true,
|
||||
dashboardOpts: true,
|
||||
status: true,
|
||||
maxUsers: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
message: "Company settings updated successfully",
|
||||
company: updatedCompany,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get system statistics
|
||||
*/
|
||||
getSystemStats: adminProcedure.query(async ({ ctx }) => {
|
||||
const companyId = ctx.company!.id;
|
||||
|
||||
const [
|
||||
totalSessions,
|
||||
totalMessages,
|
||||
totalAIRequests,
|
||||
totalCost,
|
||||
userCount,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.session.count({
|
||||
where: { companyId },
|
||||
}),
|
||||
ctx.prisma.message.count({
|
||||
where: { session: { companyId } },
|
||||
}),
|
||||
ctx.prisma.aIProcessingRequest.count({
|
||||
where: { session: { companyId } },
|
||||
}),
|
||||
ctx.prisma.aIProcessingRequest.aggregate({
|
||||
where: { session: { companyId } },
|
||||
_sum: { totalCostEur: true },
|
||||
}),
|
||||
ctx.prisma.user.count({
|
||||
where: { companyId },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalSessions,
|
||||
totalMessages,
|
||||
totalAIRequests,
|
||||
totalCostEur: totalCost._sum.totalCostEur || 0,
|
||||
userCount,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Trigger session refresh/reprocessing
|
||||
*/
|
||||
refreshSessions: adminProcedure.mutation(async ({ ctx }) => {
|
||||
// Mark all sessions for reprocessing by clearing AI analysis results
|
||||
const updatedCount = await ctx.prisma.session.updateMany({
|
||||
where: {
|
||||
companyId: ctx.company!.id,
|
||||
sentiment: { not: null },
|
||||
},
|
||||
data: {
|
||||
sentiment: null,
|
||||
category: null,
|
||||
summary: null,
|
||||
language: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Clear related AI processing requests
|
||||
await ctx.prisma.aIProcessingRequest.deleteMany({
|
||||
where: {
|
||||
session: {
|
||||
companyId: ctx.company!.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Clear session questions
|
||||
await ctx.prisma.sessionQuestion.deleteMany({
|
||||
where: {
|
||||
session: {
|
||||
companyId: ctx.company!.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
message: `Marked ${updatedCount.count} sessions for reprocessing`,
|
||||
sessionsMarked: updatedCount.count,
|
||||
};
|
||||
}),
|
||||
});
|
||||
328
server/routers/auth.ts
Normal file
328
server/routers/auth.ts
Normal file
@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Authentication tRPC Router
|
||||
*
|
||||
* Handles user authentication operations:
|
||||
* - User registration
|
||||
* - Login validation
|
||||
* - Password reset requests
|
||||
* - User profile management
|
||||
*/
|
||||
|
||||
import {
|
||||
router,
|
||||
publicProcedure,
|
||||
protectedProcedure,
|
||||
rateLimitedProcedure,
|
||||
} from "@/lib/trpc";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import {
|
||||
registerSchema,
|
||||
loginSchema,
|
||||
forgotPasswordSchema,
|
||||
userUpdateSchema,
|
||||
} from "@/lib/validation";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const authRouter = router({
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
register: rateLimitedProcedure
|
||||
.input(registerSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { email, password, company: companyName } = input;
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await ctx.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "User with this email already exists",
|
||||
});
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
// Create or find company
|
||||
let company = await ctx.prisma.company.findFirst({
|
||||
where: {
|
||||
name: {
|
||||
equals: companyName,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!company) {
|
||||
company = await ctx.prisma.company.create({
|
||||
data: {
|
||||
name: companyName,
|
||||
status: "ACTIVE",
|
||||
csvUrl: `https://placeholder-${companyName.toLowerCase().replace(/\s+/g, "-")}.example.com/api/sessions.csv`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create user
|
||||
const user = await ctx.prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashedPassword,
|
||||
companyId: company.id,
|
||||
role: "ADMIN", // First user is admin
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true,
|
||||
company: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
message: "User registered successfully",
|
||||
user,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Validate login credentials
|
||||
*/
|
||||
validateLogin: publicProcedure
|
||||
.input(loginSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { email, password } = input;
|
||||
|
||||
const user = await ctx.prisma.user.findUnique({
|
||||
where: { email },
|
||||
include: {
|
||||
company: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !(await bcrypt.compare(password, user.password))) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid email or password",
|
||||
});
|
||||
}
|
||||
|
||||
if (user.company?.status !== "ACTIVE") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Company account is not active",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
company: user.company,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Request password reset
|
||||
*/
|
||||
forgotPassword: rateLimitedProcedure
|
||||
.input(forgotPasswordSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { email } = input;
|
||||
|
||||
const user = await ctx.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// Don't reveal if email exists or not
|
||||
return {
|
||||
message:
|
||||
"If an account with that email exists, you will receive a password reset link.",
|
||||
};
|
||||
}
|
||||
|
||||
// Generate reset token (in real implementation, this would be a secure token)
|
||||
const resetToken = Math.random().toString(36).substring(2, 15);
|
||||
const resetTokenExpiry = new Date(Date.now() + 3600000); // 1 hour
|
||||
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
resetToken,
|
||||
resetTokenExpiry,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Send email with reset link
|
||||
// For now, just log the token (remove in production)
|
||||
console.log(`Password reset token for ${email}: ${resetToken}`);
|
||||
|
||||
return {
|
||||
message:
|
||||
"If an account with that email exists, you will receive a password reset link.",
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get current user profile
|
||||
*/
|
||||
getProfile: protectedProcedure.query(async ({ ctx }) => {
|
||||
const user = await ctx.prisma.user.findUnique({
|
||||
where: { email: ctx.session.user.email! },
|
||||
include: {
|
||||
company: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
createdAt: user.createdAt,
|
||||
company: user.company,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
*/
|
||||
updateProfile: protectedProcedure
|
||||
.input(userUpdateSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const updateData: any = {};
|
||||
|
||||
if (input.email) {
|
||||
// Check if new email is already taken
|
||||
const existingUser = await ctx.prisma.user.findUnique({
|
||||
where: { email: input.email },
|
||||
});
|
||||
|
||||
if (existingUser && existingUser.email !== ctx.session.user.email) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Email is already taken",
|
||||
});
|
||||
}
|
||||
|
||||
updateData.email = input.email;
|
||||
}
|
||||
|
||||
if (input.password) {
|
||||
updateData.password = await bcrypt.hash(input.password, 12);
|
||||
}
|
||||
|
||||
if (input.role) {
|
||||
// Only admins can change roles
|
||||
const currentUser = await ctx.prisma.user.findUnique({
|
||||
where: { email: ctx.session.user.email! },
|
||||
});
|
||||
|
||||
if (currentUser?.role !== "ADMIN") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only admins can change user roles",
|
||||
});
|
||||
}
|
||||
|
||||
updateData.role = input.role;
|
||||
}
|
||||
|
||||
const updatedUser = await ctx.prisma.user.update({
|
||||
where: { email: ctx.session.user.email! },
|
||||
data: updateData,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
role: true,
|
||||
company: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
message: "Profile updated successfully",
|
||||
user: updatedUser,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* Reset password with token
|
||||
*/
|
||||
resetPassword: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
token: z.string().min(1, "Reset token is required"),
|
||||
password: registerSchema.shape.password,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { token, password } = input;
|
||||
|
||||
const user = await ctx.prisma.user.findFirst({
|
||||
where: {
|
||||
resetToken: token,
|
||||
resetTokenExpiry: {
|
||||
gt: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid or expired reset token",
|
||||
});
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
resetToken: null,
|
||||
resetTokenExpiry: null,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
message: "Password reset successfully",
|
||||
};
|
||||
}),
|
||||
});
|
||||
411
server/routers/dashboard.ts
Normal file
411
server/routers/dashboard.ts
Normal file
@ -0,0 +1,411 @@
|
||||
/**
|
||||
* 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: {
|
||||
messages: {
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
content: true,
|
||||
order: 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) => ({
|
||||
...session,
|
||||
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: {
|
||||
messages: {
|
||||
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 {
|
||||
...session,
|
||||
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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user