import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc' import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email' import { hashPassword, validatePassword } from '@/lib/password' export const userRouter = router({ /** * Get current user profile */ me: protectedProcedure.query(async ({ ctx }) => { return ctx.prisma.user.findUniqueOrThrow({ where: { id: ctx.user.id }, select: { id: true, email: true, name: true, role: true, status: true, expertiseTags: true, createdAt: true, lastLoginAt: true, }, }) }), /** * Update current user profile */ updateProfile: protectedProcedure .input( z.object({ name: z.string().min(1).max(255).optional(), }) ) .mutation(async ({ ctx, input }) => { return ctx.prisma.user.update({ where: { id: ctx.user.id }, data: input, }) }), /** * List all users (admin only) */ list: adminProcedure .input( z.object({ role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(), status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(), search: z.string().optional(), page: z.number().int().min(1).default(1), perPage: z.number().int().min(1).max(100).default(20), }) ) .query(async ({ ctx, input }) => { const { role, status, search, page, perPage } = input const skip = (page - 1) * perPage const where: Record = {} if (role) where.role = role if (status) where.status = status if (search) { where.OR = [ { email: { contains: search, mode: 'insensitive' } }, { name: { contains: search, mode: 'insensitive' } }, ] } const [users, total] = await Promise.all([ ctx.prisma.user.findMany({ where, skip, take: perPage, orderBy: { createdAt: 'desc' }, select: { id: true, email: true, name: true, role: true, status: true, expertiseTags: true, maxAssignments: true, createdAt: true, lastLoginAt: true, _count: { select: { assignments: true }, }, }, }), ctx.prisma.user.count({ where }), ]) return { users, total, page, perPage, totalPages: Math.ceil(total / perPage), } }), /** * Get a single user (admin only) */ get: adminProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.prisma.user.findUniqueOrThrow({ where: { id: input.id }, include: { _count: { select: { assignments: true }, }, }, }) }), /** * Create/invite a new user (admin only) */ create: adminProcedure .input( z.object({ email: z.string().email(), name: z.string().optional(), role: z.enum(['PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'), expertiseTags: z.array(z.string()).optional(), maxAssignments: z.number().int().min(1).max(100).optional(), }) ) .mutation(async ({ ctx, input }) => { // Check if user already exists const existing = await ctx.prisma.user.findUnique({ where: { email: input.email }, }) if (existing) { throw new TRPCError({ code: 'CONFLICT', message: 'A user with this email already exists', }) } // Prevent non-super-admins from creating admins if (input.role === 'PROGRAM_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only super admins can create program admins', }) } const user = await ctx.prisma.user.create({ data: { ...input, status: 'INVITED', }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'CREATE', entityType: 'User', entityId: user.id, detailsJson: { email: input.email, role: input.role }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return user }), /** * Update a user (admin only) */ update: adminProcedure .input( z.object({ id: z.string(), name: z.string().optional().nullable(), role: z.enum(['PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(), status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(), expertiseTags: z.array(z.string()).optional(), maxAssignments: z.number().int().min(1).max(100).optional().nullable(), }) ) .mutation(async ({ ctx, input }) => { const { id, ...data } = input // Prevent changing super admin role const targetUser = await ctx.prisma.user.findUniqueOrThrow({ where: { id }, }) if (targetUser.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Cannot modify super admin', }) } // Prevent non-super-admins from assigning admin role if (data.role === 'PROGRAM_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only super admins can assign admin role', }) } const user = await ctx.prisma.user.update({ where: { id }, data, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'UPDATE', entityType: 'User', entityId: id, detailsJson: data, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return user }), /** * Delete a user (super admin only) */ delete: superAdminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { // Prevent self-deletion if (input.id === ctx.user.id) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot delete yourself', }) } const user = await ctx.prisma.user.delete({ where: { id: input.id }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'DELETE', entityType: 'User', entityId: input.id, detailsJson: { email: user.email }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return user }), /** * Bulk import users (admin only) */ bulkCreate: adminProcedure .input( z.object({ users: z.array( z.object({ email: z.string().email(), name: z.string().optional(), role: z.enum(['JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'), expertiseTags: z.array(z.string()).optional(), }) ), }) ) .mutation(async ({ ctx, input }) => { // Deduplicate input by email (keep first occurrence) const seenEmails = new Set() const uniqueUsers = input.users.filter((u) => { const email = u.email.toLowerCase() if (seenEmails.has(email)) return false seenEmails.add(email) return true }) // Get existing emails from database const existingUsers = await ctx.prisma.user.findMany({ where: { email: { in: uniqueUsers.map((u) => u.email.toLowerCase()) } }, select: { email: true }, }) const existingEmails = new Set(existingUsers.map((u) => u.email.toLowerCase())) // Filter out existing users const newUsers = uniqueUsers.filter((u) => !existingEmails.has(u.email.toLowerCase())) const duplicatesInInput = input.users.length - uniqueUsers.length const skipped = existingEmails.size + duplicatesInInput if (newUsers.length === 0) { return { created: 0, skipped } } const created = await ctx.prisma.user.createMany({ data: newUsers.map((u) => ({ ...u, email: u.email.toLowerCase(), status: 'INVITED', })), }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'BULK_CREATE', entityType: 'User', detailsJson: { count: created.count, skipped, duplicatesInInput }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return { created: created.count, skipped } }), /** * Get jury members for assignment */ getJuryMembers: adminProcedure .input( z.object({ roundId: z.string().optional(), search: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const where: Record = { role: 'JURY_MEMBER', status: 'ACTIVE', } if (input.search) { where.OR = [ { email: { contains: input.search, mode: 'insensitive' } }, { name: { contains: input.search, mode: 'insensitive' } }, ] } const users = await ctx.prisma.user.findMany({ where, select: { id: true, email: true, name: true, expertiseTags: true, maxAssignments: true, _count: { select: { assignments: input.roundId ? { where: { roundId: input.roundId } } : true, }, }, }, orderBy: { name: 'asc' }, }) return users.map((u) => ({ ...u, currentAssignments: u._count.assignments, availableSlots: u.maxAssignments !== null ? Math.max(0, u.maxAssignments - u._count.assignments) : null, })) }), /** * Send invitation email to a user */ sendInvitation: adminProcedure .input(z.object({ userId: z.string() })) .mutation(async ({ ctx, input }) => { const user = await ctx.prisma.user.findUniqueOrThrow({ where: { id: input.userId }, }) if (user.status !== 'INVITED') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'User has already accepted their invitation', }) } // Generate magic link URL const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' const inviteUrl = `${baseUrl}/api/auth/signin?email=${encodeURIComponent(user.email)}` // Send invitation email await sendInvitationEmail(user.email, user.name, inviteUrl, user.role) // Log notification await ctx.prisma.notificationLog.create({ data: { userId: user.id, channel: 'EMAIL', provider: 'SMTP', type: 'JURY_INVITATION', status: 'SENT', }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'SEND_INVITATION', entityType: 'User', entityId: user.id, detailsJson: { email: user.email }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return { success: true, email: user.email } }), /** * Send invitation emails to multiple users */ bulkSendInvitations: adminProcedure .input(z.object({ userIds: z.array(z.string()) })) .mutation(async ({ ctx, input }) => { const users = await ctx.prisma.user.findMany({ where: { id: { in: input.userIds }, status: 'INVITED', }, }) if (users.length === 0) { return { sent: 0, skipped: input.userIds.length } } const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' let sent = 0 const errors: string[] = [] for (const user of users) { try { const inviteUrl = `${baseUrl}/api/auth/signin?email=${encodeURIComponent(user.email)}` await sendInvitationEmail(user.email, user.name, inviteUrl, user.role) await ctx.prisma.notificationLog.create({ data: { userId: user.id, channel: 'EMAIL', provider: 'SMTP', type: 'JURY_INVITATION', status: 'SENT', }, }) sent++ } catch (e) { errors.push(user.email) await ctx.prisma.notificationLog.create({ data: { userId: user.id, channel: 'EMAIL', provider: 'SMTP', type: 'JURY_INVITATION', status: 'FAILED', errorMsg: e instanceof Error ? e.message : 'Unknown error', }, }) } } // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'BULK_SEND_INVITATIONS', entityType: 'User', detailsJson: { sent, errors }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return { sent, skipped: input.userIds.length - users.length, errors } }), /** * Complete onboarding for current user */ completeOnboarding: protectedProcedure .input( z.object({ name: z.string().min(1).max(255), phoneNumber: z.string().optional(), expertiseTags: z.array(z.string()).optional(), notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(), }) ) .mutation(async ({ ctx, input }) => { const user = await ctx.prisma.user.update({ where: { id: ctx.user.id }, data: { name: input.name, phoneNumber: input.phoneNumber, expertiseTags: input.expertiseTags || [], notificationPreference: input.notificationPreference || 'EMAIL', onboardingCompletedAt: new Date(), status: 'ACTIVE', // Activate user after onboarding }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'COMPLETE_ONBOARDING', entityType: 'User', entityId: ctx.user.id, detailsJson: { name: input.name }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return user }), /** * Check if current user needs onboarding */ needsOnboarding: protectedProcedure.query(async ({ ctx }) => { const user = await ctx.prisma.user.findUniqueOrThrow({ where: { id: ctx.user.id }, select: { onboardingCompletedAt: true, role: true }, }) // Only jury members need onboarding if (user.role !== 'JURY_MEMBER') { return false } return user.onboardingCompletedAt === null }), /** * Check if current user needs to set a password */ needsPasswordSetup: protectedProcedure.query(async ({ ctx }) => { const user = await ctx.prisma.user.findUniqueOrThrow({ where: { id: ctx.user.id }, select: { mustSetPassword: true, passwordHash: true }, }) return user.mustSetPassword || user.passwordHash === null }), /** * Set password for current user */ setPassword: protectedProcedure .input( z.object({ password: z.string().min(8), confirmPassword: z.string().min(8), }) ) .mutation(async ({ ctx, input }) => { // Validate passwords match if (input.password !== input.confirmPassword) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Passwords do not match', }) } // Validate password requirements const validation = validatePassword(input.password) if (!validation.valid) { throw new TRPCError({ code: 'BAD_REQUEST', message: validation.errors.join('. '), }) } // Hash the password const passwordHash = await hashPassword(input.password) // Update user with new password const user = await ctx.prisma.user.update({ where: { id: ctx.user.id }, data: { passwordHash, passwordSetAt: new Date(), mustSetPassword: false, }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'SET_PASSWORD', entityType: 'User', entityId: ctx.user.id, detailsJson: { timestamp: new Date().toISOString() }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return { success: true, email: user.email } }), /** * Change password for current user (requires current password) */ changePassword: protectedProcedure .input( z.object({ currentPassword: z.string().min(1), newPassword: z.string().min(8), confirmNewPassword: z.string().min(8), }) ) .mutation(async ({ ctx, input }) => { // Get current user with password hash const user = await ctx.prisma.user.findUniqueOrThrow({ where: { id: ctx.user.id }, select: { passwordHash: true }, }) if (!user.passwordHash) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No password set. Please use magic link to sign in.', }) } // Verify current password const { verifyPassword } = await import('@/lib/password') const isValid = await verifyPassword(input.currentPassword, user.passwordHash) if (!isValid) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Current password is incorrect', }) } // Validate new passwords match if (input.newPassword !== input.confirmNewPassword) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'New passwords do not match', }) } // Validate new password requirements const validation = validatePassword(input.newPassword) if (!validation.valid) { throw new TRPCError({ code: 'BAD_REQUEST', message: validation.errors.join('. '), }) } // Hash the new password const passwordHash = await hashPassword(input.newPassword) // Update user with new password await ctx.prisma.user.update({ where: { id: ctx.user.id }, data: { passwordHash, passwordSetAt: new Date(), }, }) // Audit log await ctx.prisma.auditLog.create({ data: { userId: ctx.user.id, action: 'CHANGE_PASSWORD', entityType: 'User', entityId: ctx.user.id, detailsJson: { timestamp: new Date().toISOString() }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return { success: true } }), /** * Request password reset (public - no auth required) * Sends a magic link and marks user for password reset */ requestPasswordReset: publicProcedure .input(z.object({ email: z.string().email() })) .mutation(async ({ ctx, input }) => { // Find user by email const user = await ctx.prisma.user.findUnique({ where: { email: input.email }, select: { id: true, email: true, status: true }, }) // Always return success to prevent email enumeration if (!user || user.status === 'SUSPENDED') { return { success: true, message: 'If an account exists with this email, a password reset link will be sent.' } } // Mark user for password reset await ctx.prisma.user.update({ where: { id: user.id }, data: { mustSetPassword: true }, }) // Generate a callback URL for the magic link const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' const callbackUrl = `${baseUrl}/set-password` // We don't send the email here - the user will use the magic link form // This just marks them for password reset // The actual email is sent through NextAuth's email provider // Audit log (without user ID since this is public) await ctx.prisma.auditLog.create({ data: { userId: null, // No authenticated user action: 'REQUEST_PASSWORD_RESET', entityType: 'User', entityId: user.id, detailsJson: { email: input.email, timestamp: new Date().toISOString() }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }, }) return { success: true, message: 'If an account exists with this email, a password reset link will be sent.' } }), })