From 2d6cee394fef5e7c9a6e36cb3bc3c8b9b2e03fa8 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 7 Apr 2026 20:37:25 -0400 Subject: [PATCH] feat: add bulk invite to jury group page + widen member search role filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds bulkInviteMembers procedure to juryGroup router and integrates BulkInviteForm into the jury group members tab. Also removes the JURY_MEMBER-only filter from the user search — any user can now be added to a jury group. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../(admin)/admin/juries/[groupId]/page.tsx | 32 ++++- src/server/routers/juryGroup.ts | 111 ++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/src/app/(admin)/admin/juries/[groupId]/page.tsx b/src/app/(admin)/admin/juries/[groupId]/page.tsx index 24a94de..0eaa4c9 100644 --- a/src/app/(admin)/admin/juries/[groupId]/page.tsx +++ b/src/app/(admin)/admin/juries/[groupId]/page.tsx @@ -1,6 +1,7 @@ 'use client' import { use, useState } from 'react' +import { BulkInviteForm } from '@/components/shared/bulk-invite-form' import Link from 'next/link' import type { Route } from 'next' import { useRouter } from 'next/navigation' @@ -94,7 +95,6 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps const { data: userSearchResults, isLoading: loadingUsers } = trpc.user.list.useQuery( { - role: 'JURY_MEMBER', search: userSearch, page: 1, perPage: 20, @@ -120,6 +120,14 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps onError: (err) => toast.error(err.message), }) + const bulkInviteMutation = trpc.juryGroup.bulkInviteMembers.useMutation({ + onSuccess: (data) => { + utils.juryGroup.getById.invalidate({ id: groupId }) + toast.success(`${data.created} invited, ${data.existing} already existed${data.errors > 0 ? `, ${data.errors} failed` : ''}`) + }, + onError: (err) => toast.error(err.message), + }) + const removeMemberMutation = trpc.juryGroup.removeMember.useMutation({ onSuccess: () => { utils.juryGroup.getById.invalidate({ id: groupId }) @@ -339,6 +347,28 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps )} + + + + Invite New Members by Email + + Invite new users who don't have accounts yet. They'll receive an invitation email and be added to this jury group. + + + + { + await bulkInviteMutation.mutateAsync({ + juryGroupId: groupId, + role: 'MEMBER', + invitees: rows.map((r) => ({ name: r.name || undefined, email: r.email })), + }) + }} + isPending={bulkInviteMutation.isPending} + submitLabel="Invite & Add Members" + /> + + {/* Settings Tab */} diff --git a/src/server/routers/juryGroup.ts b/src/server/routers/juryGroup.ts index f7e8c83..5e80fec 100644 --- a/src/server/routers/juryGroup.ts +++ b/src/server/routers/juryGroup.ts @@ -3,6 +3,8 @@ import { TRPCError } from '@trpc/server' import { Prisma } from '@prisma/client' import { router, adminProcedure, protectedProcedure } from '../trpc' import { logAudit } from '@/server/utils/audit' +import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite' +import { sendJuryInvitationEmail } from '@/lib/email' const capModeEnum = z.enum(['HARD', 'SOFT', 'NONE']) @@ -393,4 +395,113 @@ export const juryGroupRouter = router({ })), } }), + + /** + * Bulk invite new users as jury group members — creates accounts, assigns JURY_MEMBER role, sends invite emails + */ + bulkInviteMembers: adminProcedure + .input( + z.object({ + juryGroupId: z.string(), + role: z.enum(['CHAIR', 'MEMBER', 'OBSERVER']).default('MEMBER'), + invitees: z.array( + z.object({ + name: z.string().optional(), + email: z.string().email(), + }) + ).min(1).max(50), + }) + ) + .mutation(async ({ ctx, input }) => { + const group = await ctx.prisma.juryGroup.findUniqueOrThrow({ + where: { id: input.juryGroupId }, + select: { id: true, name: true }, + }) + + const results: Array<{ email: string; status: 'created' | 'existing' | 'error'; error?: string }> = [] + + for (const invitee of input.invitees) { + try { + let user = await ctx.prisma.user.findUnique({ + where: { email: invitee.email }, + select: { id: true, status: true, role: true }, + }) + + if (!user) { + const inviteToken = generateInviteToken() + const expiryMs = await getInviteExpiryMs(ctx.prisma) + + user = await ctx.prisma.user.create({ + data: { + email: invitee.email, + name: invitee.name || null, + role: 'JURY_MEMBER', + status: 'INVITED', + inviteToken, + inviteTokenExpiresAt: new Date(Date.now() + expiryMs), + }, + select: { id: true, status: true, role: true }, + }) + + const inviteUrl = `${process.env.NEXTAUTH_URL}/accept-invite?token=${inviteToken}` + try { + await sendJuryInvitationEmail( + invitee.email, + invitee.name || null, + inviteUrl, + group.name + ) + } catch { + // Email failure shouldn't block the invite + } + + results.push({ email: invitee.email, status: 'created' }) + } else { + results.push({ email: invitee.email, status: 'existing' }) + } + + // Add as jury group member (skip if already added) + const existing = await ctx.prisma.juryGroupMember.findUnique({ + where: { + juryGroupId_userId: { juryGroupId: input.juryGroupId, userId: user.id }, + }, + }) + if (!existing) { + await ctx.prisma.juryGroupMember.create({ + data: { + juryGroupId: input.juryGroupId, + userId: user.id, + role: input.role, + }, + }) + } + } catch (err) { + results.push({ + email: invitee.email, + status: 'error', + error: err instanceof Error ? err.message : 'Unknown error', + }) + } + } + + await logAudit({ + userId: ctx.user.id, + action: 'CREATE', + entityType: 'JuryGroupMember', + entityId: input.juryGroupId, + detailsJson: { + action: 'BULK_INVITE', + groupName: group.name, + count: input.invitees.length, + results, + }, + }) + + return { + created: results.filter((r) => r.status === 'created').length, + existing: results.filter((r) => r.status === 'existing').length, + errors: results.filter((r) => r.status === 'error').length, + results, + } + }), })