Add file requirements per round and super admin promotion via UI

Part A: File Requirements per Round
- New FileRequirement model with name, description, accepted MIME types, max size, required flag, sort order
- Added requirementId FK to ProjectFile for linking uploads to requirements
- Backend CRUD (create/update/delete/reorder) in file router with audit logging
- Mime type validation and team member upload authorization in applicant router
- Admin UI: FileRequirementsEditor component in round edit page
- Applicant UI: RequirementUploadSlot/List components in submission detail and team pages
- Viewer UI: RequirementChecklist with fulfillment status in file-viewer

Part B: Super Admin Promotion
- Added SUPER_ADMIN to role enums in user create/update/bulkCreate with guards
- Member detail page: SUPER_ADMIN dropdown option with AlertDialog confirmation
- Invite page: SUPER_ADMIN option visible only to super admins

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 23:01:33 +01:00
parent e73a676412
commit 829acf8d4e
12 changed files with 1229 additions and 62 deletions

View File

@@ -288,6 +288,7 @@ export const fileRouter = router({
where,
include: {
round: { select: { id: true, name: true, sortOrder: true } },
requirement: { select: { id: true, name: true, description: true, isRequired: true } },
},
orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }],
})
@@ -364,6 +365,7 @@ export const fileRouter = router({
},
include: {
round: { select: { id: true, name: true, sortOrder: true } },
requirement: { select: { id: true, name: true, description: true, isRequired: true } },
},
orderBy: [{ createdAt: 'asc' }],
})
@@ -665,4 +667,136 @@ export const fileRouter = router({
return results
}),
// =========================================================================
// FILE REQUIREMENTS
// =========================================================================
/**
* List file requirements for a round (available to any authenticated user)
*/
listRequirements: protectedProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.fileRequirement.findMany({
where: { roundId: input.roundId },
orderBy: { sortOrder: 'asc' },
})
}),
/**
* Create a file requirement for a round (admin only)
*/
createRequirement: adminProcedure
.input(
z.object({
roundId: z.string(),
name: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
acceptedMimeTypes: z.array(z.string()).default([]),
maxSizeMB: z.number().int().min(1).max(5000).optional(),
isRequired: z.boolean().default(true),
sortOrder: z.number().int().default(0),
})
)
.mutation(async ({ ctx, input }) => {
const requirement = await ctx.prisma.fileRequirement.create({
data: input,
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'FileRequirement',
entityId: requirement.id,
detailsJson: { name: input.name, roundId: input.roundId },
})
} catch {}
return requirement
}),
/**
* Update a file requirement (admin only)
*/
updateRequirement: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(200).optional(),
description: z.string().max(1000).optional().nullable(),
acceptedMimeTypes: z.array(z.string()).optional(),
maxSizeMB: z.number().int().min(1).max(5000).optional().nullable(),
isRequired: z.boolean().optional(),
sortOrder: z.number().int().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const requirement = await ctx.prisma.fileRequirement.update({
where: { id },
data,
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'FileRequirement',
entityId: id,
detailsJson: data,
})
} catch {}
return requirement
}),
/**
* Delete a file requirement (admin only)
*/
deleteRequirement: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.fileRequirement.delete({
where: { id: input.id },
})
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'FileRequirement',
entityId: input.id,
})
} catch {}
return { success: true }
}),
/**
* Reorder file requirements (admin only)
*/
reorderRequirements: adminProcedure
.input(
z.object({
roundId: z.string(),
orderedIds: z.array(z.string()),
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(
input.orderedIds.map((id, index) =>
ctx.prisma.fileRequirement.update({
where: { id },
data: { sortOrder: index },
})
)
)
return { success: true }
}),
})