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'
|
'use client'
|
||||||
|
|
||||||
import { use, useState } from 'react'
|
import { use, useState } from 'react'
|
||||||
|
import { BulkInviteForm } from '@/components/shared/bulk-invite-form'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
@@ -94,7 +95,6 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
|
|||||||
|
|
||||||
const { data: userSearchResults, isLoading: loadingUsers } = trpc.user.list.useQuery(
|
const { data: userSearchResults, isLoading: loadingUsers } = trpc.user.list.useQuery(
|
||||||
{
|
{
|
||||||
role: 'JURY_MEMBER',
|
|
||||||
search: userSearch,
|
search: userSearch,
|
||||||
page: 1,
|
page: 1,
|
||||||
perPage: 20,
|
perPage: 20,
|
||||||
@@ -120,6 +120,14 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
|
|||||||
onError: (err) => toast.error(err.message),
|
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({
|
const removeMemberMutation = trpc.juryGroup.removeMember.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.juryGroup.getById.invalidate({ id: groupId })
|
utils.juryGroup.getById.invalidate({ id: groupId })
|
||||||
@@ -339,6 +347,28 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Settings Tab */}
|
{/* Settings Tab */}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { TRPCError } from '@trpc/server'
|
|||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
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'])
|
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