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 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 15:06:11 +01:00
parent 2a5fa463b3
commit 7b85fd9602
11 changed files with 204 additions and 101 deletions

View File

@@ -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,

View File

@@ -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({

View File

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

View File

@@ -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<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
}),

View File

@@ -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<string, string> = {
[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',

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