From 432470083c65cf701965a6cffb8783eb48eb9619 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 16:05:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(admin):=20bulk=20role=20updates=20+=20ment?= =?UTF-8?q?or-onboarding=20email=20(=C2=A7D.2-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/admin/members-content.tsx | 36 ++++++- src/server/routers/user.ts | 122 ++++++++++++++++++++++- tests/unit/bulk-role-updates.test.ts | 101 +++++++++++++++++++ 3 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 tests/unit/bulk-role-updates.test.ts diff --git a/src/components/admin/members-content.tsx b/src/components/admin/members-content.tsx index c3ee801..e93a3c5 100644 --- a/src/components/admin/members-content.tsx +++ b/src/components/admin/members-content.tsx @@ -191,6 +191,20 @@ export function MembersContent() { }, }) + const bulkUpdateRoles = trpc.user.bulkUpdateRoles.useMutation({ + onSuccess: (r) => { + const parts: string[] = [] + if (r.updated > 0) parts.push(`Updated ${r.updated} user${r.updated === 1 ? '' : 's'}`) + if (r.alreadyHadRole > 0) parts.push(`${r.alreadyHadRole} already had role`) + toast.success(parts.join(' · ') || 'No changes') + setSelectedIds(new Set()) + utils.user.list.invalidate() + }, + onError: (error) => { + toast.error(error.message || 'Failed to update roles') + }, + }) + const selectableUsers = useMemo( () => data?.users ?? [], [data?.users] @@ -321,9 +335,29 @@ export function MembersContent() {

- Selection persists across pages and filters. + {selectedIds.size > 0 + ? `${selectedIds.size} selected. Selection persists across pages and filters.` + : 'Selection persists across pages and filters.'}

+ {selectedIds.size > 0 && ( + + )}