From cfee3bc8a962508c6ce9bd33939f46b88fe8a07c Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 3 Mar 2026 19:14:41 +0100 Subject: [PATCH] feat: round finalization with ranking-based outcomes + award pool notifications - processRoundClose EVALUATION uses ranking scores + advanceMode config (threshold vs count) to auto-set proposedOutcome instead of defaulting all to PASSED - Advancement emails generate invite tokens for passwordless users with "Create Your Account" CTA; rejection emails have no link - Finalization UI shows account stats (invite vs dashboard link counts) - Fixed getFinalizationSummary ranking query (was using non-existent rankingsJson) - New award pool notification system: getAwardSelectionNotificationTemplate email, notifyEligibleProjects mutation with invite token generation, "Notify Pool" button on award detail page with custom message dialog Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + docker/docker-compose.dev.yml | 2 +- docker/docker-entrypoint.sh | 21 + prisma/schema.prisma | 20 +- prisma/seed.ts | 33 +- src/app/(admin)/admin/awards/[id]/page.tsx | 90 +- src/app/(admin)/admin/messages/page.tsx | 57 +- .../(admin)/admin/projects/[id]/edit/page.tsx | 8 +- src/app/(admin)/admin/projects/page.tsx | 4 +- .../(admin)/admin/rounds/[roundId]/page.tsx | 156 ++-- src/app/(applicant)/applicant/page.tsx | 82 +- src/app/(applicant)/applicant/team/page.tsx | 501 +++++++---- src/app/(auth)/accept-invite/page.tsx | 14 +- .../(auth)/onboarding/applicant-wizard.tsx | 120 ++- src/app/(auth)/set-password/page.tsx | 4 +- .../api/cron/process-grace-periods/route.ts | 47 + .../admin/round/bulk-invite-button.tsx | 82 ++ .../admin/round/email-preview-dialog.tsx | 130 +++ .../admin/round/finalization-tab.tsx | 665 ++++++++++++++ .../admin/round/notify-advanced-button.tsx | 62 ++ .../admin/round/notify-rejected-button.tsx | 61 ++ .../admin/round/project-states-table.tsx | 310 ++++++- .../applicant/competition-timeline.tsx | 156 +++- .../applicant/mentoring-request-card.tsx | 133 +++ src/components/applicant/withdraw-button.tsx | 70 ++ src/components/layouts/applicant-nav.tsx | 4 +- src/components/layouts/role-nav.tsx | 6 +- src/components/shared/file-viewer.tsx | 4 +- src/components/shared/notification-bell.tsx | 8 + src/components/shared/project-logo-upload.tsx | 71 +- .../shared/requirement-upload-slot.tsx | 138 ++- src/components/shared/user-avatar.tsx | 5 +- src/lib/email.ts | 242 +++++ src/server/routers/applicant.ts | 345 +++++++- src/server/routers/evaluation.ts | 7 + src/server/routers/filtering.ts | 6 + src/server/routers/mentor.ts | 18 + src/server/routers/message.ts | 61 +- src/server/routers/project-pool.ts | 297 +++++-- src/server/routers/round.ts | 571 ++++++++++-- src/server/routers/roundEngine.ts | 111 +++ src/server/routers/specialAward.ts | 177 ++++ src/server/routers/user.ts | 37 +- src/server/services/deliberation.ts | 14 +- src/server/services/round-engine.ts | 156 +++- src/server/services/round-finalization.ts | 830 ++++++++++++++++++ src/server/utils/invite.ts | 25 + src/types/competition-configs.ts | 6 +- 48 files changed, 5294 insertions(+), 676 deletions(-) create mode 100644 src/app/api/cron/process-grace-periods/route.ts create mode 100644 src/components/admin/round/bulk-invite-button.tsx create mode 100644 src/components/admin/round/email-preview-dialog.tsx create mode 100644 src/components/admin/round/finalization-tab.tsx create mode 100644 src/components/admin/round/notify-advanced-button.tsx create mode 100644 src/components/admin/round/notify-rejected-button.tsx create mode 100644 src/components/applicant/mentoring-request-card.tsx create mode 100644 src/components/applicant/withdraw-button.tsx create mode 100644 src/server/services/round-finalization.ts create mode 100644 src/server/utils/invite.ts diff --git a/.gitignore b/.gitignore index 7e53f83..5f9ee0d 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ build-output.txt # Misc *.log .vercel + +# Private keys and secrets +private/ diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 2a6e213..83d9c95 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -8,7 +8,7 @@ services: image: postgres:16-alpine container_name: mopc-postgres-dev ports: - - "5432:5432" + - "5433:5432" environment: - POSTGRES_USER=${POSTGRES_USER:-mopc} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-devpassword} diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index a7f2a0e..f01577b 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -37,5 +37,26 @@ fi echo "==> Syncing notification email settings..." npx tsx prisma/seed-notification-settings.ts || echo "WARNING: Notification settings sync failed." +# Sync team lead links only if there are unlinked submitters +UNLINKED_COUNT=$(node -e " + const { PrismaClient } = require('@prisma/client'); + const p = new PrismaClient(); + p.\$queryRaw\` + SELECT COUNT(*)::int AS c FROM \"Project\" p + WHERE p.\"submittedByUserId\" IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM \"TeamMember\" tm + WHERE tm.\"projectId\" = p.id AND tm.\"userId\" = p.\"submittedByUserId\" + ) + \`.then(r => { console.log(r[0].c); p.\$disconnect(); }).catch(() => { console.log('0'); p.\$disconnect(); }); +" 2>/dev/null || echo "0") + +if [ "$UNLINKED_COUNT" != "0" ]; then + echo "==> Syncing ${UNLINKED_COUNT} unlinked team lead links..." + npx tsx prisma/seed-team-leads.ts || echo "WARNING: Team lead sync failed." +else + echo "==> Team lead links already synced, skipping." +fi + echo "==> Starting application..." exec node server.js diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c4f4791..1feea7b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2193,6 +2193,11 @@ model Round { submissionWindowId String? specialAwardId String? + // Finalization + gracePeriodEndsAt DateTime? + finalizedAt DateTime? + finalizedBy String? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -2237,13 +2242,14 @@ model Round { } model ProjectRoundState { - id String @id @default(cuid()) - projectId String - roundId String - state ProjectRoundStateValue @default(PENDING) - enteredAt DateTime @default(now()) - exitedAt DateTime? - metadataJson Json? @db.JsonB + id String @id @default(cuid()) + projectId String + roundId String + state ProjectRoundStateValue @default(PENDING) + proposedOutcome ProjectRoundStateValue? + enteredAt DateTime @default(now()) + exitedAt DateTime? + metadataJson Json? @db.JsonB createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/prisma/seed.ts b/prisma/seed.ts index ff47da9..e3c06ff 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -559,7 +559,7 @@ async function main() { }) // Create project - await prisma.project.create({ + const createdProject = await prisma.project.create({ data: { programId: program.id, title: projectName || `Project by ${name}`, @@ -584,13 +584,24 @@ async function main() { }, }) + // Link submitter as team lead + await prisma.teamMember.upsert({ + where: { projectId_userId: { projectId: createdProject.id, userId: user.id } }, + update: {}, + create: { + projectId: createdProject.id, + userId: user.id, + role: 'LEAD', + }, + }) + projectCount++ if (projectCount % 50 === 0) { console.log(` ... ${projectCount} projects created`) } } - console.log(` ✓ Created ${projectCount} projects`) + console.log(` ✓ Created ${projectCount} projects (with team lead links)`) if (skippedNoEmail > 0) { console.log(` ⚠ Skipped ${skippedNoEmail} rows with no valid email`) } @@ -864,6 +875,24 @@ async function main() { } console.log(` ✓ ${rounds.length - 1} advancement rules created`) + // --- Assign all projects to intake round (COMPLETED, since intake is closed) --- + const intakeRound = rounds[0] + const allProjects = await prisma.project.findMany({ + where: { programId: program.id }, + select: { id: true }, + }) + if (allProjects.length > 0) { + await prisma.projectRoundState.createMany({ + data: allProjects.map((p) => ({ + projectId: p.id, + roundId: intakeRound.id, + state: 'COMPLETED' as const, + })), + skipDuplicates: true, + }) + console.log(` ✓ ${allProjects.length} projects assigned to intake round (COMPLETED)`) + } + // --- Round-Submission Visibility (which rounds can see which submission windows) --- // R2 and R3 can see R1 docs, R5 can see R4 docs const visibilityLinks = [ diff --git a/src/app/(admin)/admin/awards/[id]/page.tsx b/src/app/(admin)/admin/awards/[id]/page.tsx index 0310e0e..27eb32f 100644 --- a/src/app/(admin)/admin/awards/[id]/page.tsx +++ b/src/app/(admin)/admin/awards/[id]/page.tsx @@ -53,6 +53,7 @@ import { } from '@/components/ui/dialog' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' import { Progress } from '@/components/ui/progress' import { UserAvatar } from '@/components/shared/user-avatar' import { AnimatedCard } from '@/components/shared/animated-container' @@ -91,6 +92,7 @@ import { AlertCircle, Layers, Info, + Mail, } from 'lucide-react' const STATUS_COLORS: Record = { @@ -155,6 +157,8 @@ export default function AwardDetailPage({ const [activeTab, setActiveTab] = useState('eligibility') const [addRoundOpen, setAddRoundOpen] = useState(false) const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string }) + const [notifyDialogOpen, setNotifyDialogOpen] = useState(false) + const [notifyCustomMessage, setNotifyCustomMessage] = useState('') // Pagination for eligibility list const [eligibilityPage, setEligibilityPage] = useState(1) @@ -283,6 +287,19 @@ export default function AwardDetailPage({ onError: (err) => toast.error(err.message), }) + const { data: notifyStats } = trpc.specialAward.getNotificationStats.useQuery( + { awardId }, + { enabled: notifyDialogOpen } + ) + const notifyEligible = trpc.specialAward.notifyEligibleProjects.useMutation({ + onSuccess: (result) => { + toast.success(`Notified ${result.notified} projects (${result.emailsSent} emails sent${result.emailsFailed ? `, ${result.emailsFailed} failed` : ''})`) + setNotifyDialogOpen(false) + setNotifyCustomMessage('') + }, + onError: (err) => toast.error(err.message), + }) + const handleStatusChange = async ( status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED' ) => { @@ -468,13 +485,72 @@ export default function AwardDetailPage({ )} {award.status === 'NOMINATIONS_OPEN' && ( - + <> + + + + + + + Notify Eligible Projects + + Send "Selected for {award.name}" emails to all {award.eligibleCount} eligible projects. + + +
+ {notifyStats && ( +
+ {notifyStats.needsInvite > 0 && ( + + {notifyStats.needsInvite} will receive Create Account link + + )} + {notifyStats.hasAccount > 0 && ( + + {notifyStats.hasAccount} will receive Dashboard link + + )} +
+ )} +
+ +