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:
@@ -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,
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -274,7 +274,7 @@ export const userRouter = router({
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
role: z.enum(['PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
|
||||
expertiseTags: z.array(z.string()).optional(),
|
||||
maxAssignments: z.number().int().min(1).max(100).optional(),
|
||||
})
|
||||
@@ -292,7 +292,13 @@ export const userRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Prevent non-super-admins from creating admins
|
||||
// Prevent non-super-admins from creating super admins or program admins
|
||||
if (input.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Only super admins can create super admins',
|
||||
})
|
||||
}
|
||||
if (input.role === 'PROGRAM_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
@@ -333,7 +339,7 @@ export const userRouter = router({
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional().nullable(),
|
||||
role: z.enum(['PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
||||
status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
||||
expertiseTags: z.array(z.string()).optional(),
|
||||
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
|
||||
@@ -356,7 +362,13 @@ export const userRouter = router({
|
||||
})
|
||||
}
|
||||
|
||||
// Prevent non-super-admins from assigning admin role
|
||||
// Prevent non-super-admins from assigning super admin or admin role
|
||||
if (data.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Only super admins can assign super admin role',
|
||||
})
|
||||
}
|
||||
if (data.role === 'PROGRAM_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
@@ -452,7 +464,7 @@ export const userRouter = router({
|
||||
z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
role: z.enum(['PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
|
||||
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
|
||||
expertiseTags: z.array(z.string()).optional(),
|
||||
// Optional pre-assignments for jury members
|
||||
assignments: z
|
||||
@@ -468,7 +480,14 @@ export const userRouter = router({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Prevent non-super-admins from creating program admins
|
||||
// Prevent non-super-admins from creating super admins or program admins
|
||||
const hasSuperAdminRole = input.users.some((u) => u.role === 'SUPER_ADMIN')
|
||||
if (hasSuperAdminRole && ctx.user.role !== 'SUPER_ADMIN') {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Only super admins can create super admins',
|
||||
})
|
||||
}
|
||||
const hasAdminRole = input.users.some((u) => u.role === 'PROGRAM_ADMIN')
|
||||
if (hasAdminRole && ctx.user.role !== 'SUPER_ADMIN') {
|
||||
throw new TRPCError({
|
||||
|
||||
Reference in New Issue
Block a user