Files
MOPC-Portal/src/server/routers/juryGroup.ts
Matt 2d6cee394f
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m48s
feat: add bulk invite to jury group page + widen member search role filter
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>
2026-04-07 20:37:25 -04:00

508 lines
16 KiB
TypeScript

import { z } from 'zod'
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'])
export const juryGroupRouter = router({
/**
* Create a new jury group for a competition
*/
create: adminProcedure
.input(
z.object({
competitionId: z.string(),
name: z.string().min(1).max(255),
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
description: z.string().optional(),
sortOrder: z.number().int().nonnegative().default(0),
defaultMaxAssignments: z.number().int().positive().default(20),
defaultCapMode: capModeEnum.default('SOFT'),
softCapBuffer: z.number().int().nonnegative().default(2),
categoryQuotasEnabled: z.boolean().default(false),
defaultCategoryQuotas: z
.record(z.object({ min: z.number().int().nonnegative(), max: z.number().int().positive() }))
.optional(),
allowJurorCapAdjustment: z.boolean().default(false),
allowJurorRatioAdjustment: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.competition.findUniqueOrThrow({
where: { id: input.competitionId },
})
const { defaultCategoryQuotas, ...rest } = input
const juryGroup = await ctx.prisma.juryGroup.create({
data: {
...rest,
defaultCategoryQuotas: defaultCategoryQuotas ?? undefined,
},
})
// Audit outside transaction so failures don't roll back the create
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'JuryGroup',
entityId: juryGroup.id,
detailsJson: { name: input.name, competitionId: input.competitionId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return juryGroup
}),
/**
* Get jury group by ID with members
*/
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const group = await ctx.prisma.juryGroup.findUnique({
where: { id: input.id },
include: {
members: {
include: {
user: {
select: { id: true, name: true, email: true, role: true },
},
},
orderBy: { joinedAt: 'asc' },
},
},
})
if (!group) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Jury group not found' })
}
return group
}),
/**
* List jury groups for a competition
*/
list: protectedProcedure
.input(z.object({ competitionId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.juryGroup.findMany({
where: { competitionId: input.competitionId },
orderBy: { sortOrder: 'asc' },
include: {
_count: { select: { members: true, assignments: true } },
rounds: {
select: { id: true, name: true, roundType: true, status: true },
orderBy: { sortOrder: 'asc' },
},
members: {
take: 5,
orderBy: { joinedAt: 'asc' },
select: {
id: true,
role: true,
user: { select: { id: true, name: true, email: true } },
},
},
},
})
}),
/**
* Update jury group settings
*/
update: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(255).optional(),
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
description: z.string().optional(),
sortOrder: z.number().int().nonnegative().optional(),
defaultMaxAssignments: z.number().int().positive().optional(),
defaultCapMode: capModeEnum.optional(),
softCapBuffer: z.number().int().nonnegative().optional(),
categoryQuotasEnabled: z.boolean().optional(),
defaultCategoryQuotas: z
.record(z.object({ min: z.number().int().nonnegative(), max: z.number().int().positive() }))
.nullable()
.optional(),
allowJurorCapAdjustment: z.boolean().optional(),
allowJurorRatioAdjustment: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, defaultCategoryQuotas, ...rest } = input
return ctx.prisma.juryGroup.update({
where: { id },
data: {
...rest,
...(defaultCategoryQuotas !== undefined
? {
defaultCategoryQuotas:
defaultCategoryQuotas === null
? Prisma.JsonNull
: (defaultCategoryQuotas as Prisma.InputJsonValue),
}
: {}),
},
})
}),
/**
* Add a member to a jury group
*/
addMember: adminProcedure
.input(
z.object({
juryGroupId: z.string(),
userId: z.string(),
role: z.enum(['CHAIR', 'MEMBER', 'OBSERVER']).default('MEMBER'),
maxAssignmentsOverride: z.number().int().positive().nullable().optional(),
capModeOverride: capModeEnum.nullable().optional(),
categoryQuotasOverride: z
.record(z.object({ min: z.number().int().nonnegative(), max: z.number().int().positive() }))
.nullable()
.optional(),
preferredStartupRatio: z.number().min(0).max(1).nullable().optional(),
availabilityNotes: z.string().nullable().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Verify the user exists
await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.userId },
})
// Check if already a member
const existing = await ctx.prisma.juryGroupMember.findUnique({
where: {
juryGroupId_userId: {
juryGroupId: input.juryGroupId,
userId: input.userId,
},
},
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'User is already a member of this jury group',
})
}
const member = await ctx.prisma.juryGroupMember.create({
data: {
juryGroupId: input.juryGroupId,
userId: input.userId,
role: input.role,
maxAssignmentsOverride: input.maxAssignmentsOverride ?? undefined,
capModeOverride: input.capModeOverride ?? undefined,
categoryQuotasOverride: input.categoryQuotasOverride ?? undefined,
preferredStartupRatio: input.preferredStartupRatio ?? undefined,
availabilityNotes: input.availabilityNotes ?? undefined,
},
include: {
user: { select: { id: true, name: true, email: true, role: true } },
},
})
// Audit outside transaction so failures don't roll back the member add
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'JuryGroupMember',
entityId: member.id,
detailsJson: {
juryGroupId: input.juryGroupId,
addedUserId: input.userId,
role: input.role,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return member
}),
/**
* Remove a member from a jury group
*/
removeMember: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.juryGroupMember.findUniqueOrThrow({
where: { id: input.id },
})
await ctx.prisma.juryGroupMember.delete({ where: { id: input.id } })
// Audit outside transaction so failures don't roll back the member removal
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'JuryGroupMember',
entityId: input.id,
detailsJson: {
juryGroupId: existing.juryGroupId,
removedUserId: existing.userId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return existing
}),
/**
* Delete a jury group entirely
*/
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const group = await ctx.prisma.juryGroup.findUniqueOrThrow({
where: { id: input.id },
include: {
_count: { select: { members: true, assignments: true, rounds: true } },
},
})
// Unlink any rounds that reference this jury group
await ctx.prisma.round.updateMany({
where: { juryGroupId: input.id },
data: { juryGroupId: null },
})
// Delete all members first (cascade should handle this, but be explicit)
await ctx.prisma.juryGroupMember.deleteMany({
where: { juryGroupId: input.id },
})
await ctx.prisma.juryGroup.delete({ where: { id: input.id } })
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'JuryGroup',
entityId: input.id,
detailsJson: {
name: group.name,
competitionId: group.competitionId,
memberCount: group._count.members,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true, name: group.name }
}),
/**
* Update a jury group member's role/overrides
*/
updateMember: adminProcedure
.input(
z.object({
id: z.string(),
role: z.enum(['CHAIR', 'MEMBER', 'OBSERVER']).optional(),
maxAssignmentsOverride: z.number().int().positive().nullable().optional(),
capModeOverride: capModeEnum.nullable().optional(),
categoryQuotasOverride: z
.record(z.object({ min: z.number().int().nonnegative(), max: z.number().int().positive() }))
.nullable()
.optional(),
preferredStartupRatio: z.number().min(0).max(1).nullable().optional(),
availabilityNotes: z.string().nullable().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, categoryQuotasOverride, ...rest } = input
return ctx.prisma.juryGroupMember.update({
where: { id },
data: {
...rest,
...(categoryQuotasOverride !== undefined
? {
categoryQuotasOverride:
categoryQuotasOverride === null
? Prisma.JsonNull
: (categoryQuotasOverride as Prisma.InputJsonValue),
}
: {}),
},
include: {
user: { select: { id: true, name: true, email: true, role: true } },
},
})
}),
/**
* Review self-service values set by jurors during onboarding.
* Returns members who have self-service cap or ratio adjustments.
*/
reviewSelfServiceValues: adminProcedure
.input(z.object({ juryGroupId: z.string() }))
.query(async ({ ctx, input }) => {
const group = await ctx.prisma.juryGroup.findUniqueOrThrow({
where: { id: input.juryGroupId },
select: {
id: true,
name: true,
defaultMaxAssignments: true,
allowJurorCapAdjustment: true,
allowJurorRatioAdjustment: true,
},
})
const members = await ctx.prisma.juryGroupMember.findMany({
where: {
juryGroupId: input.juryGroupId,
OR: [
{ selfServiceCap: { not: null } },
{ selfServiceRatio: { not: null } },
],
},
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { joinedAt: 'asc' },
})
return {
group,
members: members.map((m) => ({
id: m.id,
userId: m.userId,
userName: m.user.name,
userEmail: m.user.email,
role: m.role,
adminCap: m.maxAssignmentsOverride ?? group.defaultMaxAssignments,
selfServiceCap: m.selfServiceCap,
selfServiceRatio: m.selfServiceRatio,
preferredStartupRatio: m.preferredStartupRatio,
})),
}
}),
/**
* 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,
}
}),
})