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>
508 lines
16 KiB
TypeScript
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,
|
|
}
|
|
}),
|
|
})
|