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:
@@ -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) }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user