Auto-assign projects to first round, auto-filter on close, pipeline UX consolidation
Build and Push Docker Image / build (push) Successful in 10m30s
Details
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:
parent
2a5fa463b3
commit
7b85fd9602
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue