MOPC-App/src/server/routers/user.ts

979 lines
28 KiB
TypeScript
Raw Normal View History

import crypto from 'crypto'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import type { Prisma } 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'
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
function generateInviteToken(): string {
return crypto.randomBytes(32).toString('hex')
}
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,
metadataJson: true,
phoneNumber: true,
notificationPreference: true,
profileImageKey: true,
createdAt: true,
lastLoginAt: true,
},
})
}),
/**
* 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 }
}
return {
valid: true,
user: { name: user.name, email: user.email, role: user.role },
}
}),
/**
* Update current user profile
*/
updateProfile: protectedProcedure
.input(
z.object({
name: z.string().min(1).max(255).optional(),
bio: z.string().max(1000).optional(),
phoneNumber: z.string().max(20).optional().nullable(),
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { bio, ...directFields } = input
// 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<string, string>) || {}
metadataJson = { ...currentMeta, bio } as Prisma.InputJsonValue
}
return ctx.prisma.user.update({
where: { id: ctx.user.id },
data: {
...directFields,
...(metadataJson !== undefined && { metadataJson }),
},
})
}),
/**
* 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',
})
}
// Audit log before deletion
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE_OWN_ACCOUNT',
entityType: 'User',
entityId: ctx.user.id,
detailsJson: { email: user.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Delete the user
await ctx.prisma.user.delete({
where: { id: ctx.user.id },
})
return { success: true }
}),
/**
* List all users (admin only)
*/
list: adminProcedure
.input(
z.object({
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
roles: z.array(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, roles, status, search, page, perPage } = input
const skip = (page - 1) * perPage
const where: Record<string, unknown> = {}
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,
status: true,
expertiseTags: true,
maxAssignments: 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),
}
}),
/**
* 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, mentorAssignments: 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,
},
})
// Track role change specifically
if (data.role && data.role !== targetUser.role) {
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'ROLE_CHANGED',
entityType: 'User',
entityId: id,
detailsJson: { previousRole: targetUser.role, newRole: data.role },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
}).catch(() => {})
}
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<string>()
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,
},
})
// Auto-send invitation emails to newly created users
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
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 },
})
let emailsSent = 0
const emailErrors: string[] = []
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() + INVITE_TOKEN_EXPIRY_MS),
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
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',
},
})
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 }
}),
/**
* 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<string, unknown> = {
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,
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() }))
.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 invite token and store on user
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
},
})
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
// 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 {
// Generate invite token for each user
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
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: '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 log
await ctx.prisma.auditLog.create({
data: {
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 || '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.' }
}),
})