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