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:
2025-07-11 15:37:53 +02:00
committed by Kaj Kowalski
parent f2a3d87636
commit fa7e815a3b
38 changed files with 4285 additions and 518 deletions

23
server/routers/_app.ts Normal file
View 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
View 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
View 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
View 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,
};
}),
});