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

@@ -234,22 +234,33 @@ export const applicantRouter = router({
mimeType: z.string(),
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
roundId: z.string().optional(),
requirementId: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Only applicants can use this
// Applicants or team members can upload
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only applicants can upload files',
// Check if user is a team member of the project
const teamMembership = await ctx.prisma.teamMember.findFirst({
where: { projectId: input.projectId, userId: ctx.user.id },
select: { id: true },
})
if (!teamMembership) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only applicants or team members can upload files',
})
}
}
// Verify project ownership
// Verify project access (owner or team member)
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
submittedByUserId: ctx.user.id,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
include: {
round: { select: { id: true, votingStartAt: true, settingsJson: true } },
@@ -263,6 +274,31 @@ export const applicantRouter = router({
})
}
// If uploading against a requirement, validate mime type and size
if (input.requirementId) {
const requirement = await ctx.prisma.fileRequirement.findUnique({
where: { id: input.requirementId },
})
if (!requirement) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'File requirement not found' })
}
// Validate mime type
if (requirement.acceptedMimeTypes.length > 0) {
const accepted = requirement.acceptedMimeTypes.some((pattern) => {
if (pattern.endsWith('/*')) {
return input.mimeType.startsWith(pattern.replace('/*', '/'))
}
return input.mimeType === pattern
})
if (!accepted) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `File type ${input.mimeType} is not accepted. Accepted types: ${requirement.acceptedMimeTypes.join(', ')}`,
})
}
}
}
// Check round upload deadline policy if roundId provided
let isLate = false
const targetRoundId = input.roundId || project.roundId
@@ -331,22 +367,32 @@ export const applicantRouter = router({
objectKey: z.string(),
roundId: z.string().optional(),
isLate: z.boolean().optional(),
requirementId: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Only applicants can use this
// Applicants or team members can save files
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only applicants can save files',
const teamMembership = await ctx.prisma.teamMember.findFirst({
where: { projectId: input.projectId, userId: ctx.user.id },
select: { id: true },
})
if (!teamMembership) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only applicants or team members can save files',
})
}
}
// Verify project ownership
// Verify project access
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
submittedByUserId: ctx.user.id,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
})
@@ -357,16 +403,26 @@ export const applicantRouter = router({
})
}
const { projectId, roundId, isLate, ...fileData } = input
const { projectId, roundId, isLate, requirementId, ...fileData } = input
// Delete existing file of same type, scoped by roundId if provided
await ctx.prisma.projectFile.deleteMany({
where: {
projectId,
fileType: input.fileType,
...(roundId ? { roundId } : {}),
},
})
// Delete existing file: by requirementId if provided, otherwise by fileType+roundId
if (requirementId) {
await ctx.prisma.projectFile.deleteMany({
where: {
projectId,
requirementId,
...(roundId ? { roundId } : {}),
},
})
} else {
await ctx.prisma.projectFile.deleteMany({
where: {
projectId,
fileType: input.fileType,
...(roundId ? { roundId } : {}),
},
})
}
// Create new file record
const file = await ctx.prisma.projectFile.create({
@@ -375,6 +431,7 @@ export const applicantRouter = router({
...fileData,
roundId: roundId || null,
isLate: isLate || false,
requirementId: requirementId || null,
},
})
@@ -387,21 +444,16 @@ export const applicantRouter = router({
deleteFile: protectedProcedure
.input(z.object({ fileId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Only applicants can use this
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Only applicants can delete files',
})
}
const file = await ctx.prisma.projectFile.findUniqueOrThrow({
where: { id: input.fileId },
include: { project: true },
include: { project: { include: { teamMembers: { select: { userId: true } } } } },
})
// Verify ownership
if (file.project.submittedByUserId !== ctx.user.id) {
// Verify ownership or team membership
const isOwner = file.project.submittedByUserId === ctx.user.id
const isTeamMember = file.project.teamMembers.some((tm) => tm.userId === ctx.user.id)
if (!isOwner && !isTeamMember) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this file',
@@ -705,6 +757,7 @@ export const applicantRouter = router({
return {
teamMembers: project.teamMembers,
submittedBy: project.submittedBy,
roundId: project.roundId,
}
}),