feat: add bulk invite to jury group page + widen member search role filter
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m48s

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) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-07 20:37:25 -04:00
parent 5537946b5a
commit 2d6cee394f
2 changed files with 142 additions and 1 deletions

View File

@@ -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
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Invite New Members by Email</CardTitle>
<CardDescription>
Invite new users who don&apos;t have accounts yet. They&apos;ll receive an invitation email and be added to this jury group.
</CardDescription>
</CardHeader>
<CardContent>
<BulkInviteForm
onSubmit={async (rows) => {
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"
/>
</CardContent>
</Card>
</TabsContent>
{/* Settings Tab */}

View File

@@ -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,
}
}),
})