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 + + )} +
+ )} +
+ +