Auto-assign projects to first round, auto-filter on close, pipeline UX consolidation
Build and Push Docker Image / build (push) Successful in 10m30s Details

- New projects (admin create, CSV import, public form) auto-assign to program's
  first round (by sortOrder) when no round is specified
- Closing a FILTERING round auto-starts filtering job (configurable via
  autoFilterOnClose setting, defaults to true)
- Add SUBMISSION_RECEIVED notification type for confirming submissions
- Replace separate List/Pipeline toggle with integrated pipeline view below
  the sortable round list
- Add autoFilterOnClose toggle to filtering round type settings UI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-12 15:06:11 +01:00
parent 2a5fa463b3
commit 7b85fd9602
11 changed files with 204 additions and 101 deletions

View File

@ -64,6 +64,7 @@ const TEAM_NOTIFICATION_OPTIONS = [
{ value: 'ADVANCED_FINAL', label: 'Selected as Finalist', description: 'Congratulates team for being selected as finalist' }, { value: 'ADVANCED_FINAL', label: 'Selected as Finalist', description: 'Congratulates team for being selected as finalist' },
{ value: 'NOT_SELECTED', label: 'Not Selected', description: 'Informs team they were not selected to continue' }, { value: 'NOT_SELECTED', label: 'Not Selected', description: 'Informs team they were not selected to continue' },
{ value: 'WINNER_ANNOUNCEMENT', label: 'Winner Announcement', description: 'Announces the team as a winner' }, { value: 'WINNER_ANNOUNCEMENT', label: 'Winner Announcement', description: 'Announces the team as a winner' },
{ value: 'SUBMISSION_RECEIVED', label: 'Submission Received', description: 'Confirms to the team that their submission has been received' },
] ]
interface PageProps { interface PageProps {

View File

@ -46,6 +46,7 @@ const TEAM_NOTIFICATION_OPTIONS = [
{ value: 'ADVANCED_FINAL', label: 'Selected as Finalist', description: 'Congratulates team for being selected as finalist' }, { value: 'ADVANCED_FINAL', label: 'Selected as Finalist', description: 'Congratulates team for being selected as finalist' },
{ value: 'NOT_SELECTED', label: 'Not Selected', description: 'Informs team they were not selected to continue' }, { value: 'NOT_SELECTED', label: 'Not Selected', description: 'Informs team they were not selected to continue' },
{ value: 'WINNER_ANNOUNCEMENT', label: 'Winner Announcement', description: 'Announces the team as a winner' }, { value: 'WINNER_ANNOUNCEMENT', label: 'Winner Announcement', description: 'Announces the team as a winner' },
{ value: 'SUBMISSION_RECEIVED', label: 'Submission Received', description: 'Confirms to the team that their submission has been received' },
] ]
const createRoundSchema = z.object({ const createRoundSchema = z.object({

View File

@ -62,9 +62,6 @@ import {
Trash2, Trash2,
Loader2, Loader2,
GripVertical, GripVertical,
ArrowRight,
List,
GitBranchPlus,
} from 'lucide-react' } from 'lucide-react'
import { format, isPast, isFuture } from 'date-fns' import { format, isPast, isFuture } from 'date-fns'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -84,7 +81,7 @@ type RoundData = {
} }
} }
function RoundsContent({ viewMode }: { viewMode: 'list' | 'pipeline' }) { function RoundsContent() {
const { data: programs, isLoading } = trpc.program.list.useQuery({ const { data: programs, isLoading } = trpc.program.list.useQuery({
includeRounds: true, includeRounds: true,
}) })
@ -110,45 +107,6 @@ function RoundsContent({ viewMode }: { viewMode: 'list' | 'pipeline' }) {
) )
} }
if (viewMode === 'pipeline') {
return (
<div className="space-y-6">
{programs.map((program, index) => (
<AnimatedCard key={program.id} index={index}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">{program.year} Edition</CardTitle>
<CardDescription>
{program.name} - {program.status}
</CardDescription>
</div>
<Button asChild>
<Link href={`/admin/rounds/new?program=${program.id}`}>
<Plus className="mr-2 h-4 w-4" />
Add Round
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
{(program.rounds && program.rounds.length > 0) ? (
<RoundPipeline rounds={program.rounds} programName={program.name} />
) : (
<div className="text-center py-8 text-muted-foreground">
<Calendar className="mx-auto h-8 w-8 mb-2 opacity-50" />
<p>No rounds created yet</p>
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
))}
</div>
)
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{programs.map((program, index) => ( {programs.map((program, index) => (
@ -271,32 +229,10 @@ function ProgramRounds({ program }: { program: any }) {
</SortableContext> </SortableContext>
</DndContext> </DndContext>
{/* Flow visualization */} {/* Pipeline visualization */}
{rounds.length > 1 && ( {rounds.length > 1 && (
<div className="mt-6 pt-4 border-t"> <div className="mt-6 pt-4 border-t">
<p className="text-xs text-muted-foreground mb-3 uppercase tracking-wide font-medium"> <RoundPipeline rounds={rounds} programName={program.name} />
Project Flow
</p>
<div className="flex items-center gap-2 flex-wrap">
{rounds.map((round, index) => (
<div key={round.id} className="flex items-center gap-2">
<div className="flex items-center gap-2 bg-muted/50 rounded-lg px-3 py-1.5">
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-bold">
{index}
</span>
<span className="text-sm font-medium truncate max-w-[120px]">
{round.name}
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{round._count?.projects || 0}
</Badge>
</div>
{index < rounds.length - 1 && (
<ArrowRight className="h-4 w-4 text-muted-foreground/50" />
)}
</div>
))}
</div>
</div> </div>
)} )}
</div> </div>
@ -711,43 +647,19 @@ function RoundsListSkeleton() {
} }
export default function RoundsPage() { export default function RoundsPage() {
const [viewMode, setViewMode] = useState<'list' | 'pipeline'>('list')
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div>
<div> <h1 className="text-2xl font-semibold tracking-tight">Rounds</h1>
<h1 className="text-2xl font-semibold tracking-tight">Rounds</h1> <p className="text-muted-foreground">
<p className="text-muted-foreground"> Manage selection rounds and voting periods
Manage selection rounds and voting periods </p>
</p>
</div>
<div className="flex items-center gap-1 rounded-lg border p-1">
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
className="h-8 px-3"
onClick={() => setViewMode('list')}
>
<List className="mr-1.5 h-4 w-4" />
List
</Button>
<Button
variant={viewMode === 'pipeline' ? 'default' : 'ghost'}
size="sm"
className="h-8 px-3"
onClick={() => setViewMode('pipeline')}
>
<GitBranchPlus className="mr-1.5 h-4 w-4" />
Pipeline
</Button>
</div>
</div> </div>
{/* Content */} {/* Content */}
<Suspense fallback={<RoundsListSkeleton />}> <Suspense fallback={<RoundsListSkeleton />}>
<RoundsContent viewMode={viewMode} /> <RoundsContent />
</Suspense> </Suspense>
</div> </div>
) )

View File

@ -260,6 +260,31 @@ function FilteringSettings({
)} )}
</div> </div>
{/* Auto-Filter on Close */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Auto-Run Filtering on Close</Label>
<p className="text-sm text-muted-foreground">
Automatically start filtering when this round is closed
</p>
</div>
<Switch
checked={settings.autoFilterOnClose}
onCheckedChange={(v) =>
onChange({ ...settings, autoFilterOnClose: v })
}
/>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
When enabled, closing this round will automatically run the configured filtering rules.
Results still require admin review before finalization.
</AlertDescription>
</Alert>
</div>
{/* Display Options */} {/* Display Options */}
<div className="space-y-4"> <div className="space-y-4">
<h5 className="text-sm font-medium">Display Options</h5> <h5 className="text-sm font-medium">Display Options</h5>

View File

@ -5,8 +5,10 @@ import { Prisma, CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma
import { import {
createNotification, createNotification,
notifyAdmins, notifyAdmins,
notifyProjectTeam,
NotificationTypes, NotificationTypes,
} from '../services/in-app-notification' } from '../services/in-app-notification'
import { getFirstRoundForProgram } from '@/server/utils/round-helpers'
import { checkRateLimit } from '@/lib/rate-limit' import { checkRateLimit } from '@/lib/rate-limit'
import { logAudit } from '@/server/utils/audit' import { logAudit } from '@/server/utils/audit'
import { parseWizardConfig } from '@/lib/wizard-config' import { parseWizardConfig } from '@/lib/wizard-config'
@ -458,6 +460,18 @@ export const applicationRouter = router({
}, },
}) })
// Auto-assign to first round if project has no roundId (edition-wide mode)
let assignedRound: { id: string; name: string; entryNotificationType: string | null } | null = null
if (!project.roundId) {
assignedRound = await getFirstRoundForProgram(ctx.prisma, program.id)
if (assignedRound) {
await ctx.prisma.project.update({
where: { id: project.id },
data: { roundId: assignedRound.id },
})
}
}
// Create team lead membership // Create team lead membership
await ctx.prisma.teamMember.create({ await ctx.prisma.teamMember.create({
data: { data: {
@ -510,6 +524,7 @@ export const applicationRouter = router({
source: 'public_application_form', source: 'public_application_form',
title: data.projectName, title: data.projectName,
category: data.competitionCategory, category: data.competitionCategory,
autoAssignedRound: assignedRound?.name || null,
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
@ -544,6 +559,26 @@ export const applicationRouter = router({
}, },
}) })
// Send SUBMISSION_RECEIVED notification if the round is configured for it
if (assignedRound?.entryNotificationType === 'SUBMISSION_RECEIVED') {
try {
await notifyProjectTeam(project.id, {
type: NotificationTypes.SUBMISSION_RECEIVED,
title: 'Submission Received',
message: `Your submission "${data.projectName}" has been received and is now under review.`,
linkUrl: `/team/projects/${project.id}`,
linkLabel: 'View Submission',
metadata: {
projectName: data.projectName,
roundName: assignedRound.name,
programName: program.name,
},
})
} catch {
// Never fail on notification
}
}
return { return {
success: true, success: true,
projectId: project.id, projectId: project.id,
@ -816,6 +851,18 @@ export const applicationRouter = router({
}, },
}) })
// Auto-assign to first round if project has no roundId
let draftAssignedRound: { id: string; name: string; entryNotificationType: string | null } | null = null
if (!updated.roundId) {
draftAssignedRound = await getFirstRoundForProgram(ctx.prisma, updated.programId)
if (draftAssignedRound) {
await ctx.prisma.project.update({
where: { id: updated.id },
data: { roundId: draftAssignedRound.id },
})
}
}
// Audit log // Audit log
try { try {
await logAudit({ await logAudit({
@ -828,6 +875,7 @@ export const applicationRouter = router({
source: 'draft_submission', source: 'draft_submission',
title: data.projectName, title: data.projectName,
category: data.competitionCategory, category: data.competitionCategory,
autoAssignedRound: draftAssignedRound?.name || null,
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
@ -836,6 +884,25 @@ export const applicationRouter = router({
// Never throw on audit failure // Never throw on audit failure
} }
// Send SUBMISSION_RECEIVED notification if the round is configured for it
if (draftAssignedRound?.entryNotificationType === 'SUBMISSION_RECEIVED') {
try {
await notifyProjectTeam(updated.id, {
type: NotificationTypes.SUBMISSION_RECEIVED,
title: 'Submission Received',
message: `Your submission "${data.projectName}" has been received and is now under review.`,
linkUrl: `/team/projects/${updated.id}`,
linkLabel: 'View Submission',
metadata: {
projectName: data.projectName,
roundName: draftAssignedRound.name,
},
})
} catch {
// Never fail on notification
}
}
return { return {
success: true, success: true,
projectId: updated.id, projectId: updated.id,

View File

@ -11,8 +11,8 @@ import {
NotificationTypes, NotificationTypes,
} from '../services/in-app-notification' } from '../services/in-app-notification'
// Background job execution function // Background job execution function (exported for auto-filtering on round close)
async function runFilteringJob(jobId: string, roundId: string, userId: string) { export async function runFilteringJob(jobId: string, roundId: string, userId: string) {
try { try {
// Update job to running // Update job to running
await prisma.filteringJob.update({ await prisma.filteringJob.update({

View File

@ -8,6 +8,7 @@ import {
notifyProjectTeam, notifyProjectTeam,
NotificationTypes, NotificationTypes,
} from '../services/in-app-notification' } from '../services/in-app-notification'
import { getFirstRoundForProgram } from '@/server/utils/round-helpers'
import { normalizeCountryToCode } from '@/lib/countries' import { normalizeCountryToCode } from '@/lib/countries'
import { logAudit } from '../utils/audit' import { logAudit } from '../utils/audit'
import { sendInvitationEmail } from '@/lib/email' import { sendInvitationEmail } from '@/lib/email'
@ -459,10 +460,19 @@ export const projectRouter = router({
: undefined : undefined
const { project, membersToInvite } = await ctx.prisma.$transaction(async (tx) => { const { project, membersToInvite } = await ctx.prisma.$transaction(async (tx) => {
// Auto-assign to first round if no roundId provided
let resolvedRoundId = input.roundId || null
if (!resolvedRoundId) {
const firstRound = await getFirstRoundForProgram(tx, resolvedProgramId)
if (firstRound) {
resolvedRoundId = firstRound.id
}
}
const created = await tx.project.create({ const created = await tx.project.create({
data: { data: {
programId: resolvedProgramId, programId: resolvedProgramId,
roundId: input.roundId || null, roundId: resolvedRoundId,
title: input.title, title: input.title,
teamName: input.teamName, teamName: input.teamName,
description: input.description, description: input.description,
@ -882,6 +892,15 @@ export const projectRouter = router({
} }
} }
// Auto-assign to first round if no roundId provided
let resolvedImportRoundId = input.roundId || null
if (!resolvedImportRoundId) {
const firstRound = await getFirstRoundForProgram(ctx.prisma, input.programId)
if (firstRound) {
resolvedImportRoundId = firstRound.id
}
}
// Create projects in a transaction // Create projects in a transaction
const result = await ctx.prisma.$transaction(async (tx) => { const result = await ctx.prisma.$transaction(async (tx) => {
// Create all projects with roundId and programId // Create all projects with roundId and programId
@ -890,7 +909,7 @@ export const projectRouter = router({
return { return {
...rest, ...rest,
programId: input.programId, programId: input.programId,
roundId: input.roundId!, roundId: resolvedImportRoundId,
status: 'SUBMITTED' as const, status: 'SUBMITTED' as const,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
} }

View File

@ -4,9 +4,12 @@ import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure } from '../trpc'
import { import {
notifyRoundJury, notifyRoundJury,
notifyAdmins,
NotificationTypes, NotificationTypes,
} from '../services/in-app-notification' } from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit' import { logAudit } from '@/server/utils/audit'
import { runFilteringJob } from './filtering'
import { prisma as globalPrisma } from '@/lib/prisma'
// Valid round status transitions (state machine) // Valid round status transitions (state machine)
const VALID_ROUND_TRANSITIONS: Record<string, string[]> = { const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
@ -437,6 +440,60 @@ export const roundRouter = router({
} }
} }
// Auto-run filtering when a FILTERING round is closed (if enabled in settings)
const roundSettings = (round.settingsJson as Record<string, unknown>) || {}
const autoFilterEnabled = roundSettings.autoFilterOnClose !== false // Default to true
if (input.status === 'CLOSED' && round.roundType === 'FILTERING' && autoFilterEnabled) {
try {
const [filteringRules, projectCount] = await Promise.all([
ctx.prisma.filteringRule.findMany({
where: { roundId: input.id, isActive: true },
}),
ctx.prisma.project.count({ where: { roundId: input.id } }),
])
// Check for existing running job
const existingJob = await ctx.prisma.filteringJob.findFirst({
where: { roundId: input.id, status: 'RUNNING' },
})
if (filteringRules.length > 0 && projectCount > 0 && !existingJob) {
// Create filtering job
const job = await globalPrisma.filteringJob.create({
data: {
roundId: input.id,
status: 'PENDING',
totalProjects: projectCount,
},
})
// Start background execution (non-blocking)
setImmediate(() => {
runFilteringJob(job.id, input.id, ctx.user.id).catch(console.error)
})
// Notify admins that auto-filtering has started
await notifyAdmins({
type: NotificationTypes.FILTERING_COMPLETE,
title: 'Auto-Filtering Started',
message: `Filtering automatically started for "${round.name}" after closing. ${projectCount} projects will be processed.`,
linkUrl: `/admin/rounds/${input.id}/filtering`,
linkLabel: 'View Progress',
metadata: {
roundId: input.id,
roundName: round.name,
projectCount,
ruleCount: filteringRules.length,
autoTriggered: true,
},
})
}
} catch (error) {
// Auto-filtering failure should not block round closure
console.error('[Auto-Filtering] Failed to start:', error)
}
}
return round return round
}), }),

View File

@ -77,6 +77,7 @@ export const NotificationTypes = {
FEEDBACK_AVAILABLE: 'FEEDBACK_AVAILABLE', FEEDBACK_AVAILABLE: 'FEEDBACK_AVAILABLE',
EVENT_INVITATION: 'EVENT_INVITATION', EVENT_INVITATION: 'EVENT_INVITATION',
WINNER_ANNOUNCEMENT: 'WINNER_ANNOUNCEMENT', WINNER_ANNOUNCEMENT: 'WINNER_ANNOUNCEMENT',
SUBMISSION_RECEIVED: 'SUBMISSION_RECEIVED',
CERTIFICATE_READY: 'CERTIFICATE_READY', CERTIFICATE_READY: 'CERTIFICATE_READY',
PROGRAM_NEWSLETTER: 'PROGRAM_NEWSLETTER', PROGRAM_NEWSLETTER: 'PROGRAM_NEWSLETTER',
@ -107,6 +108,7 @@ export const NotificationIcons: Record<string, string> = {
[NotificationTypes.MENTEE_ADVANCED]: 'TrendingUp', [NotificationTypes.MENTEE_ADVANCED]: 'TrendingUp',
[NotificationTypes.MENTEE_WON]: 'Trophy', [NotificationTypes.MENTEE_WON]: 'Trophy',
[NotificationTypes.APPLICATION_SUBMITTED]: 'CheckCircle', [NotificationTypes.APPLICATION_SUBMITTED]: 'CheckCircle',
[NotificationTypes.SUBMISSION_RECEIVED]: 'Inbox',
[NotificationTypes.ADVANCED_SEMIFINAL]: 'TrendingUp', [NotificationTypes.ADVANCED_SEMIFINAL]: 'TrendingUp',
[NotificationTypes.ADVANCED_FINAL]: 'Star', [NotificationTypes.ADVANCED_FINAL]: 'Star',
[NotificationTypes.MENTOR_ASSIGNED]: 'GraduationCap', [NotificationTypes.MENTOR_ASSIGNED]: 'GraduationCap',

View File

@ -0,0 +1,15 @@
/**
* Get the first round (by sortOrder) for a program.
* Used to auto-assign new projects to the intake round.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function getFirstRoundForProgram(
prisma: any,
programId: string
): Promise<{ id: string; name: string; entryNotificationType: string | null } | null> {
return prisma.round.findFirst({
where: { programId },
orderBy: { sortOrder: 'asc' },
select: { id: true, name: true, entryNotificationType: true },
})
}

View File

@ -10,6 +10,9 @@ export interface FilteringRoundSettings {
autoEliminationMinReviews: number // Min reviews required before elimination autoEliminationMinReviews: number // Min reviews required before elimination
targetAdvancing: number // Target number of projects to advance (e.g., 60) targetAdvancing: number // Target number of projects to advance (e.g., 60)
// Auto-run filtering when round closes
autoFilterOnClose: boolean
// Display options // Display options
showAverageScore: boolean showAverageScore: boolean
showRanking: boolean showRanking: boolean
@ -62,6 +65,7 @@ export const defaultFilteringSettings: FilteringRoundSettings = {
autoEliminationThreshold: 4, autoEliminationThreshold: 4,
autoEliminationMinReviews: 0, autoEliminationMinReviews: 0,
targetAdvancing: 60, targetAdvancing: 60,
autoFilterOnClose: true,
showAverageScore: true, showAverageScore: true,
showRanking: true, showRanking: true,
} }