Decouple projects from rounds with RoundProject join table

Projects now exist at the program level instead of being locked to a
single round. A new RoundProject join table enables many-to-many
relationships with per-round status tracking. Rounds have sortOrder
for configurable progression paths.

- Add RoundProject model, programId on Project, sortOrder on Round
- Migration preserves existing data (roundId -> RoundProject entries)
- Update all routers to query through RoundProject join
- Add assign/remove/advance/reorder round endpoints
- Add Assign, Advance, Remove Projects dialogs on round detail page
- Add round reorder controls (up/down arrows) on rounds list
- Show all rounds on project detail page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-02 22:33:55 +01:00
parent 0d2bc4db7e
commit fd5e5222da
52 changed files with 1892 additions and 326 deletions

View File

@@ -148,13 +148,17 @@ export const analyticsRouter = router({
getProjectRankings: adminProcedure
.input(z.object({ roundId: z.string(), limit: z.number().optional() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
const roundProjects = await ctx.prisma.roundProject.findMany({
where: { roundId: input.roundId },
include: {
assignments: {
project: {
include: {
evaluation: {
select: { criterionScoresJson: true, status: true },
assignments: {
include: {
evaluation: {
select: { criterionScoresJson: true, status: true },
},
},
},
},
},
@@ -162,8 +166,9 @@ export const analyticsRouter = router({
})
// Calculate average scores
const rankings = projects
.map((project) => {
const rankings = roundProjects
.map((rp) => {
const project = rp.project
const allScores: number[] = []
project.assignments.forEach((assignment) => {
@@ -195,7 +200,7 @@ export const analyticsRouter = router({
id: project.id,
title: project.title,
teamName: project.teamName,
status: project.status,
status: rp.status,
averageScore,
evaluationCount: allScores.length,
}
@@ -212,15 +217,15 @@ export const analyticsRouter = router({
getStatusBreakdown: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.groupBy({
const roundProjects = await ctx.prisma.roundProject.groupBy({
by: ['status'],
where: { roundId: input.roundId },
_count: true,
})
return projects.map((p) => ({
status: p.status,
count: p._count,
return roundProjects.map((rp) => ({
status: rp.status,
count: rp._count,
}))
}),
@@ -237,7 +242,7 @@ export const analyticsRouter = router({
jurorCount,
statusCounts,
] = await Promise.all([
ctx.prisma.project.count({ where: { roundId: input.roundId } }),
ctx.prisma.roundProject.count({ where: { roundId: input.roundId } }),
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.evaluation.count({
where: {
@@ -249,7 +254,7 @@ export const analyticsRouter = router({
by: ['userId'],
where: { roundId: input.roundId },
}),
ctx.prisma.project.groupBy({
ctx.prisma.roundProject.groupBy({
by: ['status'],
where: { roundId: input.roundId },
_count: true,
@@ -348,8 +353,8 @@ export const analyticsRouter = router({
)
.query(async ({ ctx, input }) => {
const where = input.roundId
? { roundId: input.roundId }
: { round: { programId: input.programId } }
? { roundProjects: { some: { roundId: input.roundId } } }
: { programId: input.programId }
const distribution = await ctx.prisma.project.groupBy({
by: ['country'],

View File

@@ -62,7 +62,7 @@ export const applicantRouter = router({
const project = await ctx.prisma.project.findFirst({
where: {
roundId: input.roundId,
roundProjects: { some: { roundId: input.roundId } },
OR: [
{ submittedByUserId: ctx.user.id },
{
@@ -74,9 +74,14 @@ export const applicantRouter = router({
},
include: {
files: true,
round: {
roundProjects: {
where: { roundId: input.roundId },
include: {
program: { select: { name: true, year: true } },
round: {
include: {
program: { select: { name: true, year: true } },
},
},
},
},
teamMembers: {
@@ -171,26 +176,47 @@ export const applicantRouter = router({
...data,
metadataJson: metadataJson as unknown ?? undefined,
submittedAt: submit && !existing.submittedAt ? now : existing.submittedAt,
status: submit ? 'SUBMITTED' : existing.status,
},
})
// Update RoundProject status if submitting
if (submit) {
await ctx.prisma.roundProject.updateMany({
where: { projectId: projectId },
data: { status: 'SUBMITTED' },
})
}
return project
} else {
// Create new
// Get the round to find the programId
const roundForCreate = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { programId: true },
})
// Create new project
const project = await ctx.prisma.project.create({
data: {
roundId,
programId: roundForCreate.programId,
...data,
metadataJson: metadataJson as unknown ?? undefined,
submittedByUserId: ctx.user.id,
submittedByEmail: ctx.user.email,
submissionSource: 'MANUAL',
status: 'SUBMITTED',
submittedAt: submit ? now : null,
},
})
// Create RoundProject entry
await ctx.prisma.roundProject.create({
data: {
roundId,
projectId: project.id,
status: 'SUBMITTED',
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
@@ -386,10 +412,15 @@ export const applicantRouter = router({
],
},
include: {
round: {
roundProjects: {
include: {
program: { select: { name: true, year: true } },
round: {
include: {
program: { select: { name: true, year: true } },
},
},
},
orderBy: { addedAt: 'desc' },
},
files: true,
teamMembers: {
@@ -409,6 +440,10 @@ export const applicantRouter = router({
})
}
// Get the latest round project status
const latestRoundProject = project.roundProjects[0]
const currentStatus = latestRoundProject?.status ?? 'SUBMITTED'
// Build timeline
const timeline = [
{
@@ -426,27 +461,27 @@ export const applicantRouter = router({
{
status: 'UNDER_REVIEW',
label: 'Under Review',
date: project.status === 'SUBMITTED' && project.submittedAt ? project.submittedAt : null,
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status),
date: currentStatus === 'SUBMITTED' && project.submittedAt ? project.submittedAt : null,
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus),
},
{
status: 'SEMIFINALIST',
label: 'Semi-finalist',
date: null, // Would need status change tracking
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(project.status),
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus),
},
{
status: 'FINALIST',
label: 'Finalist',
date: null,
completed: ['FINALIST', 'WINNER'].includes(project.status),
completed: ['FINALIST', 'WINNER'].includes(currentStatus),
},
]
return {
project,
timeline,
currentStatus: project.status,
currentStatus,
}
}),
@@ -474,10 +509,15 @@ export const applicantRouter = router({
],
},
include: {
round: {
roundProjects: {
include: {
program: { select: { name: true, year: true } },
round: {
include: {
program: { select: { name: true, year: true } },
},
},
},
orderBy: { addedAt: 'desc' },
},
files: true,
teamMembers: {

View File

@@ -186,7 +186,7 @@ export const applicationRouter = router({
// Check if email already submitted for this round
const existingProject = await ctx.prisma.project.findFirst({
where: {
roundId,
roundProjects: { some: { roundId } },
submittedByEmail: data.contactEmail,
},
})
@@ -218,11 +218,10 @@ export const applicationRouter = router({
// Create the project
const project = await ctx.prisma.project.create({
data: {
roundId,
programId: round.programId,
title: data.projectName,
teamName: data.teamName,
description: data.description,
status: 'SUBMITTED',
competitionCategory: data.competitionCategory,
oceanIssue: data.oceanIssue,
country: data.country,
@@ -242,6 +241,15 @@ export const applicationRouter = router({
},
})
// Create RoundProject entry
await ctx.prisma.roundProject.create({
data: {
roundId,
projectId: project.id,
status: 'SUBMITTED',
},
})
// Create team lead membership
await ctx.prisma.teamMember.create({
data: {
@@ -320,7 +328,7 @@ export const applicationRouter = router({
.query(async ({ ctx, input }) => {
const existing = await ctx.prisma.project.findFirst({
where: {
roundId: input.roundId,
roundProjects: { some: { roundId: input.roundId } },
submittedByEmail: input.email,
},
})

View File

@@ -286,12 +286,16 @@ export const assignmentRouter = router({
where: { roundId: input.roundId },
_count: true,
}),
ctx.prisma.project.findMany({
ctx.prisma.roundProject.findMany({
where: { roundId: input.roundId },
select: {
id: true,
title: true,
_count: { select: { assignments: true } },
include: {
project: {
select: {
id: true,
title: true,
_count: { select: { assignments: true } },
},
},
},
}),
])
@@ -302,7 +306,7 @@ export const assignmentRouter = router({
})
const projectsWithFullCoverage = projectCoverage.filter(
(p) => p._count.assignments >= round.requiredReviews
(rp) => rp.project._count.assignments >= round.requiredReviews
).length
return {
@@ -354,15 +358,20 @@ export const assignmentRouter = router({
})
// Get all projects that need more assignments
const projects = await ctx.prisma.project.findMany({
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
where: { roundId: input.roundId },
select: {
id: true,
title: true,
tags: true,
_count: { select: { assignments: true } },
include: {
project: {
select: {
id: true,
title: true,
tags: true,
_count: { select: { assignments: true } },
},
},
},
})
const projects = roundProjectEntries.map((rp) => rp.project)
// Get existing assignments to avoid duplicates
const existingAssignments = await ctx.prisma.assignment.findMany({
@@ -482,17 +491,22 @@ export const assignmentRouter = router({
})
// Get all projects in the round
const projects = await ctx.prisma.project.findMany({
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
where: { roundId: input.roundId },
select: {
id: true,
title: true,
description: true,
tags: true,
teamName: true,
_count: { select: { assignments: true } },
include: {
project: {
select: {
id: true,
title: true,
description: true,
tags: true,
teamName: true,
_count: { select: { assignments: true } },
},
},
},
})
const projects = roundProjectEntries.map((rp) => rp.project)
// Get existing assignments
const existingAssignments = await ctx.prisma.assignment.findMany({

View File

@@ -103,21 +103,26 @@ export const exportRouter = router({
projectScores: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
where: { roundId: input.roundId },
include: {
assignments: {
project: {
include: {
evaluation: {
where: { status: 'SUBMITTED' },
assignments: {
include: {
evaluation: {
where: { status: 'SUBMITTED' },
},
},
},
},
},
},
orderBy: { title: 'asc' },
orderBy: { project: { title: 'asc' } },
})
const data = projects.map((p) => {
const data = roundProjectEntries.map((rp) => {
const p = rp.project
const evaluations = p.assignments
.map((a) => a.evaluation)
.filter((e) => e !== null)
@@ -133,7 +138,7 @@ export const exportRouter = router({
return {
title: p.title,
teamName: p.teamName,
status: p.status,
status: rp.status,
tags: p.tags.join(', '),
totalEvaluations: evaluations.length,
averageScore:

View File

@@ -147,14 +147,19 @@ export const filteringRouter = router({
}
// Get projects in this round
const projects = await ctx.prisma.project.findMany({
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
where: { roundId: input.roundId },
include: {
files: {
select: { id: true, fileName: true, fileType: true },
project: {
include: {
files: {
select: { id: true, fileName: true, fileType: true },
},
},
},
},
})
const projects = roundProjectEntries.map((rp) => rp.project)
if (projects.length === 0) {
throw new TRPCError({
@@ -250,7 +255,6 @@ export const filteringRouter = router({
id: true,
title: true,
teamName: true,
status: true,
competitionCategory: true,
country: true,
},
@@ -390,13 +394,13 @@ export const filteringRouter = router({
.filter((r) => (r.finalOutcome || r.outcome) === 'PASSED')
.map((r) => r.projectId)
// Update project statuses
// Update RoundProject statuses
await ctx.prisma.$transaction([
// Filtered out projects get REJECTED status (data preserved)
...(filteredOutIds.length > 0
? [
ctx.prisma.project.updateMany({
where: { id: { in: filteredOutIds } },
ctx.prisma.roundProject.updateMany({
where: { roundId: input.roundId, projectId: { in: filteredOutIds } },
data: { status: 'REJECTED' },
}),
]
@@ -404,8 +408,8 @@ export const filteringRouter = router({
// Passed projects get ELIGIBLE status
...(passedIds.length > 0
? [
ctx.prisma.project.updateMany({
where: { id: { in: passedIds } },
ctx.prisma.roundProject.updateMany({
where: { roundId: input.roundId, projectId: { in: passedIds } },
data: { status: 'ELIGIBLE' },
}),
]
@@ -454,9 +458,9 @@ export const filteringRouter = router({
},
})
// Restore project status
await ctx.prisma.project.update({
where: { id: input.projectId },
// Restore RoundProject status
await ctx.prisma.roundProject.updateMany({
where: { roundId: input.roundId, projectId: input.projectId },
data: { status: 'ELIGIBLE' },
})
@@ -500,8 +504,8 @@ export const filteringRouter = router({
},
})
),
ctx.prisma.project.updateMany({
where: { id: { in: input.projectIds } },
ctx.prisma.roundProject.updateMany({
where: { roundId: input.roundId, projectId: { in: input.projectIds } },
data: { status: 'ELIGIBLE' },
}),
])

View File

@@ -80,18 +80,27 @@ export const learningResourceRouter = router({
const assignments = await ctx.prisma.assignment.findMany({
where: { userId: ctx.user.id },
include: {
project: { select: { status: true } },
project: {
select: {
roundProjects: {
select: { status: true },
orderBy: { addedAt: 'desc' },
take: 1,
},
},
},
},
})
// Determine highest cohort level
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) {
if (assignment.project.status === 'FINALIST') {
const rpStatus = assignment.project.roundProjects[0]?.status
if (rpStatus === 'FINALIST') {
userCohortLevel = 'FINALIST'
break
}
if (assignment.project.status === 'SEMIFINALIST') {
if (rpStatus === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST'
}
}
@@ -155,17 +164,26 @@ export const learningResourceRouter = router({
const assignments = await ctx.prisma.assignment.findMany({
where: { userId: ctx.user.id },
include: {
project: { select: { status: true } },
project: {
select: {
roundProjects: {
select: { status: true },
orderBy: { addedAt: 'desc' as const },
take: 1,
},
},
},
},
})
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) {
if (assignment.project.status === 'FINALIST') {
const rpStatus = assignment.project.roundProjects[0]?.status
if (rpStatus === 'FINALIST') {
userCohortLevel = 'FINALIST'
break
}
if (assignment.project.status === 'SEMIFINALIST') {
if (rpStatus === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST'
}
}
@@ -220,16 +238,27 @@ export const learningResourceRouter = router({
// Check cohort level access
const assignments = await ctx.prisma.assignment.findMany({
where: { userId: ctx.user.id },
include: { project: { select: { status: true } } },
include: {
project: {
select: {
roundProjects: {
select: { status: true },
orderBy: { addedAt: 'desc' as const },
take: 1,
},
},
},
},
})
let userCohortLevel: 'ALL' | 'SEMIFINALIST' | 'FINALIST' = 'ALL'
for (const assignment of assignments) {
if (assignment.project.status === 'FINALIST') {
const rpStatus = assignment.project.roundProjects[0]?.status
if (rpStatus === 'FINALIST') {
userCohortLevel = 'FINALIST'
break
}
if (assignment.project.status === 'SEMIFINALIST') {
if (rpStatus === 'SEMIFINALIST') {
userCohortLevel = 'SEMIFINALIST'
}
}

View File

@@ -15,9 +15,13 @@ export const liveVotingRouter = router({
round: {
include: {
program: { select: { name: true, year: true } },
projects: {
roundProjects: {
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
select: { id: true, title: true, teamName: true },
include: {
project: {
select: { id: true, title: true, teamName: true },
},
},
},
},
},
@@ -34,9 +38,13 @@ export const liveVotingRouter = router({
round: {
include: {
program: { select: { name: true, year: true } },
projects: {
roundProjects: {
where: { status: { in: ['FINALIST', 'SEMIFINALIST'] } },
select: { id: true, title: true, teamName: true },
include: {
project: {
select: { id: true, title: true, teamName: true },
},
},
},
},
},

View File

@@ -333,7 +333,7 @@ export const mentorRouter = router({
// Get projects without mentors
const projects = await ctx.prisma.project.findMany({
where: {
roundId: input.roundId,
roundProjects: { some: { roundId: input.roundId } },
mentorAssignment: null,
wantsMentorship: true,
},
@@ -431,10 +431,17 @@ export const mentorRouter = router({
include: {
project: {
include: {
round: {
program: { select: { name: true, year: true } },
roundProjects: {
include: {
program: { select: { name: true, year: true } },
round: {
include: {
program: { select: { name: true, year: true } },
},
},
},
orderBy: { addedAt: 'desc' },
take: 1,
},
teamMembers: {
include: {
@@ -477,10 +484,17 @@ export const mentorRouter = router({
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: input.projectId },
include: {
round: {
program: { select: { id: true, name: true, year: true } },
roundProjects: {
include: {
program: { select: { id: true, name: true, year: true } },
round: {
include: {
program: { select: { id: true, name: true, year: true } },
},
},
},
orderBy: { addedAt: 'desc' },
take: 1,
},
teamMembers: {
include: {
@@ -528,7 +542,7 @@ export const mentorRouter = router({
)
.query(async ({ ctx, input }) => {
const where = {
...(input.roundId && { project: { roundId: input.roundId } }),
...(input.roundId && { project: { roundProjects: { some: { roundId: input.roundId } } } }),
...(input.mentorId && { mentorId: input.mentorId }),
}
@@ -541,9 +555,12 @@ export const mentorRouter = router({
id: true,
title: true,
teamName: true,
status: true,
oceanIssue: true,
competitionCategory: true,
roundProjects: {
select: { status: true },
take: 1,
},
},
},
mentor: {

View File

@@ -171,9 +171,9 @@ export const notionImportRouter = router({
}
// Create project
await ctx.prisma.project.create({
const createdProject = await ctx.prisma.project.create({
data: {
roundId: round.id,
programId: round.programId,
title: title.trim(),
teamName: typeof teamName === 'string' ? teamName.trim() : null,
description: typeof description === 'string' ? description : null,
@@ -183,6 +183,14 @@ export const notionImportRouter = router({
notionPageId: record.id,
notionDatabaseId: input.databaseId,
} as Prisma.InputJsonValue,
},
})
// Create RoundProject entry
await ctx.prisma.roundProject.create({
data: {
roundId: round.id,
projectId: createdProject.id,
status: 'SUBMITTED',
},
})

View File

@@ -40,7 +40,7 @@ export const programRouter = router({
orderBy: { createdAt: 'asc' },
include: {
_count: {
select: { projects: true, assignments: true },
select: { roundProjects: true, assignments: true },
},
},
},

View File

@@ -12,6 +12,7 @@ export const projectRouter = router({
list: protectedProcedure
.input(
z.object({
programId: z.string().optional(),
roundId: z.string().optional(),
status: z
.enum([
@@ -33,6 +34,8 @@ export const projectRouter = router({
'REJECTED',
])
).optional(),
notInRoundId: z.string().optional(), // Exclude projects already in this round
unassignedOnly: z.boolean().optional(), // Projects not in any round
search: z.string().optional(),
tags: z.array(z.string()).optional(),
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
@@ -52,7 +55,7 @@ export const projectRouter = router({
)
.query(async ({ ctx, input }) => {
const {
roundId, status, statuses, search, tags,
programId, roundId, notInRoundId, status, statuses, unassignedOnly, search, tags,
competitionCategory, oceanIssue, country,
wantsMentorship, hasFiles, hasAssignments,
page, perPage,
@@ -62,12 +65,51 @@ export const projectRouter = router({
// Build where clause
const where: Record<string, unknown> = {}
if (roundId) where.roundId = roundId
if (statuses && statuses.length > 0) {
where.status = { in: statuses }
} else if (status) {
where.status = status
if (programId) where.programId = programId
// Filter by round via RoundProject join
if (roundId) {
where.roundProjects = { some: { roundId } }
}
// Exclude projects already in a specific round
if (notInRoundId) {
where.roundProjects = {
...(where.roundProjects as Record<string, unknown> || {}),
none: { roundId: notInRoundId },
}
}
// Filter by unassigned (not in any round)
if (unassignedOnly) {
where.roundProjects = { none: {} }
}
// Status filter via RoundProject
if (roundId && (statuses?.length || status)) {
const statusValues = statuses?.length ? statuses : status ? [status] : []
if (statusValues.length > 0) {
where.roundProjects = {
some: {
roundId,
status: { in: statusValues },
},
}
}
} else if (statuses?.length || status) {
// Status filter without specific round — match any round with that status
const statusValues = statuses?.length ? statuses : status ? [status] : []
if (statusValues.length > 0) {
where.roundProjects = {
...(where.roundProjects as Record<string, unknown> || {}),
some: {
...((where.roundProjects as Record<string, unknown>)?.some as Record<string, unknown> || {}),
status: { in: statusValues },
},
}
}
}
if (tags && tags.length > 0) {
where.tags = { hasSome: tags }
}
@@ -90,7 +132,6 @@ export const projectRouter = router({
// Jury members can only see assigned projects
if (ctx.user.role === 'JURY_MEMBER') {
// If hasAssignments filter is already set, combine with jury filter
where.assignments = {
...((where.assignments as Record<string, unknown>) || {}),
some: { userId: ctx.user.id },
@@ -105,8 +146,16 @@ export const projectRouter = router({
orderBy: { createdAt: 'desc' },
include: {
files: true,
round: {
select: { id: true, name: true, program: { select: { name: true, year: true } } },
program: {
select: { id: true, name: true, year: true },
},
roundProjects: {
include: {
round: {
select: { id: true, name: true, sortOrder: true },
},
},
orderBy: { addedAt: 'desc' },
},
_count: { select: { assignments: true } },
},
@@ -130,8 +179,8 @@ export const projectRouter = router({
.query(async ({ ctx }) => {
const [rounds, countries, categories, issues] = await Promise.all([
ctx.prisma.round.findMany({
select: { id: true, name: true, program: { select: { name: true, year: true } } },
orderBy: { createdAt: 'desc' },
select: { id: true, name: true, sortOrder: true, program: { select: { name: true, year: true } } },
orderBy: [{ program: { year: 'desc' } }, { sortOrder: 'asc' }],
}),
ctx.prisma.project.findMany({
where: { country: { not: null } },
@@ -175,7 +224,17 @@ export const projectRouter = router({
where: { id: input.id },
include: {
files: true,
round: true,
program: {
select: { id: true, name: true, year: true },
},
roundProjects: {
include: {
round: {
select: { id: true, name: true, sortOrder: true, status: true },
},
},
orderBy: { round: { sortOrder: 'asc' } },
},
teamMembers: {
include: {
user: {
@@ -244,11 +303,13 @@ export const projectRouter = router({
/**
* Create a single project (admin only)
* Projects belong to a program. Optionally assign to a round immediately.
*/
create: adminProcedure
.input(
z.object({
roundId: z.string(),
programId: z.string(),
roundId: z.string().optional(),
title: z.string().min(1).max(500),
teamName: z.string().optional(),
description: z.string().optional(),
@@ -257,7 +318,7 @@ export const projectRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const { metadataJson, ...rest } = input
const { metadataJson, roundId, ...rest } = input
const project = await ctx.prisma.project.create({
data: {
...rest,
@@ -265,6 +326,17 @@ export const projectRouter = router({
},
})
// If roundId provided, also create RoundProject entry
if (roundId) {
await ctx.prisma.roundProject.create({
data: {
roundId,
projectId: project.id,
status: 'SUBMITTED',
},
})
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
@@ -272,7 +344,7 @@ export const projectRouter = router({
action: 'CREATE',
entityType: 'Project',
entityId: project.id,
detailsJson: { title: input.title, roundId: input.roundId },
detailsJson: { title: input.title, programId: input.programId, roundId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
@@ -283,6 +355,7 @@ export const projectRouter = router({
/**
* Update a project (admin only)
* Status updates require a roundId context since status is per-round.
*/
update: adminProcedure
.input(
@@ -291,6 +364,8 @@ export const projectRouter = router({
title: z.string().min(1).max(500).optional(),
teamName: z.string().optional().nullable(),
description: z.string().optional().nullable(),
// Status update requires roundId
roundId: z.string().optional(),
status: z
.enum([
'SUBMITTED',
@@ -306,7 +381,7 @@ export const projectRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const { id, metadataJson, ...data } = input
const { id, metadataJson, status, roundId, ...data } = input
const project = await ctx.prisma.project.update({
where: { id },
@@ -316,6 +391,14 @@ export const projectRouter = router({
},
})
// Update status on RoundProject if both status and roundId provided
if (status && roundId) {
await ctx.prisma.roundProject.updateMany({
where: { projectId: id, roundId },
data: { status },
})
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
@@ -323,7 +406,7 @@ export const projectRouter = router({
action: 'UPDATE',
entityType: 'Project',
entityId: id,
detailsJson: { ...data, metadataJson } as Prisma.InputJsonValue,
detailsJson: { ...data, status, roundId, metadataJson } as Prisma.InputJsonValue,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
@@ -360,11 +443,13 @@ export const projectRouter = router({
/**
* Import projects from CSV data (admin only)
* Projects belong to a program. Optionally assign to a round.
*/
importCSV: adminProcedure
.input(
z.object({
roundId: z.string(),
programId: z.string(),
roundId: z.string().optional(),
projects: z.array(
z.object({
title: z.string().min(1),
@@ -377,20 +462,53 @@ export const projectRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
// Verify round exists
await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
// Verify program exists
await ctx.prisma.program.findUniqueOrThrow({
where: { id: input.programId },
})
const created = await ctx.prisma.project.createMany({
data: input.projects.map((p) => {
// Verify round exists and belongs to program if provided
if (input.roundId) {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
if (round.programId !== input.programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Round does not belong to the selected program',
})
}
}
// Create projects in a transaction
const result = await ctx.prisma.$transaction(async (tx) => {
// Create all projects
const projectData = input.projects.map((p) => {
const { metadataJson, ...rest } = p
return {
...rest,
roundId: input.roundId,
programId: input.programId,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
}
}),
})
const created = await tx.project.createManyAndReturn({
data: projectData,
select: { id: true },
})
// If roundId provided, create RoundProject entries
if (input.roundId) {
await tx.roundProject.createMany({
data: created.map((p) => ({
roundId: input.roundId!,
projectId: p.id,
status: 'SUBMITTED' as const,
})),
})
}
return { imported: created.length }
})
// Audit log
@@ -399,23 +517,30 @@ export const projectRouter = router({
userId: ctx.user.id,
action: 'IMPORT',
entityType: 'Project',
detailsJson: { roundId: input.roundId, count: created.count },
detailsJson: { programId: input.programId, roundId: input.roundId, count: result.imported },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { imported: created.count }
return result
}),
/**
* Get all unique tags used in projects
*/
getTags: protectedProcedure
.input(z.object({ roundId: z.string().optional() }))
.input(z.object({
roundId: z.string().optional(),
programId: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {}
if (input.programId) where.programId = input.programId
if (input.roundId) where.roundProjects = { some: { roundId: input.roundId } }
const projects = await ctx.prisma.project.findMany({
where: input.roundId ? { roundId: input.roundId } : undefined,
where: Object.keys(where).length > 0 ? where : undefined,
select: { tags: true },
})
@@ -427,11 +552,13 @@ export const projectRouter = router({
/**
* Update project status in bulk (admin only)
* Status is per-round, so roundId is required.
*/
bulkUpdateStatus: adminProcedure
.input(
z.object({
ids: z.array(z.string()),
roundId: z.string(),
status: z.enum([
'SUBMITTED',
'ELIGIBLE',
@@ -443,8 +570,11 @@ export const projectRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const updated = await ctx.prisma.project.updateMany({
where: { id: { in: input.ids } },
const updated = await ctx.prisma.roundProject.updateMany({
where: {
projectId: { in: input.ids },
roundId: input.roundId,
},
data: { status: input.status },
})
@@ -454,7 +584,7 @@ export const projectRouter = router({
userId: ctx.user.id,
action: 'BULK_UPDATE_STATUS',
entityType: 'Project',
detailsJson: { ids: input.ids, status: input.status },
detailsJson: { ids: input.ids, roundId: input.roundId, status: input.status },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
@@ -462,4 +592,53 @@ export const projectRouter = router({
return { updated: updated.count }
}),
/**
* List projects in a program's pool (not assigned to any round)
*/
listPool: adminProcedure
.input(
z.object({
programId: z.string(),
search: z.string().optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(100).default(50),
})
)
.query(async ({ ctx, input }) => {
const { programId, search, page, perPage } = input
const skip = (page - 1) * perPage
const where: Record<string, unknown> = {
programId,
roundProjects: { none: {} },
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ teamName: { contains: search, mode: 'insensitive' } },
]
}
const [projects, total] = await Promise.all([
ctx.prisma.project.findMany({
where,
skip,
take: perPage,
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
teamName: true,
country: true,
competitionCategory: true,
createdAt: true,
},
}),
ctx.prisma.project.count({ where }),
])
return { projects, total, page, perPage, totalPages: Math.ceil(total / perPage) }
}),
})

View File

@@ -12,10 +12,10 @@ export const roundRouter = router({
.query(async ({ ctx, input }) => {
return ctx.prisma.round.findMany({
where: { programId: input.programId },
orderBy: { createdAt: 'asc' },
orderBy: { sortOrder: 'asc' },
include: {
_count: {
select: { projects: true, assignments: true },
select: { roundProjects: true, assignments: true },
},
},
})
@@ -32,7 +32,7 @@ export const roundRouter = router({
include: {
program: true,
_count: {
select: { projects: true, assignments: true },
select: { roundProjects: true, assignments: true },
},
evaluationForms: {
where: { isActive: true },
@@ -64,7 +64,10 @@ export const roundRouter = router({
z.object({
programId: z.string(),
name: z.string().min(1).max(255),
roundType: z.enum(['FILTERING', 'EVALUATION', 'LIVE_EVENT']).default('EVALUATION'),
requiredReviews: z.number().int().min(1).max(10).default(3),
sortOrder: z.number().int().optional(),
settingsJson: z.record(z.unknown()).optional(),
votingStartAt: z.date().optional(),
votingEndAt: z.date().optional(),
})
@@ -80,8 +83,23 @@ export const roundRouter = router({
}
}
// Auto-set sortOrder if not provided (append to end)
let sortOrder = input.sortOrder
if (sortOrder === undefined) {
const maxOrder = await ctx.prisma.round.aggregate({
where: { programId: input.programId },
_max: { sortOrder: true },
})
sortOrder = (maxOrder._max.sortOrder ?? -1) + 1
}
const { settingsJson, sortOrder: _so, ...rest } = input
const round = await ctx.prisma.round.create({
data: input,
data: {
...rest,
sortOrder,
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
},
})
// Audit log
@@ -91,7 +109,7 @@ export const roundRouter = router({
action: 'CREATE',
entityType: 'Round',
entityId: round.id,
detailsJson: input,
detailsJson: { ...rest, settingsJson } as Prisma.InputJsonValue,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
@@ -234,7 +252,7 @@ export const roundRouter = router({
.query(async ({ ctx, input }) => {
const [totalProjects, totalAssignments, completedAssignments] =
await Promise.all([
ctx.prisma.project.count({ where: { roundId: input.id } }),
ctx.prisma.roundProject.count({ where: { roundId: input.id } }),
ctx.prisma.assignment.count({ where: { roundId: input.id } }),
ctx.prisma.assignment.count({
where: { roundId: input.id, isCompleted: true },
@@ -365,7 +383,7 @@ export const roundRouter = router({
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.id },
include: {
_count: { select: { projects: true, assignments: true } },
_count: { select: { roundProjects: true, assignments: true } },
},
})
@@ -383,7 +401,7 @@ export const roundRouter = router({
detailsJson: {
name: round.name,
status: round.status,
projectsDeleted: round._count.projects,
projectsDeleted: round._count.roundProjects,
assignmentsDeleted: round._count.assignments,
},
ipAddress: ctx.ip,
@@ -408,4 +426,202 @@ export const roundRouter = router({
})
return count > 0
}),
/**
* Assign projects from the program pool to a round
*/
assignProjects: adminProcedure
.input(
z.object({
roundId: z.string(),
projectIds: z.array(z.string()).min(1),
})
)
.mutation(async ({ ctx, input }) => {
// Verify round exists and get programId
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
})
// Verify all projects belong to the same program
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.projectIds }, programId: round.programId },
select: { id: true },
})
if (projects.length !== input.projectIds.length) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Some projects do not belong to this program',
})
}
// Create RoundProject entries (skip duplicates)
const created = await ctx.prisma.roundProject.createMany({
data: input.projectIds.map((projectId) => ({
roundId: input.roundId,
projectId,
status: 'SUBMITTED' as const,
})),
skipDuplicates: true,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'ASSIGN_PROJECTS_TO_ROUND',
entityType: 'Round',
entityId: input.roundId,
detailsJson: { projectCount: created.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { assigned: created.count }
}),
/**
* Remove projects from a round
*/
removeProjects: adminProcedure
.input(
z.object({
roundId: z.string(),
projectIds: z.array(z.string()).min(1),
})
)
.mutation(async ({ ctx, input }) => {
const deleted = await ctx.prisma.roundProject.deleteMany({
where: {
roundId: input.roundId,
projectId: { in: input.projectIds },
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'REMOVE_PROJECTS_FROM_ROUND',
entityType: 'Round',
entityId: input.roundId,
detailsJson: { projectCount: deleted.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { removed: deleted.count }
}),
/**
* Advance projects from one round to the next
* Creates new RoundProject entries in the target round (keeps them in source round too)
*/
advanceProjects: adminProcedure
.input(
z.object({
fromRoundId: z.string(),
toRoundId: z.string(),
projectIds: z.array(z.string()).min(1),
})
)
.mutation(async ({ ctx, input }) => {
// Verify both rounds exist and belong to the same program
const [fromRound, toRound] = await Promise.all([
ctx.prisma.round.findUniqueOrThrow({ where: { id: input.fromRoundId } }),
ctx.prisma.round.findUniqueOrThrow({ where: { id: input.toRoundId } }),
])
if (fromRound.programId !== toRound.programId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Rounds must belong to the same program',
})
}
// Verify all projects are in the source round
const sourceProjects = await ctx.prisma.roundProject.findMany({
where: {
roundId: input.fromRoundId,
projectId: { in: input.projectIds },
},
select: { projectId: true },
})
if (sourceProjects.length !== input.projectIds.length) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Some projects are not in the source round',
})
}
// Create entries in target round (skip duplicates)
const created = await ctx.prisma.roundProject.createMany({
data: input.projectIds.map((projectId) => ({
roundId: input.toRoundId,
projectId,
status: 'SUBMITTED' as const,
})),
skipDuplicates: true,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'ADVANCE_PROJECTS',
entityType: 'Round',
entityId: input.toRoundId,
detailsJson: {
fromRoundId: input.fromRoundId,
toRoundId: input.toRoundId,
projectCount: created.count,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { advanced: created.count }
}),
/**
* Reorder rounds within a program
*/
reorder: adminProcedure
.input(
z.object({
programId: z.string(),
roundIds: z.array(z.string()).min(1),
})
)
.mutation(async ({ ctx, input }) => {
// Update sortOrder for each round based on array position
await ctx.prisma.$transaction(
input.roundIds.map((roundId, index) =>
ctx.prisma.round.update({
where: { id: roundId },
data: { sortOrder: index },
})
)
)
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'REORDER_ROUNDS',
entityType: 'Program',
entityId: input.programId,
detailsJson: { roundIds: input.roundIds },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }
}),
})

View File

@@ -237,22 +237,29 @@ export const specialAwardRouter = router({
const statusFilter = input.includeSubmitted
? (['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
: (['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST'] as const)
const projects = await ctx.prisma.project.findMany({
const roundProjectEntries = await ctx.prisma.roundProject.findMany({
where: {
round: { programId: award.programId },
status: { in: [...statusFilter] },
},
select: {
id: true,
title: true,
description: true,
competitionCategory: true,
country: true,
geographicZone: true,
tags: true,
oceanIssue: true,
include: {
project: {
select: {
id: true,
title: true,
description: true,
competitionCategory: true,
country: true,
geographicZone: true,
tags: true,
oceanIssue: true,
},
},
},
})
// Deduplicate projects (same project may be in multiple rounds)
const projectMap = new Map(roundProjectEntries.map((rp) => [rp.project.id, rp.project]))
const projects = Array.from(projectMap.values())
if (projects.length === 0) {
throw new TRPCError({

View File

@@ -199,9 +199,9 @@ export const typeformImportRouter = router({
}
// Create project
await ctx.prisma.project.create({
const createdProject = await ctx.prisma.project.create({
data: {
roundId: round.id,
programId: round.programId,
title: String(title).trim(),
teamName: typeof teamName === 'string' ? teamName.trim() : null,
description: typeof description === 'string' ? description : null,
@@ -211,6 +211,14 @@ export const typeformImportRouter = router({
typeformResponseId: response.response_id,
typeformFormId: input.formId,
} as Prisma.InputJsonValue,
},
})
// Create RoundProject entry
await ctx.prisma.roundProject.create({
data: {
roundId: round.id,
projectId: createdProject.id,
status: 'SUBMITTED',
},
})