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

@@ -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) }
}),
})