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:
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
15
src/server/utils/round-helpers.ts
Normal file
15
src/server/utils/round-helpers.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user