From 7b85fd960264163f12976279ee3caf4185e35871 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 12 Feb 2026 15:06:11 +0100 Subject: [PATCH] Auto-assign projects to first round, auto-filter on close, pipeline UX consolidation - 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 --- .../(admin)/admin/rounds/[id]/edit/page.tsx | 1 + src/app/(admin)/admin/rounds/new/page.tsx | 1 + src/app/(admin)/admin/rounds/page.tsx | 106 ++---------------- src/components/forms/round-type-settings.tsx | 25 +++++ src/server/routers/application.ts | 67 +++++++++++ src/server/routers/filtering.ts | 4 +- src/server/routers/project.ts | 23 +++- src/server/routers/round.ts | 57 ++++++++++ src/server/services/in-app-notification.ts | 2 + src/server/utils/round-helpers.ts | 15 +++ src/types/round-settings.ts | 4 + 11 files changed, 204 insertions(+), 101 deletions(-) create mode 100644 src/server/utils/round-helpers.ts diff --git a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx index 9c7169b..dd83b89 100644 --- a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx @@ -64,6 +64,7 @@ const TEAM_NOTIFICATION_OPTIONS = [ { 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: '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 { diff --git a/src/app/(admin)/admin/rounds/new/page.tsx b/src/app/(admin)/admin/rounds/new/page.tsx index 94f5760..29e2534 100644 --- a/src/app/(admin)/admin/rounds/new/page.tsx +++ b/src/app/(admin)/admin/rounds/new/page.tsx @@ -46,6 +46,7 @@ const TEAM_NOTIFICATION_OPTIONS = [ { 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: '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({ diff --git a/src/app/(admin)/admin/rounds/page.tsx b/src/app/(admin)/admin/rounds/page.tsx index e49c052..bbd0f03 100644 --- a/src/app/(admin)/admin/rounds/page.tsx +++ b/src/app/(admin)/admin/rounds/page.tsx @@ -62,9 +62,6 @@ import { Trash2, Loader2, GripVertical, - ArrowRight, - List, - GitBranchPlus, } from 'lucide-react' import { format, isPast, isFuture } from 'date-fns' 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({ includeRounds: true, }) @@ -110,45 +107,6 @@ function RoundsContent({ viewMode }: { viewMode: 'list' | 'pipeline' }) { ) } - if (viewMode === 'pipeline') { - return ( -
- {programs.map((program, index) => ( - - - -
-
- {program.year} Edition - - {program.name} - {program.status} - -
- -
-
- - {(program.rounds && program.rounds.length > 0) ? ( - - ) : ( -
- -

No rounds created yet

-
- )} -
-
-
- ))} -
- ) - } - return (
{programs.map((program, index) => ( @@ -271,32 +229,10 @@ function ProgramRounds({ program }: { program: any }) { - {/* Flow visualization */} + {/* Pipeline visualization */} {rounds.length > 1 && (
-

- Project Flow -

-
- {rounds.map((round, index) => ( -
-
- - {index} - - - {round.name} - - - {round._count?.projects || 0} - -
- {index < rounds.length - 1 && ( - - )} -
- ))} -
+
)}
@@ -711,43 +647,19 @@ function RoundsListSkeleton() { } export default function RoundsPage() { - const [viewMode, setViewMode] = useState<'list' | 'pipeline'>('list') - return (
{/* Header */} -
-
-

Rounds

-

- Manage selection rounds and voting periods -

-
-
- - -
+
+

Rounds

+

+ Manage selection rounds and voting periods +

{/* Content */} }> - +
) diff --git a/src/components/forms/round-type-settings.tsx b/src/components/forms/round-type-settings.tsx index 34d8b31..9414855 100644 --- a/src/components/forms/round-type-settings.tsx +++ b/src/components/forms/round-type-settings.tsx @@ -260,6 +260,31 @@ function FilteringSettings({ )}
+ {/* Auto-Filter on Close */} +
+
+
+ +

+ Automatically start filtering when this round is closed +

+
+ + onChange({ ...settings, autoFilterOnClose: v }) + } + /> +
+ + + + When enabled, closing this round will automatically run the configured filtering rules. + Results still require admin review before finalization. + + +
+ {/* Display Options */}
Display Options
diff --git a/src/server/routers/application.ts b/src/server/routers/application.ts index dabadb5..16bef73 100644 --- a/src/server/routers/application.ts +++ b/src/server/routers/application.ts @@ -5,8 +5,10 @@ import { Prisma, CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma import { createNotification, notifyAdmins, + notifyProjectTeam, NotificationTypes, } from '../services/in-app-notification' +import { getFirstRoundForProgram } from '@/server/utils/round-helpers' import { checkRateLimit } from '@/lib/rate-limit' import { logAudit } from '@/server/utils/audit' 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 await ctx.prisma.teamMember.create({ data: { @@ -510,6 +524,7 @@ export const applicationRouter = router({ source: 'public_application_form', title: data.projectName, category: data.competitionCategory, + autoAssignedRound: assignedRound?.name || null, }, ipAddress: ctx.ip, 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 { success: true, 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 try { await logAudit({ @@ -828,6 +875,7 @@ export const applicationRouter = router({ source: 'draft_submission', title: data.projectName, category: data.competitionCategory, + autoAssignedRound: draftAssignedRound?.name || null, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, @@ -836,6 +884,25 @@ export const applicationRouter = router({ // 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 { success: true, projectId: updated.id, diff --git a/src/server/routers/filtering.ts b/src/server/routers/filtering.ts index 0d3ffe0..532be98 100644 --- a/src/server/routers/filtering.ts +++ b/src/server/routers/filtering.ts @@ -11,8 +11,8 @@ import { NotificationTypes, } from '../services/in-app-notification' -// Background job execution function -async function runFilteringJob(jobId: string, roundId: string, userId: string) { +// Background job execution function (exported for auto-filtering on round close) +export async function runFilteringJob(jobId: string, roundId: string, userId: string) { try { // Update job to running await prisma.filteringJob.update({ diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index 99f3414..d25c22f 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -8,6 +8,7 @@ import { notifyProjectTeam, NotificationTypes, } from '../services/in-app-notification' +import { getFirstRoundForProgram } from '@/server/utils/round-helpers' import { normalizeCountryToCode } from '@/lib/countries' import { logAudit } from '../utils/audit' import { sendInvitationEmail } from '@/lib/email' @@ -459,10 +460,19 @@ export const projectRouter = router({ : undefined 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({ data: { programId: resolvedProgramId, - roundId: input.roundId || null, + roundId: resolvedRoundId, title: input.title, teamName: input.teamName, 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 const result = await ctx.prisma.$transaction(async (tx) => { // Create all projects with roundId and programId @@ -890,7 +909,7 @@ export const projectRouter = router({ return { ...rest, programId: input.programId, - roundId: input.roundId!, + roundId: resolvedImportRoundId, status: 'SUBMITTED' as const, metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, } diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts index b199b1a..6df7f39 100644 --- a/src/server/routers/round.ts +++ b/src/server/routers/round.ts @@ -4,9 +4,12 @@ import { Prisma } from '@prisma/client' import { router, protectedProcedure, adminProcedure } from '../trpc' import { notifyRoundJury, + notifyAdmins, NotificationTypes, } from '../services/in-app-notification' import { logAudit } from '@/server/utils/audit' +import { runFilteringJob } from './filtering' +import { prisma as globalPrisma } from '@/lib/prisma' // Valid round status transitions (state machine) const VALID_ROUND_TRANSITIONS: Record = { @@ -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) || {} + 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 }), diff --git a/src/server/services/in-app-notification.ts b/src/server/services/in-app-notification.ts index 71ae51e..87bcab5 100644 --- a/src/server/services/in-app-notification.ts +++ b/src/server/services/in-app-notification.ts @@ -77,6 +77,7 @@ export const NotificationTypes = { FEEDBACK_AVAILABLE: 'FEEDBACK_AVAILABLE', EVENT_INVITATION: 'EVENT_INVITATION', WINNER_ANNOUNCEMENT: 'WINNER_ANNOUNCEMENT', + SUBMISSION_RECEIVED: 'SUBMISSION_RECEIVED', CERTIFICATE_READY: 'CERTIFICATE_READY', PROGRAM_NEWSLETTER: 'PROGRAM_NEWSLETTER', @@ -107,6 +108,7 @@ export const NotificationIcons: Record = { [NotificationTypes.MENTEE_ADVANCED]: 'TrendingUp', [NotificationTypes.MENTEE_WON]: 'Trophy', [NotificationTypes.APPLICATION_SUBMITTED]: 'CheckCircle', + [NotificationTypes.SUBMISSION_RECEIVED]: 'Inbox', [NotificationTypes.ADVANCED_SEMIFINAL]: 'TrendingUp', [NotificationTypes.ADVANCED_FINAL]: 'Star', [NotificationTypes.MENTOR_ASSIGNED]: 'GraduationCap', diff --git a/src/server/utils/round-helpers.ts b/src/server/utils/round-helpers.ts new file mode 100644 index 0000000..e70429c --- /dev/null +++ b/src/server/utils/round-helpers.ts @@ -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 }, + }) +} diff --git a/src/types/round-settings.ts b/src/types/round-settings.ts index 4e83bb1..a093d20 100644 --- a/src/types/round-settings.ts +++ b/src/types/round-settings.ts @@ -10,6 +10,9 @@ export interface FilteringRoundSettings { autoEliminationMinReviews: number // Min reviews required before elimination targetAdvancing: number // Target number of projects to advance (e.g., 60) + // Auto-run filtering when round closes + autoFilterOnClose: boolean + // Display options showAverageScore: boolean showRanking: boolean @@ -62,6 +65,7 @@ export const defaultFilteringSettings: FilteringRoundSettings = { autoEliminationThreshold: 4, autoEliminationMinReviews: 0, targetAdvancing: 60, + autoFilterOnClose: true, showAverageScore: true, showRanking: true, }