Pool, competition & round pages overhaul: deep-link context, inline project management, AI filtering UX, email toggle
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m30s

- Pool page: auto-select program from edition context, URL params for roundId/competitionId deep-linking, unassigned toggle, round badges column
- Competition detail: rich round cards with project counts, dates, jury info, status badges replacing flat list
- Round detail: readiness checklist, embedded assignment dashboard, file requirements in config tab, notifyOnEntry toggle
- ProjectStatesTable: search input, project links, quick-add dialog, pool links with context params
- FilteringDashboard: expandable rows with AI reasoning inline, quick override buttons, search, clickable stats
- Backend: notifyOnEntry in round configJson triggers announcement emails on project assignment via existing email infra

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 08:23:40 +01:00
parent 7f334ed095
commit 845554fdb8
7 changed files with 1197 additions and 496 deletions

View File

@@ -42,15 +42,16 @@ export const programRouter = router({
: undefined,
})
// Return programs with rounds flattened
// Return programs with rounds flattened, preserving competitionId
return programs.map((p) => {
const allRounds = (p as any).competitions?.flatMap((c: any) => c.rounds || []) || []
const allRounds = (p as any).competitions?.flatMap((c: any) =>
(c.rounds || []).map((round: any) => ({ ...round, competitionId: c.id }))
) || []
return {
...p,
// Provide `stages` as alias for backward compatibility
stages: allRounds.map((round: any) => ({
...round,
// Backward-compatible _count shape
_count: {
projects: round._count?.projectRoundStates || 0,
assignments: round._count?.assignments || 0,
@@ -60,6 +61,7 @@ export const programRouter = router({
rounds: allRounds.map((round: any) => ({
id: round.id,
name: round.name,
competitionId: round.competitionId,
status: round.status,
votingEndAt: round.windowCloseAt,
_count: {
@@ -95,8 +97,10 @@ export const programRouter = router({
},
})
// Flatten rounds from all competitions
const allRounds = (program as any).competitions?.flatMap((c: any) => c.rounds || []) || []
// Flatten rounds from all competitions, preserving competitionId
const allRounds = (program as any).competitions?.flatMap((c: any) =>
(c.rounds || []).map((round: any) => ({ ...round, competitionId: c.id }))
) || []
const rounds = allRounds.map((round: any) => ({
...round,
_count: {

View File

@@ -2,38 +2,120 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import { sendAnnouncementEmail } from '@/lib/email'
import type { PrismaClient } from '@prisma/client'
/**
* Send round-entry notification emails to project team members.
* Fire-and-forget: errors are logged but never block the assignment.
*/
async function sendRoundEntryEmails(
prisma: PrismaClient,
projectIds: string[],
roundName: string,
) {
try {
// Fetch projects with team members' user emails + fallback submittedByEmail
const projects = await prisma.project.findMany({
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: {
user: { select: { email: true, name: true } },
},
},
},
})
const emailPromises: Promise<void>[] = []
for (const project of projects) {
// Collect unique emails for this project
const recipients = new Map<string, string | null>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, tm.user.name)
}
}
// Fallback: if no team members have emails, use submittedByEmail
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
}
for (const [email, name] of recipients) {
emailPromises.push(
sendAnnouncementEmail(
email,
name,
`Your project has entered: ${roundName}`,
`Your project "${project.title}" has been added to the round "${roundName}" in the Monaco Ocean Protection Challenge. You will receive further instructions as the round progresses.`,
'View Your Dashboard',
`${process.env.NEXTAUTH_URL || 'https://monaco-opc.com'}/dashboard`,
).catch((err) => {
console.error(`[round-entry-email] Failed to send to ${email}:`, err)
}),
)
}
}
await Promise.allSettled(emailPromises)
} catch (err) {
console.error('[round-entry-email] Failed to send round entry emails:', err)
}
}
/**
* Project Pool Router
*
* Manages the pool of unassigned projects (projects not yet assigned to any stage).
* Provides procedures for listing unassigned projects and bulk assigning them to stages.
* Manages the project pool for assigning projects to competition rounds.
* Shows all projects by default, with optional filtering for unassigned-only
* or projects not yet in a specific round.
*/
export const projectPoolRouter = router({
/**
* List unassigned projects with filtering and pagination
* Projects not assigned to any round
* List projects in the pool with filtering and pagination.
* By default shows ALL projects. Use filters to narrow:
* - unassignedOnly: true → only projects not in any round
* - excludeRoundId: "..." → only projects not already in that round
*/
listUnassigned: adminProcedure
.input(
z.object({
programId: z.string(), // Required - must specify which program
programId: z.string(),
competitionCategory: z
.enum(['STARTUP', 'BUSINESS_CONCEPT'])
.optional(),
search: z.string().optional(), // Search in title, teamName, description
search: z.string().optional(),
unassignedOnly: z.boolean().optional().default(false),
excludeRoundId: z.string().optional(),
page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(200).default(20),
perPage: z.number().int().min(1).max(200).default(50),
})
)
.query(async ({ ctx, input }) => {
const { programId, competitionCategory, search, page, perPage } = input
const { programId, competitionCategory, search, unassignedOnly, excludeRoundId, page, perPage } = input
const skip = (page - 1) * perPage
// Build where clause
const where: Record<string, unknown> = {
programId,
projectRoundStates: { none: {} }, // Only unassigned projects (not in any round)
}
// Optional: only show projects not in any round
if (unassignedOnly) {
where.projectRoundStates = { none: {} }
}
// Optional: exclude projects already in a specific round
if (excludeRoundId && !unassignedOnly) {
where.projectRoundStates = {
none: { roundId: excludeRoundId },
}
}
// Filter by competition category
@@ -77,6 +159,22 @@ export const projectPoolRouter = router({
teamMembers: true,
},
},
projectRoundStates: {
select: {
roundId: true,
state: true,
round: {
select: {
name: true,
roundType: true,
sortOrder: true,
},
},
},
orderBy: {
round: { sortOrder: 'asc' },
},
},
},
}),
ctx.prisma.project.count({ where }),
@@ -93,21 +191,11 @@ export const projectPoolRouter = router({
/**
* Bulk assign projects to a round
*
* Validates that:
* - All projects exist
* - Round exists
*
* Creates:
* - RoundAssignment entries for each project
* - Project.status updated to 'ASSIGNED'
* - ProjectStatusHistory records for each project
* - Audit log
*/
assignToRound: adminProcedure
.input(
z.object({
projectIds: z.array(z.string()).min(1).max(200), // Max 200 projects at once
projectIds: z.array(z.string()).min(1).max(200),
roundId: z.string(),
})
)
@@ -136,10 +224,10 @@ export const projectPoolRouter = router({
})
}
// Verify round exists
// Verify round exists and get config
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true },
select: { id: true, name: true, configJson: true },
})
// Step 2: Perform bulk assignment in a transaction
@@ -192,6 +280,12 @@ export const projectPoolRouter = router({
userAgent: ctx.userAgent,
})
// Send round-entry notification emails if enabled (fire-and-forget)
const config = (round.configJson as Record<string, unknown>) || {}
if (config.notifyOnEntry) {
void sendRoundEntryEmails(ctx.prisma as unknown as PrismaClient, projectIds, round.name)
}
return {
success: true,
assignedCount: result.count,
@@ -200,7 +294,8 @@ export const projectPoolRouter = router({
}),
/**
* Assign ALL unassigned projects in a program to a round (server-side, no ID limit)
* Assign ALL matching projects in a program to a round (server-side, no ID limit).
* Skips projects already in the target round.
*/
assignAllToRound: adminProcedure
.input(
@@ -208,22 +303,33 @@ export const projectPoolRouter = router({
programId: z.string(),
roundId: z.string(),
competitionCategory: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
unassignedOnly: z.boolean().optional().default(false),
})
)
.mutation(async ({ ctx, input }) => {
const { programId, roundId, competitionCategory } = input
const { programId, roundId, competitionCategory, unassignedOnly } = input
// Verify round exists
await ctx.prisma.round.findUniqueOrThrow({
// Verify round exists and get config
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true },
select: { id: true, name: true, configJson: true },
})
// Find all unassigned projects
// Find projects to assign
const where: Record<string, unknown> = {
programId,
projectRoundStates: { none: {} },
}
if (unassignedOnly) {
// Only projects not in any round
where.projectRoundStates = { none: {} }
} else {
// All projects not already in the target round
where.projectRoundStates = {
none: { roundId },
}
}
if (competitionCategory) {
where.competitionCategory = competitionCategory
}
@@ -271,12 +377,19 @@ export const projectPoolRouter = router({
roundId,
programId,
competitionCategory: competitionCategory || 'ALL',
unassignedOnly,
projectCount: projectIds.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Send round-entry notification emails if enabled (fire-and-forget)
const config = (round.configJson as Record<string, unknown>) || {}
if (config.notifyOnEntry) {
void sendRoundEntryEmails(ctx.prisma as unknown as PrismaClient, projectIds, round.name)
}
return { success: true, assignedCount: result.count, roundId }
}),
})