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
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:
@@ -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't have accounts yet. They'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 */}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user