import { z } from 'zod' import { TRPCError } from '@trpc/server' import type { Prisma } from '@prisma/client' import { UserRole } from '@prisma/client' import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc' import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email' import { hashPassword, validatePassword } from '@/lib/password' import { attachAvatarUrls } from '@/server/utils/avatar-url' import { logAudit } from '@/server/utils/audit' import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite' export const userRouter = router({ /** * Get current user profile */ me: protectedProcedure.query(async ({ ctx }) => { const user = await ctx.prisma.user.findUnique({ where: { id: ctx.user.id }, select: { id: true, email: true, name: true, role: true, status: true, expertiseTags: true, metadataJson: true, phoneNumber: true, country: true, nationality: true, institution: true, bio: true, notificationPreference: true, profileImageKey: true, digestFrequency: true, availabilityJson: true, preferredWorkload: true, createdAt: true, lastLoginAt: true, }, }) if (!user) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User session is stale. Please log out and log back in.', }) } return user }), /** * Validate an invitation token (public, no auth required) */ validateInviteToken: publicProcedure .input(z.object({ token: z.string().min(1) })) .query(async ({ ctx, input }) => { const user = await ctx.prisma.user.findUnique({ where: { inviteToken: input.token }, select: { id: true, name: true, email: true, role: true, status: true, inviteTokenExpiresAt: true }, }) if (!user) { return { valid: false, error: 'INVALID_TOKEN' as const } } if (user.status !== 'INVITED') { return { valid: false, error: 'ALREADY_ACCEPTED' as const } } if (user.inviteTokenExpiresAt && user.inviteTokenExpiresAt < new Date()) { return { valid: false, error: 'EXPIRED_TOKEN' as const } } // Check if user belongs to a team (was invited as team member) const teamMembership = await ctx.prisma.teamMember.findFirst({ where: { userId: user.id }, select: { role: true, project: { select: { title: true, teamName: true } }, }, }) return { valid: true, user: { name: user.name, email: user.email, role: user.role }, team: teamMembership ? { projectTitle: teamMembership.project.title, teamName: teamMembership.project.teamName } : null, } }), /** * Update current user profile */ updateProfile: protectedProcedure .input( z.object({ email: z.string().email().optional(), name: z.string().min(1).max(255).optional(), bio: z.string().max(1000).optional(), phoneNumber: z.string().max(20).optional().nullable(), nationality: z.string().max(100).optional().nullable(), institution: z.string().max(255).optional().nullable(), country: z.string().max(100).optional(), notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(), expertiseTags: z.array(z.string()).max(15).optional(), digestFrequency: z.enum(['none', 'daily', 'weekly']).optional(), availabilityJson: z.any().optional(), preferredWorkload: z.number().int().min(1).max(100).optional().nullable(), }) ) .mutation(async ({ ctx, input }) => { const { bio, expertiseTags, availabilityJson, preferredWorkload, digestFrequency, email, ...directFields } = input const normalizedEmail = email?.toLowerCase().trim() if (normalizedEmail !== undefined) { const existing = await ctx.prisma.user.findFirst({ where: { email: normalizedEmail, NOT: { id: ctx.user.id }, }, select: { id: true }, }) if (existing) { throw new TRPCError({ code: 'CONFLICT', message: 'Another account already uses this email address', }) } } // If bio is provided, merge it into metadataJson let metadataJson: Prisma.InputJsonValue | undefined if (bio !== undefined) { const currentUser = await ctx.prisma.user.findUniqueOrThrow({ where: { id: ctx.user.id }, select: { metadataJson: true }, }) const currentMeta = (currentUser.metadataJson as Record) || {} metadataJson = { ...currentMeta, bio } as Prisma.InputJsonValue } return ctx.prisma.user.update({ where: { id: ctx.user.id }, data: { ...directFields, ...(normalizedEmail !== undefined && { email: normalizedEmail }), ...(metadataJson !== undefined && { metadataJson }), ...(expertiseTags !== undefined && { expertiseTags }), ...(digestFrequency !== undefined && { digestFrequency }), ...(availabilityJson !== undefined && { availabilityJson: availabilityJson as Prisma.InputJsonValue }), ...(preferredWorkload !== undefined && { preferredWorkload }), }, }) }), /** * Delete own account (requires password confirmation) */ deleteAccount: protectedProcedure .input( z.object({ password: z.string().min(1), }) ) .mutation(async ({ ctx, input }) => { // Get current user with password hash const user = await ctx.prisma.user.findUniqueOrThrow({ where: { id: ctx.user.id }, select: { id: true, email: true, passwordHash: true, role: true }, }) // Prevent super admins from self-deleting if (user.role === 'SUPER_ADMIN') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Super admins cannot delete their own account', }) } if (!user.passwordHash) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'No password set. Please set a password first.', }) } // Verify password const { verifyPassword } = await import('@/lib/password') const isValid = await verifyPassword(input.password, user.passwordHash) if (!isValid) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Password is incorrect', }) } // Delete user await ctx.prisma.user.delete({ where: { id: ctx.user.id }, }) // Audit outside transaction so failures don't roll back the deletion await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'DELETE_OWN_ACCOUNT', entityType: 'User', entityId: ctx.user.id, detailsJson: { email: user.email }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { success: true } }), /** * List all users (admin only) */ list: adminProcedure .input( z.object({ role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(), roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(), status: z.enum(['NONE', '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, roles, status, search, page, perPage } = input const skip = (page - 1) * perPage const where: Record = {} if (roles && roles.length > 0) { where.role = { in: roles } } else 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, roles: true, status: true, expertiseTags: true, maxAssignments: true, availabilityJson: true, preferredWorkload: true, profileImageKey: true, profileImageProvider: true, createdAt: true, lastLoginAt: true, _count: { select: { assignments: true, mentorAssignments: true }, }, }, }), ctx.prisma.user.count({ where }), ]) const usersWithAvatars = await attachAvatarUrls(users) return { users: usersWithAvatars, total, page, perPage, totalPages: Math.ceil(total / perPage), } }), /** * List all invitable user IDs for current filters (not paginated) */ listInvitableIds: adminProcedure .input( z.object({ role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(), roles: z.array(z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'])).optional(), search: z.string().optional(), }) ) .query(async ({ ctx, input }) => { const where: Record = { status: { in: ['NONE', 'INVITED'] }, } if (input.roles && input.roles.length > 0) { where.role = { in: input.roles } } else if (input.role) { where.role = input.role } 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 }, }) return { userIds: users.map((u) => u.id), total: users.length, } }), /** * Get a single user (admin only) */ get: adminProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { const user = await ctx.prisma.user.findUniqueOrThrow({ where: { id: input.id }, include: { _count: { select: { assignments: true, mentorAssignments: true }, }, }, }) return user }), /** * Resolve a batch of user IDs to names (admin only). * Returns a map of id → name for displaying in audit logs, etc. */ resolveNames: adminProcedure .input(z.object({ ids: z.array(z.string()).max(50) })) .query(async ({ ctx, input }) => { if (input.ids.length === 0) return {} const users = await ctx.prisma.user.findMany({ where: { id: { in: input.ids } }, select: { id: true, name: true, email: true }, }) const map: Record = {} for (const u of users) { map[u.id] = u.name || u.email } return map }), /** * Create/invite a new user (admin only) */ create: adminProcedure .input( z.object({ email: z.string().email(), name: z.string().optional(), role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', '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 super admins or program admins if (input.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only super admins can create super 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 outside transaction so failures don't roll back the user creation await logAudit({ prisma: ctx.prisma, 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(), email: z.string().email().optional(), name: z.string().optional().nullable(), role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(), status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(), expertiseTags: z.array(z.string()).optional(), maxAssignments: z.number().int().min(1).max(100).optional().nullable(), availabilityJson: z.any().optional(), preferredWorkload: z.number().int().min(1).max(100).optional().nullable(), }) ) .mutation(async ({ ctx, input }) => { const { id, ...data } = input const normalizedEmail = data.email?.toLowerCase().trim() // 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 changing admin roles if (data.role && targetUser.role === 'PROGRAM_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only super admins can change admin roles', }) } // Prevent non-super-admins from assigning super admin or admin role if (data.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only super admins can assign super 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', }) } if (normalizedEmail !== undefined) { const existing = await ctx.prisma.user.findFirst({ where: { email: normalizedEmail, NOT: { id }, }, select: { id: true }, }) if (existing) { throw new TRPCError({ code: 'CONFLICT', message: 'Another user already uses this email address', }) } } const updateData = { ...data, ...(normalizedEmail !== undefined && { email: normalizedEmail }), } const user = await ctx.prisma.user.update({ where: { id }, data: updateData, }) // Audit outside transaction so failures don't roll back the update await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'UPDATE', entityType: 'User', entityId: id, detailsJson: updateData, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) // Track role change specifically if (data.role && data.role !== targetUser.role) { await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'ROLE_CHANGED', entityType: 'User', entityId: id, detailsJson: { previousRole: targetUser.role, newRole: data.role }, 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', }) } // Fetch user data before deletion for the audit log const target = await ctx.prisma.user.findUniqueOrThrow({ where: { id: input.id }, select: { email: true }, }) const user = await ctx.prisma.user.delete({ where: { id: input.id }, }) // Audit outside transaction so failures don't roll back the deletion await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'DELETE', entityType: 'User', entityId: input.id, detailsJson: { email: target.email }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return user }), /** * Bulk import users (admin only) * Optionally pre-assign projects to jury members during invitation */ bulkCreate: adminProcedure .input( z.object({ users: z.array( z.object({ email: z.string().email(), name: z.string().optional(), role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'AWARD_MASTER', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'), expertiseTags: z.array(z.string()).optional(), // Optional pre-assignments for jury members assignments: z .array( z.object({ projectId: z.string(), roundId: z.string(), }) ) .optional(), // Competition architecture: optional jury group memberships juryGroupIds: z.array(z.string()).optional(), juryGroupRole: z.enum(['CHAIR', 'MEMBER', 'OBSERVER']).default('MEMBER'), // Competition architecture: optional assignment intents assignmentIntents: z .array( z.object({ roundId: z.string(), projectId: z.string(), }) ) .optional(), }) ), sendInvitation: z.boolean().default(true), }) ) .mutation(async ({ ctx, input }) => { // Prevent non-super-admins from creating super admins or program admins const hasSuperAdminRole = input.users.some((u) => u.role === 'SUPER_ADMIN') if (hasSuperAdminRole && ctx.user.role !== 'SUPER_ADMIN') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only super admins can create super admins', }) } const hasAdminRole = input.users.some((u) => u.role === 'PROGRAM_ADMIN') if (hasAdminRole && ctx.user.role !== 'SUPER_ADMIN') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only super admins can create program admins', }) } // 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 emailToAssignments = new Map>() const emailToJuryGroupIds = new Map() const emailToIntents = new Map>() for (const u of newUsers) { if (u.assignments && u.assignments.length > 0) { emailToAssignments.set(u.email.toLowerCase(), u.assignments) } if (u.juryGroupIds && u.juryGroupIds.length > 0) { emailToJuryGroupIds.set(u.email.toLowerCase(), { ids: u.juryGroupIds, role: u.juryGroupRole }) } if (u.assignmentIntents && u.assignmentIntents.length > 0) { emailToIntents.set(u.email.toLowerCase(), u.assignmentIntents) } } const created = await ctx.prisma.user.createMany({ data: newUsers.map((u) => ({ email: u.email.toLowerCase(), name: u.name, role: u.role, expertiseTags: u.expertiseTags, status: input.sendInvitation ? 'INVITED' : 'NONE', })), }) // Audit log await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'BULK_CREATE', entityType: 'User', detailsJson: { count: created.count, skipped, duplicatesInInput }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) // Fetch newly created users for assignments and optional invitation emails const createdUsers = await ctx.prisma.user.findMany({ where: { email: { in: newUsers.map((u) => u.email.toLowerCase()) } }, select: { id: true, email: true, name: true, role: true }, }) // Create pre-assignments for users who have them (batched) const assignmentData: Array<{ userId: string; projectId: string; roundId: string; method: 'MANUAL'; createdBy: string }> = [] for (const user of createdUsers) { const assignments = emailToAssignments.get(user.email.toLowerCase()) if (assignments && assignments.length > 0) { for (const assignment of assignments) { assignmentData.push({ userId: user.id, projectId: assignment.projectId, roundId: assignment.roundId, method: 'MANUAL', createdBy: ctx.user.id, }) } } } let assignmentsCreated = 0 if (assignmentData.length > 0) { const result = await ctx.prisma.assignment.createMany({ data: assignmentData, skipDuplicates: true, }) assignmentsCreated = result.count } // Audit log for assignments if any were created if (assignmentsCreated > 0) { await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'BULK_ASSIGN', entityType: 'Assignment', detailsJson: { count: assignmentsCreated, context: 'invitation_pre_assignment' }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) } // Create JuryGroupMember records for users with juryGroupIds (batched) const juryGroupMemberData: Array<{ juryGroupId: string; userId: string; role: 'CHAIR' | 'MEMBER' | 'OBSERVER' }> = [] for (const user of createdUsers) { const groupInfo = emailToJuryGroupIds.get(user.email.toLowerCase()) if (groupInfo) { for (const groupId of groupInfo.ids) { juryGroupMemberData.push({ juryGroupId: groupId, userId: user.id, role: groupInfo.role, }) } } } let juryGroupMembershipsCreated = 0 if (juryGroupMemberData.length > 0) { const result = await ctx.prisma.juryGroupMember.createMany({ data: juryGroupMemberData, skipDuplicates: true, }) juryGroupMembershipsCreated = result.count } // Create AssignmentIntents for users who have them let assignmentIntentsCreated = 0 const allIntentUsers = createdUsers.filter( (u) => emailToIntents.has(u.email.toLowerCase()) ) if (allIntentUsers.length > 0) { // Batch-fetch all relevant rounds to avoid N+1 lookups const allIntentRoundIds = new Set() for (const u of allIntentUsers) { for (const intent of emailToIntents.get(u.email.toLowerCase())!) { allIntentRoundIds.add(intent.roundId) } } const rounds = await ctx.prisma.round.findMany({ where: { id: { in: [...allIntentRoundIds] } }, select: { id: true, juryGroupId: true }, }) const roundJuryGroupMap = new Map(rounds.map((r) => [r.id, r.juryGroupId])) // Batch-fetch all matching JuryGroupMembers const memberLookups = allIntentUsers.flatMap((u) => { const intents = emailToIntents.get(u.email.toLowerCase())! return intents .map((intent) => { const juryGroupId = roundJuryGroupMap.get(intent.roundId) return juryGroupId ? { juryGroupId, userId: u.id } : null }) .filter((x): x is { juryGroupId: string; userId: string } => x !== null) }) const members = memberLookups.length > 0 ? await ctx.prisma.juryGroupMember.findMany({ where: { OR: memberLookups.map((l) => ({ juryGroupId: l.juryGroupId, userId: l.userId, })), }, select: { id: true, juryGroupId: true, userId: true }, }) : [] const memberMap = new Map( members.map((m) => [`${m.juryGroupId}:${m.userId}`, m.id]) ) // Batch-create all intents const intentData: Array<{ juryGroupMemberId: string roundId: string projectId: string source: 'INVITE' status: 'INTENT_PENDING' }> = [] for (const user of allIntentUsers) { const intents = emailToIntents.get(user.email.toLowerCase())! for (const intent of intents) { const juryGroupId = roundJuryGroupMap.get(intent.roundId) if (!juryGroupId) continue const memberId = memberMap.get(`${juryGroupId}:${user.id}`) if (!memberId) continue intentData.push({ juryGroupMemberId: memberId, roundId: intent.roundId, projectId: intent.projectId, source: 'INVITE', status: 'INTENT_PENDING', }) } } if (intentData.length > 0) { const result = await ctx.prisma.assignmentIntent.createMany({ data: intentData, skipDuplicates: true, }) assignmentIntentsCreated = result.count } } if (juryGroupMembershipsCreated > 0) { await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'BULK_CREATE', entityType: 'JuryGroupMember', detailsJson: { count: juryGroupMembershipsCreated, context: 'invitation_jury_group_binding' }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) } // Send invitation emails if requested let emailsSent = 0 const emailErrors: string[] = [] if (input.sendInvitation) { const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com' const expiryHours = await getInviteExpiryHours(ctx.prisma) const expiryMs = expiryHours * 60 * 60 * 1000 for (const user of createdUsers) { try { const token = generateInviteToken() await ctx.prisma.user.update({ where: { id: user.id }, data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs), }, }) const inviteUrl = `${baseUrl}/accept-invite?token=${token}` await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours) await ctx.prisma.notificationLog.create({ data: { userId: user.id, channel: 'EMAIL', provider: 'SMTP', type: 'JURY_INVITATION', status: 'SENT', }, }) emailsSent++ } catch (e) { emailErrors.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', }, }) } } } return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated, juryGroupMembershipsCreated, assignmentIntentsCreated, invitationSent: input.sendInvitation } }), /** * 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 = { roles: { has: '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, availabilityJson: true, preferredWorkload: true, profileImageKey: true, profileImageProvider: true, _count: { select: { assignments: input.roundId ? { where: { roundId: input.roundId } } : true, }, }, }, orderBy: { name: 'asc' }, }) const mapped = users.map((u) => ({ ...u, currentAssignments: u._count.assignments, availableSlots: u.maxAssignments !== null ? Math.max(0, u.maxAssignments - u._count.assignments) : null, })) return attachAvatarUrls(mapped) }), /** * Send invitation email to a user */ sendInvitation: adminProcedure .input(z.object({ userId: z.string(), juryGroupId: z.string().optional(), })) .mutation(async ({ ctx, input }) => { const user = await ctx.prisma.user.findUniqueOrThrow({ where: { id: input.userId }, }) if (user.status !== 'NONE' && user.status !== 'INVITED') { throw new TRPCError({ code: 'BAD_REQUEST', message: 'User has already accepted their invitation', }) } // Bind to jury group if specified (upsert to be idempotent) if (input.juryGroupId) { await ctx.prisma.juryGroupMember.upsert({ where: { juryGroupId_userId: { juryGroupId: input.juryGroupId, userId: user.id, }, }, create: { juryGroupId: input.juryGroupId, userId: user.id, role: 'MEMBER', }, update: {}, // No-op if already exists }) } // Generate invite token, set status to INVITED, and store on user const token = generateInviteToken() const expiryHours = await getInviteExpiryHours(ctx.prisma) await ctx.prisma.user.update({ where: { id: user.id }, data: { status: 'INVITED', inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryHours * 60 * 60 * 1000), }, }) const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com' const inviteUrl = `${baseUrl}/accept-invite?token=${token}` // Send invitation email await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours) // Log notification await ctx.prisma.notificationLog.create({ data: { userId: user.id, channel: 'EMAIL', provider: 'SMTP', type: 'JURY_INVITATION', status: 'SENT', }, }) // Audit log await logAudit({ prisma: ctx.prisma, 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: { in: ['NONE', 'INVITED'] }, }, }) if (users.length === 0) { return { sent: 0, skipped: input.userIds.length } } const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com' const expiryHours = await getInviteExpiryHours(ctx.prisma) const expiryMs = expiryHours * 60 * 60 * 1000 let sent = 0 const errors: string[] = [] for (const user of users) { try { // Generate invite token for each user and set status to INVITED const token = generateInviteToken() await ctx.prisma.user.update({ where: { id: user.id }, data: { status: 'INVITED', inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs), }, }) const inviteUrl = `${baseUrl}/accept-invite?token=${token}` await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours) 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 logAudit({ prisma: ctx.prisma, 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(), country: z.string().optional(), nationality: z.string().optional(), institution: z.string().optional(), bio: z.string().max(500).optional(), expertiseTags: z.array(z.string()).optional(), notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(), // Competition architecture: jury self-service preferences juryPreferences: z .array( z.object({ juryGroupMemberId: z.string(), selfServiceCap: z.number().int().positive().optional(), selfServiceRatio: z.number().min(0).max(1).optional(), }) ) .optional(), }) ) .mutation(async ({ ctx, input }) => { // Get existing user to preserve admin-set tags const existingUser = await ctx.prisma.user.findUniqueOrThrow({ where: { id: ctx.user.id }, select: { expertiseTags: true }, }) // Merge admin-set tags with user-selected tags (preserving order: admin first, then user) const adminTags = existingUser.expertiseTags || [] const userTags = input.expertiseTags || [] const mergedTags = [...new Set([...adminTags, ...userTags])] const user = await ctx.prisma.$transaction(async (tx) => { const updated = await tx.user.update({ where: { id: ctx.user.id }, data: { name: input.name, phoneNumber: input.phoneNumber, country: input.country, nationality: input.nationality, institution: input.institution, bio: input.bio, expertiseTags: mergedTags, notificationPreference: input.notificationPreference || 'EMAIL', onboardingCompletedAt: new Date(), status: 'ACTIVE', // Activate user after onboarding }, }) // Process jury self-service preferences if (input.juryPreferences && input.juryPreferences.length > 0) { for (const pref of input.juryPreferences) { // Security: verify this member belongs to the current user const member = await tx.juryGroupMember.findUnique({ where: { id: pref.juryGroupMemberId }, }) if (!member || member.userId !== ctx.user.id) continue await tx.juryGroupMember.update({ where: { id: pref.juryGroupMemberId }, data: { selfServiceCap: pref.selfServiceCap != null ? Math.min(pref.selfServiceCap, 50) : undefined, selfServiceRatio: pref.selfServiceRatio, }, }) } } return updated }) // Audit outside transaction so failures don't roll back the onboarding await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'COMPLETE_ONBOARDING', entityType: 'User', entityId: ctx.user.id, detailsJson: { name: input.name, juryPreferencesCount: input.juryPreferences?.length ?? 0 }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return user }), /** * Update jury preferences outside of onboarding (e.g., when a new round opens). */ updateJuryPreferences: protectedProcedure .input( z.object({ preferences: z.array( z.object({ juryGroupMemberId: z.string(), selfServiceCap: z.number().int().min(1).max(50), selfServiceRatio: z.number().min(0).max(1), }) ), }) ) .mutation(async ({ ctx, input }) => { for (const pref of input.preferences) { const member = await ctx.prisma.juryGroupMember.findUnique({ where: { id: pref.juryGroupMemberId }, }) if (!member || member.userId !== ctx.user.id) continue await ctx.prisma.juryGroupMember.update({ where: { id: pref.juryGroupMemberId }, data: { selfServiceCap: pref.selfServiceCap, selfServiceRatio: pref.selfServiceRatio, }, }) } return { success: true } }), /** * Get onboarding context for the current user. * Returns jury group memberships for self-service preferences. */ getOnboardingContext: protectedProcedure.query(async ({ ctx }) => { const memberships = await ctx.prisma.juryGroupMember.findMany({ where: { userId: ctx.user.id }, include: { juryGroup: { select: { id: true, name: true, defaultMaxAssignments: true, }, }, }, }) return { hasSelfServiceOptions: memberships.length > 0, memberships: memberships.map((m) => ({ juryGroupMemberId: m.id, juryGroupName: m.juryGroup.name, currentCap: m.maxAssignmentsOverride ?? m.juryGroup.defaultMaxAssignments, selfServiceCap: m.selfServiceCap, selfServiceRatio: m.selfServiceRatio, preferredStartupRatio: m.preferredStartupRatio, })), } }), /** * 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 }, }) // Jury members, mentors, and admins need onboarding const rolesRequiringOnboarding = ['JURY_MEMBER', 'MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN'] if (!rolesRequiringOnboarding.includes(user.role)) { 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 outside transaction so failures don't roll back the password set await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'PASSWORD_SET', 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 outside transaction so failures don't roll back the password change await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'PASSWORD_CHANGED', 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 || 'https://portal.monaco-opc.com' 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 logAudit({ prisma: ctx.prisma, 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.' } }), /** * Get current user's digest settings along with global digest config */ getDigestSettings: protectedProcedure.query(async ({ ctx }) => { const [user, digestEnabled, digestSections] = await Promise.all([ ctx.prisma.user.findUniqueOrThrow({ where: { id: ctx.user.id }, select: { digestFrequency: true }, }), ctx.prisma.systemSettings.findUnique({ where: { key: 'digest_enabled' }, select: { value: true }, }), ctx.prisma.systemSettings.findUnique({ where: { key: 'digest_sections' }, select: { value: true }, }), ]) return { digestFrequency: user.digestFrequency, globalDigestEnabled: digestEnabled?.value === 'true', globalDigestSections: digestSections?.value ? JSON.parse(digestSections.value) : [], } }), /** * Update a user's roles array (admin only) * Also updates the primary role to the highest privilege role in the array. */ updateRoles: adminProcedure .input(z.object({ userId: z.string(), roles: z.array(z.nativeEnum(UserRole)).min(1), })) .mutation(async ({ ctx, input }) => { // Guard: only SUPER_ADMIN can grant SUPER_ADMIN if (input.roles.includes('SUPER_ADMIN') && ctx.user.role !== 'SUPER_ADMIN') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only super admins can grant super admin role' }) } // Set primary role to highest privilege role const rolePriority: UserRole[] = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'AWARD_MASTER', 'APPLICANT', 'AUDIENCE'] const primaryRole = rolePriority.find(r => input.roles.includes(r)) || input.roles[0] return ctx.prisma.user.update({ where: { id: input.userId }, data: { roles: input.roles, role: primaryRole }, }) }), /** * List applicant users with project info for admin bulk-invite page. */ getApplicants: adminProcedure .input( z.object({ search: z.string().optional(), roundId: z.string().optional(), status: z.enum(['NONE', 'INVITED', 'ACTIVE', 'SUSPENDED']).optional(), page: z.number().int().positive().default(1), perPage: z.number().int().positive().max(100).default(20), }) ) .query(async ({ ctx, input }) => { const where: Prisma.UserWhereInput = { role: 'APPLICANT', ...(input.status && { status: input.status }), ...(input.search && { OR: [ { name: { contains: input.search, mode: 'insensitive' as const } }, { email: { contains: input.search, mode: 'insensitive' as const } }, { teamMemberships: { some: { project: { title: { contains: input.search, mode: 'insensitive' as const } } } } }, ], }), ...(input.roundId && { teamMemberships: { some: { project: { projectRoundStates: { some: { roundId: input.roundId } } } } }, }), } const [users, total] = await Promise.all([ ctx.prisma.user.findMany({ where, select: { id: true, email: true, name: true, status: true, nationality: true, institution: true, lastLoginAt: true, onboardingCompletedAt: true, teamMemberships: { take: 1, select: { role: true, project: { select: { id: true, title: true } }, }, }, submittedProjects: { take: 1, select: { id: true, title: true }, }, }, orderBy: { name: 'asc' }, skip: (input.page - 1) * input.perPage, take: input.perPage, }), ctx.prisma.user.count({ where }), ]) return { users: users.map((u) => { const project = u.submittedProjects[0] || u.teamMemberships[0]?.project || null return { id: u.id, email: u.email, name: u.name, status: u.status, nationality: u.nationality, institution: u.institution, lastLoginAt: u.lastLoginAt, onboardingCompleted: !!u.onboardingCompletedAt, projectName: project?.title ?? null, projectId: project?.id ?? null, } }), total, totalPages: Math.ceil(total / input.perPage), perPage: input.perPage, } }), /** * Bulk invite applicant users — generates tokens, sets INVITED, sends emails. */ bulkInviteApplicants: adminProcedure .input(z.object({ userIds: z.array(z.string()).min(1).max(500) })) .mutation(async ({ ctx, input }) => { const users = await ctx.prisma.user.findMany({ where: { id: { in: input.userIds }, role: 'APPLICANT', status: { in: ['NONE', 'INVITED'] }, }, select: { id: true, email: true, name: true, status: true }, }) const expiryMs = await getInviteExpiryMs(ctx.prisma) let sent = 0 let skipped = 0 const failed: string[] = [] const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com' for (const user of users) { try { const token = generateInviteToken() await ctx.prisma.user.update({ where: { id: user.id }, data: { status: 'INVITED', inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs), }, }) const inviteUrl = `${baseUrl}/accept-invite?token=${token}` await sendInvitationEmail(user.email, user.name || 'Applicant', inviteUrl, 'APPLICANT') sent++ } catch (error) { failed.push(user.email) } } skipped = input.userIds.length - users.length await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'BULK_INVITE_APPLICANTS', entityType: 'User', entityId: 'bulk', detailsJson: { sent, skipped, failed: failed.length, total: input.userIds.length }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) return { sent, skipped, failed } }), })