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:
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } }
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
|
||||
@@ -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.')
|
||||
|
||||
Reference in New Issue
Block a user