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

@@ -8,23 +8,23 @@ async function check() {
const rounds = await prisma.round.findMany({
include: {
_count: { select: { projects: true } }
_count: { select: { roundProjects: true } }
}
})
for (const r of rounds) {
console.log(`Round: ${r.name} (id: ${r.id})`)
console.log(` Projects: ${r._count.projects}`)
console.log(` Projects: ${r._count.roundProjects}`)
}
// Check if projects have roundId set
const projectsWithRound = await prisma.project.findMany({
select: { id: true, title: true, roundId: true },
// Check if projects have programId set
const sampleProjects = await prisma.project.findMany({
select: { id: true, title: true, programId: true },
take: 5
})
console.log('\nSample projects:')
for (const p of projectsWithRound) {
console.log(` ${p.title}: roundId=${p.roundId}`)
for (const p of sampleProjects) {
console.log(` ${p.title}: programId=${p.programId}`)
}
}

View File

@@ -10,18 +10,18 @@ async function cleanup() {
id: true,
name: true,
slug: true,
projects: { select: { id: true, title: true } },
_count: { select: { projects: true } }
roundProjects: { select: { id: true, projectId: true, project: { select: { id: true, title: true } } } },
_count: { select: { roundProjects: true } }
}
})
console.log(`Found ${rounds.length} rounds:`)
for (const round of rounds) {
console.log(`- ${round.name} (slug: ${round.slug}): ${round._count.projects} projects`)
console.log(`- ${round.name} (slug: ${round.slug}): ${round._count.roundProjects} projects`)
}
// Find rounds with 9 or fewer projects (dummy data)
const dummyRounds = rounds.filter(r => r._count.projects <= 9)
const dummyRounds = rounds.filter(r => r._count.roundProjects <= 9)
if (dummyRounds.length > 0) {
console.log(`\nDeleting ${dummyRounds.length} dummy round(s)...`)
@@ -29,10 +29,16 @@ async function cleanup() {
for (const round of dummyRounds) {
console.log(`\nProcessing: ${round.name}`)
const projectIds = round.projects.map(p => p.id)
const projectIds = round.roundProjects.map(rp => rp.projectId)
if (projectIds.length > 0) {
// Delete team members first
// Delete round-project associations first
const rpDeleted = await prisma.roundProject.deleteMany({
where: { roundId: round.id }
})
console.log(` Deleted ${rpDeleted.count} round-project associations`)
// Delete team members
const teamDeleted = await prisma.teamMember.deleteMany({
where: { projectId: { in: projectIds } }
})

View File

@@ -8,15 +8,15 @@ async function cleanup() {
// Find and delete the dummy round
const dummyRound = await prisma.round.findFirst({
where: { slug: 'round-1-2026' },
include: { projects: true }
include: { roundProjects: { include: { project: true } } }
})
if (dummyRound) {
console.log(`Found dummy round: ${dummyRound.name}`)
console.log(`Projects in round: ${dummyRound.projects.length}`)
console.log(`Projects in round: ${dummyRound.roundProjects.length}`)
// Get project IDs to delete
const projectIds = dummyRound.projects.map(p => p.id)
const projectIds = dummyRound.roundProjects.map(rp => rp.projectId)
// Delete team members for these projects
if (projectIds.length > 0) {
@@ -25,11 +25,11 @@ async function cleanup() {
})
console.log(`Deleted ${teamDeleted.count} team members`)
// Disconnect projects from round first
await prisma.round.update({
where: { id: dummyRound.id },
data: { projects: { disconnect: projectIds.map(id => ({ id })) } }
// Delete round-project associations
await prisma.roundProject.deleteMany({
where: { roundId: dummyRound.id }
})
console.log(`Deleted round-project associations`)
// Delete the projects
const projDeleted = await prisma.project.deleteMany({

View File

@@ -0,0 +1,69 @@
-- Step 1: Add sortOrder to Round
ALTER TABLE "Round" ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
-- Set initial sort order by creation date within each program
UPDATE "Round" r SET "sortOrder" = sub.rn - 1
FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY "programId" ORDER BY "createdAt") as rn
FROM "Round"
) sub
WHERE r.id = sub.id;
-- Step 2: Add programId to Project (nullable initially)
ALTER TABLE "Project" ADD COLUMN "programId" TEXT;
-- Populate programId from the round's program
UPDATE "Project" p SET "programId" = r."programId"
FROM "Round" r WHERE p."roundId" = r."id";
-- Make programId required
ALTER TABLE "Project" ALTER COLUMN "programId" SET NOT NULL;
-- Add foreign key constraint
ALTER TABLE "Project" ADD CONSTRAINT "Project_programId_fkey"
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Step 3: Create RoundProject table
CREATE TABLE "RoundProject" (
"id" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"status" "ProjectStatus" NOT NULL DEFAULT 'SUBMITTED',
"addedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RoundProject_pkey" PRIMARY KEY ("id")
);
-- Populate RoundProject from existing Project.roundId and status
INSERT INTO "RoundProject" ("id", "roundId", "projectId", "status", "addedAt")
SELECT gen_random_uuid(), p."roundId", p."id", p."status", p."createdAt"
FROM "Project" p;
-- Add indexes and unique constraint
CREATE UNIQUE INDEX "RoundProject_roundId_projectId_key" ON "RoundProject"("roundId", "projectId");
CREATE INDEX "RoundProject_roundId_idx" ON "RoundProject"("roundId");
CREATE INDEX "RoundProject_projectId_idx" ON "RoundProject"("projectId");
CREATE INDEX "RoundProject_status_idx" ON "RoundProject"("status");
-- Add foreign keys
ALTER TABLE "RoundProject" ADD CONSTRAINT "RoundProject_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "RoundProject" ADD CONSTRAINT "RoundProject_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Step 4: Drop old columns from Project
-- Drop the roundId foreign key constraint first
ALTER TABLE "Project" DROP CONSTRAINT "Project_roundId_fkey";
-- Drop the roundId index
DROP INDEX IF EXISTS "Project_roundId_idx";
-- Drop status index
DROP INDEX IF EXISTS "Project_status_idx";
-- Drop the columns
ALTER TABLE "Project" DROP COLUMN "roundId";
ALTER TABLE "Project" DROP COLUMN "status";
-- Add programId index
CREATE INDEX "Project_programId_idx" ON "Project"("programId");

View File

@@ -335,6 +335,7 @@ model Program {
// Relations
rounds Round[]
projects Project[]
learningResources LearningResource[]
partners Partner[]
applicationForms ApplicationForm[]
@@ -351,6 +352,7 @@ model Round {
slug String? @unique // URL-friendly identifier for public submissions
status RoundStatus @default(DRAFT)
roundType RoundType @default(EVALUATION)
sortOrder Int @default(0) // Progression order within program
// Submission window (for applicant portal)
submissionDeadline DateTime? // Deadline for project submissions
@@ -375,7 +377,7 @@ model Round {
// Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
projects Project[]
roundProjects RoundProject[]
assignments Assignment[]
evaluationForms EvaluationForm[]
gracePeriods GracePeriod[]
@@ -419,13 +421,12 @@ model EvaluationForm {
model Project {
id String @id @default(cuid())
roundId String
programId String
// Core fields
title String
teamName String?
description String? @db.Text
status ProjectStatus @default(SUBMITTED)
// Competition category
competitionCategory CompetitionCategory?
@@ -474,7 +475,8 @@ model Project {
updatedAt DateTime @updatedAt
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
roundProjects RoundProject[]
files ProjectFile[]
assignments Assignment[]
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
@@ -485,8 +487,7 @@ model Project {
awardVotes AwardVote[]
wonAwards SpecialAward[] @relation("AwardWinner")
@@index([roundId])
@@index([status])
@@index([programId])
@@index([tags])
@@index([submissionSource])
@@index([submittedByUserId])
@@ -495,6 +496,23 @@ model Project {
@@index([country])
}
model RoundProject {
id String @id @default(cuid())
roundId String
projectId String
status ProjectStatus @default(SUBMITTED)
addedAt DateTime @default(now())
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([roundId, projectId])
@@index([roundId])
@@index([projectId])
@@index([status])
}
model ProjectFile {
id String @id @default(cuid())
projectId String

View File

@@ -321,7 +321,7 @@ async function main() {
// Check if project already exists
const existingProject = await prisma.project.findFirst({
where: {
roundId: round.id,
programId: program.id,
OR: [
{ title: projectName },
{ submittedByEmail: email },
@@ -365,10 +365,9 @@ async function main() {
// Create project
const project = await prisma.project.create({
data: {
roundId: round.id,
programId: program.id,
title: projectName,
description: row['Comment ']?.trim() || null,
status: 'SUBMITTED',
competitionCategory: mapCategory(row['Category']),
oceanIssue: mapOceanIssue(row['Issue']),
country: extractCountry(row['Country']),
@@ -392,6 +391,15 @@ async function main() {
},
})
// Create round-project association
await prisma.roundProject.create({
data: {
roundId: round.id,
projectId: project.id,
status: 'SUBMITTED',
},
})
// Create team lead membership
await prisma.teamMember.create({
data: {
@@ -466,7 +474,7 @@ async function main() {
console.log('\nBackfilling missing country codes...\n')
let backfilled = 0
const nullCountryProjects = await prisma.project.findMany({
where: { roundId: round.id, country: null },
where: { programId: program.id, country: null },
select: { id: true, submittedByEmail: true, title: true },
})

View File

@@ -64,13 +64,14 @@ async function main() {
console.log(`Voting window: ${votingStart.toISOString()}${votingEnd.toISOString()}\n`)
// Get some projects to assign
const projects = await prisma.project.findMany({
// Get some projects to assign (via RoundProject)
const roundProjects = await prisma.roundProject.findMany({
where: { roundId: round.id },
take: 8,
orderBy: { createdAt: 'desc' },
select: { id: true, title: true },
orderBy: { addedAt: 'desc' },
select: { project: { select: { id: true, title: true } } },
})
const projects = roundProjects.map(rp => rp.project)
if (projects.length === 0) {
console.error('No projects found! Run seed-candidatures first.')