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
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:
@@ -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: {
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user