feat(admin): bulk role updates + mentor-onboarding email (§D.2-3)
user.bulkUpdateRoles({userIds, addRole?, removeRole?}) batches role
changes across up to 200 users with a SUPER_ADMIN self-demote guard.
When MENTOR is freshly added, fires sendMentorOnboardingEmail once per
user, gated by User.mentorOnboardingSentAt for idempotency. Audit log
entry per user changed.
UI: 'Add MENTOR role' button surfaces in the existing /admin/members
bulk-selection toolbar when ≥1 user is selected. Other roles
(OBSERVER / AWARD_MASTER) supported by the procedure but not yet wired
to UI; one button keeps the toolbar minimal until a clear need arises.
Tests cover happy path, idempotency on second call, removeRole semantics,
and the SUPER_ADMIN self-demote guard.
Plan: docs/superpowers/plans/2026-04-28-pr6-multi-role-and-workspace-previews.md
This commit is contained in:
@@ -3,7 +3,7 @@ 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, sendJuryInvitationEmail, sendMagicLinkEmail, sendPasswordResetEmail } from '@/lib/email'
|
||||
import { sendInvitationEmail, sendJuryInvitationEmail, sendMagicLinkEmail, sendMentorOnboardingEmail, sendPasswordResetEmail } from '@/lib/email'
|
||||
import { hashPassword, validatePassword } from '@/lib/password'
|
||||
import { attachAvatarUrls, getUserAvatarUrl } from '@/server/utils/avatar-url'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
@@ -1819,6 +1819,126 @@ export const userRouter = router({
|
||||
})
|
||||
}),
|
||||
|
||||
/**
|
||||
* Bulk add/remove a single role across multiple users. Fires the mentor
|
||||
* onboarding email exactly once per user when MENTOR is freshly added,
|
||||
* idempotent via User.mentorOnboardingSentAt.
|
||||
*/
|
||||
bulkUpdateRoles: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userIds: z.array(z.string()).min(1).max(200),
|
||||
addRole: z.nativeEnum(UserRole).optional(),
|
||||
removeRole: z.nativeEnum(UserRole).optional(),
|
||||
}).refine((d) => d.addRole || d.removeRole, {
|
||||
message: 'Provide addRole or removeRole',
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Self-demote guard
|
||||
if (input.removeRole === 'SUPER_ADMIN' && input.userIds.includes(ctx.user.id)) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'You cannot remove SUPER_ADMIN from self',
|
||||
})
|
||||
}
|
||||
// Privilege guard: only SUPER_ADMIN can grant SUPER_ADMIN
|
||||
if (input.addRole === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Only super admins can grant super admin role',
|
||||
})
|
||||
}
|
||||
|
||||
const targets = await ctx.prisma.user.findMany({
|
||||
where: { id: { in: input.userIds } },
|
||||
select: { id: true, name: true, email: true, roles: true, mentorOnboardingSentAt: true },
|
||||
})
|
||||
|
||||
let updated = 0
|
||||
let alreadyHadRole = 0
|
||||
const newlyMentor: typeof targets = []
|
||||
|
||||
const rolePriority: UserRole[] = [
|
||||
'SUPER_ADMIN',
|
||||
'PROGRAM_ADMIN',
|
||||
'JURY_MEMBER',
|
||||
'MENTOR',
|
||||
'OBSERVER',
|
||||
'AWARD_MASTER',
|
||||
'APPLICANT',
|
||||
'AUDIENCE',
|
||||
]
|
||||
|
||||
for (const u of targets) {
|
||||
const current = new Set(u.roles)
|
||||
const next = new Set(current)
|
||||
|
||||
if (input.addRole) {
|
||||
if (current.has(input.addRole)) {
|
||||
alreadyHadRole++
|
||||
continue
|
||||
}
|
||||
next.add(input.addRole)
|
||||
}
|
||||
if (input.removeRole) {
|
||||
if (!current.has(input.removeRole)) {
|
||||
alreadyHadRole++
|
||||
continue
|
||||
}
|
||||
next.delete(input.removeRole)
|
||||
}
|
||||
if (next.size === 0) next.add('APPLICANT' as UserRole) // safety: never empty
|
||||
|
||||
const nextArr = Array.from(next)
|
||||
const primary = rolePriority.find((r) => nextArr.includes(r)) ?? nextArr[0]
|
||||
|
||||
const isFreshMentor =
|
||||
input.addRole === 'MENTOR' && !current.has('MENTOR') && !u.mentorOnboardingSentAt
|
||||
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: u.id },
|
||||
data: {
|
||||
roles: nextArr,
|
||||
role: primary,
|
||||
...(isFreshMentor ? { mentorOnboardingSentAt: new Date() } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: ctx.user.id,
|
||||
action: input.addRole ? 'USER_ROLE_ADD' : 'USER_ROLE_REMOVE',
|
||||
entityType: 'User',
|
||||
entityId: u.id,
|
||||
detailsJson: {
|
||||
addRole: input.addRole,
|
||||
removeRole: input.removeRole,
|
||||
before: u.roles,
|
||||
after: nextArr,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
if (isFreshMentor) newlyMentor.push(u)
|
||||
updated++
|
||||
}
|
||||
|
||||
// Send onboarding emails outside the per-user mutation loop. Errors
|
||||
// caught and logged — we don't roll back the role grant if email
|
||||
// delivery fails.
|
||||
for (const u of newlyMentor) {
|
||||
try {
|
||||
await sendMentorOnboardingEmail(u.email, u.name)
|
||||
} catch (err) {
|
||||
console.error('[bulkUpdateRoles] mentor-onboarding email failed for', u.email, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { updated, alreadyHadRole, total: targets.length }
|
||||
}),
|
||||
|
||||
/**
|
||||
* List applicant users with project info for admin bulk-invite page.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user