Add multiple admin improvements and bug fixes
Build and Push Docker Image / build (push) Successful in 8m58s Details

- Email settings: Add separate sender display name field
- Rounds page: Drag-and-drop reordering with visible order numbers
- Round creation: Auto-assign projects to filtering rounds, auto-activate if voting started
- Round detail: Fix incorrect "voting period ended" message for draft rounds
- Projects page: Add delete option with confirmation dialog
- AI filtering: Add configurable batch size and parallel request settings
- Filtering results: Fix duplicate criteria display
- Add seed scripts for notification settings and MOPC onboarding form

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-03 23:19:45 +01:00
parent 1d137ce93e
commit 3be6a743ed
12 changed files with 895 additions and 192 deletions

View File

@ -0,0 +1,270 @@
/**
* Seed script for MOPC Onboarding Form (ESM version for production)
* Run with: node prisma/seed-mopc-onboarding.mjs
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const MOPC_FORM_CONFIG = {
name: 'MOPC Application 2026',
description: 'Monaco Ocean Protection Challenge application form',
publicSlug: 'mopc-2026',
status: 'PUBLISHED',
isPublic: true,
sendConfirmationEmail: true,
sendTeamInviteEmails: true,
confirmationEmailSubject: 'Application Received - Monaco Ocean Protection Challenge',
confirmationEmailBody: `Thank you for applying to the Monaco Ocean Protection Challenge 2026!
We have received your application and our team will review it carefully.
If you have any questions, please don't hesitate to reach out.
Good luck!
The MOPC Team`,
confirmationMessage: 'Thank you for your application! We have sent a confirmation email to the address you provided. Our team will review your submission and get back to you soon.',
}
const STEPS = [
{
name: 'category',
title: 'Competition Category',
description: 'Select your competition track',
sortOrder: 0,
isOptional: false,
fields: [
{
name: 'competitionCategory',
label: 'Which category best describes your project?',
fieldType: 'RADIO',
specialType: 'COMPETITION_CATEGORY',
required: true,
sortOrder: 0,
width: 'full',
projectMapping: 'competitionCategory',
description: 'Choose the category that best fits your stage of development',
optionsJson: [
{ value: 'STARTUP', label: 'Startup', description: 'You have an existing company or registered business entity' },
{ value: 'BUSINESS_CONCEPT', label: 'Business Concept', description: 'You are a student, graduate, or have an idea not yet incorporated' },
],
},
],
},
{
name: 'contact',
title: 'Contact Information',
description: 'Tell us how to reach you',
sortOrder: 1,
isOptional: false,
fields: [
{ name: 'contactName', label: 'Full Name', fieldType: 'TEXT', required: true, sortOrder: 0, width: 'half', placeholder: 'Enter your full name' },
{ name: 'contactEmail', label: 'Email Address', fieldType: 'EMAIL', required: true, sortOrder: 1, width: 'half', placeholder: 'your.email@example.com', description: 'We will use this email for all communications' },
{ name: 'contactPhone', label: 'Phone Number', fieldType: 'PHONE', required: true, sortOrder: 2, width: 'half', placeholder: '+1 (555) 123-4567' },
{ name: 'country', label: 'Country', fieldType: 'SELECT', specialType: 'COUNTRY_SELECT', required: true, sortOrder: 3, width: 'half', projectMapping: 'country' },
{ name: 'city', label: 'City', fieldType: 'TEXT', required: false, sortOrder: 4, width: 'half', placeholder: 'City name' },
],
},
{
name: 'project',
title: 'Project Details',
description: 'Tell us about your ocean protection project',
sortOrder: 2,
isOptional: false,
fields: [
{ name: 'projectName', label: 'Project Name', fieldType: 'TEXT', required: true, sortOrder: 0, width: 'full', projectMapping: 'title', maxLength: 200, placeholder: 'Give your project a memorable name' },
{ name: 'teamName', label: 'Team / Company Name', fieldType: 'TEXT', required: false, sortOrder: 1, width: 'half', projectMapping: 'teamName', placeholder: 'Your team or company name' },
{ name: 'oceanIssue', label: 'Primary Ocean Issue', fieldType: 'SELECT', specialType: 'OCEAN_ISSUE', required: true, sortOrder: 2, width: 'half', projectMapping: 'oceanIssue', description: 'Select the primary ocean issue your project addresses' },
{ name: 'description', label: 'Project Description', fieldType: 'TEXTAREA', required: true, sortOrder: 3, width: 'full', projectMapping: 'description', minLength: 50, maxLength: 2000, placeholder: 'Describe your project, its goals, and how it will help protect the ocean...', description: 'Provide a clear description of your project (50-2000 characters)' },
{ name: 'websiteUrl', label: 'Website URL', fieldType: 'URL', required: false, sortOrder: 4, width: 'half', projectMapping: 'websiteUrl', placeholder: 'https://yourproject.com' },
],
},
{
name: 'team',
title: 'Team Members',
description: 'Add your team members (they will receive email invitations)',
sortOrder: 3,
isOptional: true,
fields: [
{ name: 'teamMembers', label: 'Team Members', fieldType: 'TEXT', specialType: 'TEAM_MEMBERS', required: false, sortOrder: 0, width: 'full', description: 'Add up to 5 team members. They will receive an invitation email to join your application.' },
],
},
{
name: 'additional',
title: 'Additional Details',
description: 'A few more questions about your project',
sortOrder: 4,
isOptional: false,
fields: [
{ name: 'institution', label: 'University / School', fieldType: 'TEXT', required: false, sortOrder: 0, width: 'half', projectMapping: 'institution', placeholder: 'Name of your institution', conditionJson: { field: 'competitionCategory', operator: 'equals', value: 'BUSINESS_CONCEPT' } },
{ name: 'startupCreatedDate', label: 'Startup Founded Date', fieldType: 'DATE', required: false, sortOrder: 1, width: 'half', description: 'When was your company founded?', conditionJson: { field: 'competitionCategory', operator: 'equals', value: 'STARTUP' } },
{ name: 'wantsMentorship', label: 'I am interested in receiving mentorship', fieldType: 'CHECKBOX', required: false, sortOrder: 2, width: 'full', projectMapping: 'wantsMentorship', description: 'Check this box if you would like to be paired with an expert mentor' },
{ name: 'referralSource', label: 'How did you hear about MOPC?', fieldType: 'SELECT', required: false, sortOrder: 3, width: 'half', optionsJson: [
{ value: 'social_media', label: 'Social Media' },
{ value: 'search_engine', label: 'Search Engine' },
{ value: 'word_of_mouth', label: 'Word of Mouth' },
{ value: 'university', label: 'University / School' },
{ value: 'partner', label: 'Partner Organization' },
{ value: 'media', label: 'News / Media' },
{ value: 'event', label: 'Event / Conference' },
{ value: 'other', label: 'Other' },
]},
],
},
{
name: 'review',
title: 'Review & Submit',
description: 'Review your application and accept the terms',
sortOrder: 5,
isOptional: false,
fields: [
{ name: 'instructions', label: 'Review Instructions', fieldType: 'INSTRUCTIONS', required: false, sortOrder: 0, width: 'full', description: 'Please review all the information you have provided. Once submitted, you will not be able to make changes.' },
{ name: 'gdprConsent', label: 'I consent to the processing of my personal data in accordance with the GDPR and the MOPC Privacy Policy', fieldType: 'CHECKBOX', specialType: 'GDPR_CONSENT', required: true, sortOrder: 1, width: 'full' },
{ name: 'termsAccepted', label: 'I have read and accept the Terms and Conditions of the Monaco Ocean Protection Challenge', fieldType: 'CHECKBOX', required: true, sortOrder: 2, width: 'full' },
],
},
]
async function main() {
console.log('Seeding MOPC onboarding form...')
// Check if form already exists
const existingForm = await prisma.applicationForm.findUnique({
where: { publicSlug: MOPC_FORM_CONFIG.publicSlug },
})
if (existingForm) {
console.log('Form with slug "mopc-2026" already exists. Updating...')
// Delete existing steps and fields to recreate them
await prisma.applicationFormField.deleteMany({ where: { formId: existingForm.id } })
await prisma.onboardingStep.deleteMany({ where: { formId: existingForm.id } })
// Update the form
await prisma.applicationForm.update({
where: { id: existingForm.id },
data: {
name: MOPC_FORM_CONFIG.name,
description: MOPC_FORM_CONFIG.description,
status: MOPC_FORM_CONFIG.status,
isPublic: MOPC_FORM_CONFIG.isPublic,
sendConfirmationEmail: MOPC_FORM_CONFIG.sendConfirmationEmail,
sendTeamInviteEmails: MOPC_FORM_CONFIG.sendTeamInviteEmails,
confirmationEmailSubject: MOPC_FORM_CONFIG.confirmationEmailSubject,
confirmationEmailBody: MOPC_FORM_CONFIG.confirmationEmailBody,
confirmationMessage: MOPC_FORM_CONFIG.confirmationMessage,
},
})
// Create steps and fields
for (const stepData of STEPS) {
const step = await prisma.onboardingStep.create({
data: {
formId: existingForm.id,
name: stepData.name,
title: stepData.title,
description: stepData.description,
sortOrder: stepData.sortOrder,
isOptional: stepData.isOptional,
},
})
for (const field of stepData.fields) {
await prisma.applicationFormField.create({
data: {
formId: existingForm.id,
stepId: step.id,
name: field.name,
label: field.label,
fieldType: field.fieldType,
specialType: field.specialType || null,
required: field.required,
sortOrder: field.sortOrder,
width: field.width,
description: field.description || null,
placeholder: field.placeholder || null,
projectMapping: field.projectMapping || null,
minLength: field.minLength || null,
maxLength: field.maxLength || null,
optionsJson: field.optionsJson || undefined,
conditionJson: field.conditionJson || undefined,
},
})
}
console.log(` - Created step: ${stepData.title} (${stepData.fields.length} fields)`)
}
console.log(`\nForm updated: ${existingForm.id}`)
return
}
// Create new form
const form = await prisma.applicationForm.create({
data: {
name: MOPC_FORM_CONFIG.name,
description: MOPC_FORM_CONFIG.description,
publicSlug: MOPC_FORM_CONFIG.publicSlug,
status: MOPC_FORM_CONFIG.status,
isPublic: MOPC_FORM_CONFIG.isPublic,
sendConfirmationEmail: MOPC_FORM_CONFIG.sendConfirmationEmail,
sendTeamInviteEmails: MOPC_FORM_CONFIG.sendTeamInviteEmails,
confirmationEmailSubject: MOPC_FORM_CONFIG.confirmationEmailSubject,
confirmationEmailBody: MOPC_FORM_CONFIG.confirmationEmailBody,
confirmationMessage: MOPC_FORM_CONFIG.confirmationMessage,
},
})
console.log(`Created form: ${form.id}`)
// Create steps and fields
for (const stepData of STEPS) {
const step = await prisma.onboardingStep.create({
data: {
formId: form.id,
name: stepData.name,
title: stepData.title,
description: stepData.description,
sortOrder: stepData.sortOrder,
isOptional: stepData.isOptional,
},
})
for (const field of stepData.fields) {
await prisma.applicationFormField.create({
data: {
formId: form.id,
stepId: step.id,
name: field.name,
label: field.label,
fieldType: field.fieldType,
specialType: field.specialType || null,
required: field.required,
sortOrder: field.sortOrder,
width: field.width,
description: field.description || null,
placeholder: field.placeholder || null,
projectMapping: field.projectMapping || null,
minLength: field.minLength || null,
maxLength: field.maxLength || null,
optionsJson: field.optionsJson || undefined,
conditionJson: field.conditionJson || undefined,
},
})
}
console.log(` - Created step: ${stepData.title} (${stepData.fields.length} fields)`)
}
console.log(`\nMOPC form seeded successfully!`)
console.log(`Form ID: ${form.id}`)
console.log(`Public URL: /apply/${form.publicSlug}`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -0,0 +1,73 @@
/**
* Seed script for notification email settings (ESM version for production)
* Run with: node prisma/seed-notification-settings.mjs
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const NOTIFICATION_EMAIL_SETTINGS = [
// Team / Applicant notifications
{ notificationType: 'APPLICATION_SUBMITTED', category: 'team', label: 'Application Submitted', description: 'When a team submits their application', sendEmail: true },
{ notificationType: 'TEAM_INVITE_RECEIVED', category: 'team', label: 'Team Invitation Received', description: 'When someone is invited to join a team', sendEmail: true },
{ notificationType: 'TEAM_MEMBER_JOINED', category: 'team', label: 'Team Member Joined', description: 'When a new member joins the team', sendEmail: false },
{ notificationType: 'ADVANCED_SEMIFINAL', category: 'team', label: 'Advanced to Semi-Finals', description: 'When a project advances to semi-finals', sendEmail: true },
{ notificationType: 'ADVANCED_FINAL', category: 'team', label: 'Selected as Finalist', description: 'When a project is selected as a finalist', sendEmail: true },
{ notificationType: 'MENTOR_ASSIGNED', category: 'team', label: 'Mentor Assigned', description: 'When a mentor is assigned to the team', sendEmail: true },
{ notificationType: 'NOT_SELECTED', category: 'team', label: 'Not Selected', description: 'When a project is not selected for the next round', sendEmail: true },
{ notificationType: 'FEEDBACK_AVAILABLE', category: 'team', label: 'Feedback Available', description: 'When jury feedback becomes available', sendEmail: true },
{ notificationType: 'WINNER_ANNOUNCEMENT', category: 'team', label: 'Winner Announcement', description: 'When a project wins an award', sendEmail: true },
// Jury notifications
{ notificationType: 'ASSIGNED_TO_PROJECT', category: 'jury', label: 'Assigned to Project', description: 'When a jury member is assigned to a project', sendEmail: true },
{ notificationType: 'BATCH_ASSIGNED', category: 'jury', label: 'Batch Assignment', description: 'When multiple projects are assigned at once', sendEmail: true },
{ notificationType: 'ROUND_NOW_OPEN', category: 'jury', label: 'Round Now Open', description: 'When a round opens for evaluation', sendEmail: true },
{ notificationType: 'REMINDER_24H', category: 'jury', label: 'Reminder (24h)', description: 'Reminder 24 hours before deadline', sendEmail: true },
{ notificationType: 'REMINDER_1H', category: 'jury', label: 'Reminder (1h)', description: 'Urgent reminder 1 hour before deadline', sendEmail: true },
{ notificationType: 'ROUND_CLOSED', category: 'jury', label: 'Round Closed', description: 'When a round closes', sendEmail: false },
{ notificationType: 'AWARD_VOTING_OPEN', category: 'jury', label: 'Award Voting Open', description: 'When special award voting opens', sendEmail: true },
// Mentor notifications
{ notificationType: 'MENTEE_ASSIGNED', category: 'mentor', label: 'Mentee Assigned', description: 'When assigned as mentor to a project', sendEmail: true },
{ notificationType: 'MENTEE_UPLOADED_DOCS', category: 'mentor', label: 'Mentee Documents Updated', description: 'When a mentee uploads new documents', sendEmail: false },
{ notificationType: 'MENTEE_ADVANCED', category: 'mentor', label: 'Mentee Advanced', description: 'When a mentee advances to the next round', sendEmail: true },
{ notificationType: 'MENTEE_FINALIST', category: 'mentor', label: 'Mentee is Finalist', description: 'When a mentee is selected as finalist', sendEmail: true },
{ notificationType: 'MENTEE_WON', category: 'mentor', label: 'Mentee Won', description: 'When a mentee wins an award', sendEmail: true },
// Observer notifications
{ notificationType: 'ROUND_STARTED', category: 'observer', label: 'Round Started', description: 'When a new round begins', sendEmail: false },
{ notificationType: 'ROUND_COMPLETED', category: 'observer', label: 'Round Completed', description: 'When a round is completed', sendEmail: true },
{ notificationType: 'FINALISTS_ANNOUNCED', category: 'observer', label: 'Finalists Announced', description: 'When finalists are announced', sendEmail: true },
{ notificationType: 'WINNERS_ANNOUNCED', category: 'observer', label: 'Winners Announced', description: 'When winners are announced', sendEmail: true },
// Admin notifications
{ notificationType: 'FILTERING_COMPLETE', category: 'admin', label: 'AI Filtering Complete', description: 'When AI filtering job completes', sendEmail: false },
{ notificationType: 'FILTERING_FAILED', category: 'admin', label: 'AI Filtering Failed', description: 'When AI filtering job fails', sendEmail: true },
{ notificationType: 'NEW_APPLICATION', category: 'admin', label: 'New Application', description: 'When a new application is received', sendEmail: false },
{ notificationType: 'SYSTEM_ERROR', category: 'admin', label: 'System Error', description: 'When a system error occurs', sendEmail: true },
]
async function main() {
console.log('Seeding notification email settings...')
for (const setting of NOTIFICATION_EMAIL_SETTINGS) {
await prisma.notificationEmailSetting.upsert({
where: { notificationType: setting.notificationType },
update: { category: setting.category, label: setting.label, description: setting.description },
create: setting,
})
console.log(` - ${setting.label}`)
}
console.log(`\nSeeded ${NOTIFICATION_EMAIL_SETTINGS.length} notification email settings.`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useSearchParams, usePathname } from 'next/navigation' import { useSearchParams, usePathname } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { import {
Card, Card,
CardContent, CardContent,
@ -27,8 +28,19 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { import {
Plus, Plus,
MoreHorizontal, MoreHorizontal,
@ -38,6 +50,8 @@ import {
FileUp, FileUp,
Users, Users,
Search, Search,
Trash2,
Loader2,
} from 'lucide-react' } from 'lucide-react'
import { truncate } from '@/lib/utils' import { truncate } from '@/lib/utils'
import { ProjectLogo } from '@/components/shared/project-logo' import { ProjectLogo } from '@/components/shared/project-logo'
@ -210,9 +224,30 @@ export default function ProjectsPage() {
perPage: PER_PAGE, perPage: PER_PAGE,
} }
const utils = trpc.useUtils()
const { data, isLoading } = trpc.project.list.useQuery(queryInput) const { data, isLoading } = trpc.project.list.useQuery(queryInput)
const { data: filterOptions } = trpc.project.getFilterOptions.useQuery() const { data: filterOptions } = trpc.project.getFilterOptions.useQuery()
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null)
const deleteProject = trpc.project.delete.useMutation({
onSuccess: () => {
toast.success('Project deleted successfully')
utils.project.list.invalidate()
setDeleteDialogOpen(false)
setProjectToDelete(null)
},
onError: (error) => {
toast.error(error.message || 'Failed to delete project')
},
})
const handleDeleteClick = (project: { id: string; title: string }) => {
setProjectToDelete(project)
setDeleteDialogOpen(true)
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@ -391,6 +426,17 @@ export default function ProjectsPage() {
Edit Edit
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation()
handleDeleteClick({ id: project.id, title: project.title })
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</TableCell> </TableCell>
@ -459,6 +505,32 @@ export default function ProjectsPage() {
/> />
</> </>
) : null} ) : null}
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Project</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{projectToDelete?.title}&quot;? This will
permanently remove the project, all its files, assignments, and evaluations.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => projectToDelete && deleteProject.mutate({ id: projectToDelete.id })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteProject.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
) )
} }

View File

@ -339,7 +339,7 @@ export default function FilteringResultsPage({
<TableRow key={`${result.id}-detail`}> <TableRow key={`${result.id}-detail`}>
<TableCell colSpan={5} className="bg-muted/30"> <TableCell colSpan={5} className="bg-muted/30">
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
{/* Rule Results */} {/* Rule Results (non-AI rules only, AI shown separately) */}
<div> <div>
<p className="text-sm font-medium mb-2"> <p className="text-sm font-medium mb-2">
Rule Results Rule Results
@ -355,7 +355,7 @@ export default function FilteringResultsPage({
action: string action: string
reasoning?: string reasoning?: string
}> }>
).map((rr, i) => ( ).filter((rr) => rr.ruleType !== 'AI_SCREENING').map((rr, i) => (
<div <div
key={i} key={i}
className="flex items-start gap-2 text-sm" className="flex items-start gap-2 text-sm"

View File

@ -109,6 +109,8 @@ export default function FilteringRulesPage({
// AI screening config state // AI screening config state
const [criteriaText, setCriteriaText] = useState('') const [criteriaText, setCriteriaText] = useState('')
const [aiBatchSize, setAiBatchSize] = useState('20')
const [aiParallelBatches, setAiParallelBatches] = useState('1')
const handleCreateRule = async () => { const handleCreateRule = async () => {
if (!newRuleName.trim()) return if (!newRuleName.trim()) return
@ -143,6 +145,8 @@ export default function FilteringRulesPage({
configJson = { configJson = {
criteriaText, criteriaText,
action: 'FLAG', action: 'FLAG',
batchSize: parseInt(aiBatchSize) || 20,
parallelBatches: parseInt(aiParallelBatches) || 1,
} }
} }
@ -195,6 +199,8 @@ export default function FilteringRulesPage({
setMinFileCount('1') setMinFileCount('1')
setDocAction('REJECT') setDocAction('REJECT')
setCriteriaText('') setCriteriaText('')
setAiBatchSize('20')
setAiParallelBatches('1')
} }
if (isLoading) { if (isLoading) {
@ -403,6 +409,7 @@ export default function FilteringRulesPage({
{/* AI Screening Config */} {/* AI Screening Config */}
{newRuleType === 'AI_SCREENING' && ( {newRuleType === 'AI_SCREENING' && (
<div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Screening Criteria</Label> <Label>Screening Criteria</Label>
<Textarea <Textarea
@ -416,6 +423,52 @@ export default function FilteringRulesPage({
auto-rejects. auto-rejects.
</p> </p>
</div> </div>
<div className="border-t pt-4">
<Label className="text-sm font-medium">Performance Settings</Label>
<p className="text-xs text-muted-foreground mb-3">
Adjust batch settings to balance speed vs. cost
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs">Batch Size</Label>
<Select value={aiBatchSize} onValueChange={setAiBatchSize}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 (Individual)</SelectItem>
<SelectItem value="5">5 (Small)</SelectItem>
<SelectItem value="10">10 (Medium)</SelectItem>
<SelectItem value="20">20 (Default)</SelectItem>
<SelectItem value="50">50 (Large)</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
Projects per API call. Smaller = more parallel potential
</p>
</div>
<div className="space-y-2">
<Label className="text-xs">Parallel Requests</Label>
<Select value={aiParallelBatches} onValueChange={setAiParallelBatches}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 (Sequential)</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="5">5 (Fast)</SelectItem>
<SelectItem value="10">10 (Maximum)</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
Concurrent API calls. Higher = faster but more costly
</p>
</div>
</div>
</div>
</div>
)} )}
</div> </div>
<DialogFooter> <DialogFooter>

View File

@ -498,6 +498,8 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
? 'bg-green-500/10 text-green-700' ? 'bg-green-500/10 text-green-700'
: isFuture(new Date(round.votingStartAt)) : isFuture(new Date(round.votingStartAt))
? 'bg-amber-500/10 text-amber-700' ? 'bg-amber-500/10 text-amber-700'
: isFuture(new Date(round.votingEndAt))
? 'bg-blue-500/10 text-blue-700'
: 'bg-muted text-muted-foreground' : 'bg-muted text-muted-foreground'
}`} }`}
> >
@ -513,6 +515,16 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
Voting opens {formatDistanceToNow(new Date(round.votingStartAt), { addSuffix: true })} Voting opens {formatDistanceToNow(new Date(round.votingStartAt), { addSuffix: true })}
</span> </span>
</div> </div>
) : isFuture(new Date(round.votingEndAt)) ? (
<div className="flex items-center gap-2">
<Clock className="h-5 w-5" />
<span className="font-medium">
{round.status === 'DRAFT'
? 'Voting window configured (round not yet active)'
: `Voting ends ${formatDistanceToNow(new Date(round.votingEndAt), { addSuffix: true })}`
}
</span>
</div>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5" /> <AlertCircle className="h-5 w-5" />

View File

@ -62,10 +62,12 @@ function CreateRoundContent() {
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION') const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({}) const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
const utils = trpc.useUtils()
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery() const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery()
const createRound = trpc.round.create.useMutation({ const createRound = trpc.round.create.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
utils.program.list.invalidate({ includeRounds: true })
router.push(`/admin/rounds/${data.id}`) router.push(`/admin/rounds/${data.id}`)
}, },
}) })

View File

@ -4,6 +4,23 @@ import { Suspense, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { import {
Card, Card,
CardContent, CardContent,
@ -14,14 +31,6 @@ import {
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -52,10 +61,24 @@ import {
Archive, Archive,
Trash2, Trash2,
Loader2, Loader2,
ChevronUp, GripVertical,
ChevronDown, ArrowRight,
} 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'
type RoundData = {
id: string
name: string
status: string
roundType: string
votingStartAt: string | null
votingEndAt: string | null
_count?: {
roundProjects: number
assignments: number
}
}
function RoundsContent() { function RoundsContent() {
const { data: programs, isLoading } = trpc.program.list.useQuery({ const { data: programs, isLoading } = trpc.program.list.useQuery({
@ -86,7 +109,64 @@ function RoundsContent() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{programs.map((program) => ( {programs.map((program) => (
<Card key={program.id}> <ProgramRounds key={program.id} program={program} />
))}
</div>
)
}
function ProgramRounds({ program }: { program: any }) {
const utils = trpc.useUtils()
const [rounds, setRounds] = useState<RoundData[]>(program.rounds || [])
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const reorder = trpc.round.reorder.useMutation({
onSuccess: () => {
utils.program.list.invalidate({ includeRounds: true })
toast.success('Round order updated')
},
onError: (error) => {
toast.error(error.message || 'Failed to reorder rounds')
// Reset to original order on error
setRounds(program.rounds || [])
},
})
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = rounds.findIndex((r) => r.id === active.id)
const newIndex = rounds.findIndex((r) => r.id === over.id)
const newRounds = arrayMove(rounds, oldIndex, newIndex)
setRounds(newRounds)
// Send the new order to the server
reorder.mutate({
programId: program.id,
roundIds: newRounds.map((r) => r.id),
})
}
}
// Sync local state when program.rounds changes
if (JSON.stringify(rounds.map(r => r.id)) !== JSON.stringify((program.rounds || []).map((r: RoundData) => r.id))) {
setRounds(program.rounds || [])
}
return (
<Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@ -104,32 +184,72 @@ function RoundsContent() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{program.rounds && program.rounds.length > 0 ? ( {rounds.length > 0 ? (
<Table> <div className="space-y-2">
<TableHeader> {/* Header */}
<TableRow> <div className="grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
<TableHead className="w-20">Order</TableHead> <div>Order</div>
<TableHead>Round</TableHead> <div>Round</div>
<TableHead>Status</TableHead> <div>Status</div>
<TableHead>Voting Window</TableHead> <div>Voting Window</div>
<TableHead>Projects</TableHead> <div>Projects</div>
<TableHead>Assignments</TableHead> <div>Reviewers</div>
<TableHead className="text-right">Actions</TableHead> <div></div>
</TableRow> </div>
</TableHeader>
<TableBody> {/* Sortable List */}
{program.rounds.map((round, index) => ( <DndContext
<RoundRow sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={rounds.map((r) => r.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{rounds.map((round, index) => (
<SortableRoundRow
key={round.id} key={round.id}
round={round} round={round}
index={index} index={index}
totalRounds={program.rounds.length} totalRounds={rounds.length}
allRoundIds={program.rounds.map((r) => r.id)} isReordering={reorder.isPending}
programId={program.id}
/> />
))} ))}
</TableBody> </div>
</Table> </SortableContext>
</DndContext>
{/* Flow visualization */}
{rounds.length > 1 && (
<div className="mt-6 pt-4 border-t">
<p className="text-xs text-muted-foreground mb-3 uppercase tracking-wide font-medium">
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?.roundProjects || 0}
</Badge>
</div>
{index < rounds.length - 1 && (
<ArrowRight className="h-4 w-4 text-muted-foreground/50" />
)}
</div>
))}
</div>
</div>
)}
</div>
) : ( ) : (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
<Calendar className="mx-auto h-8 w-8 mb-2 opacity-50" /> <Calendar className="mx-auto h-8 w-8 mb-2 opacity-50" />
@ -138,45 +258,35 @@ function RoundsContent() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
))}
</div>
) )
} }
function RoundRow({ function SortableRoundRow({
round, round,
index, index,
totalRounds, totalRounds,
allRoundIds, isReordering,
programId,
}: { }: {
round: any round: RoundData
index: number index: number
totalRounds: number totalRounds: number
allRoundIds: string[] isReordering: boolean
programId: string
}) { }) {
const utils = trpc.useUtils() const utils = trpc.useUtils()
const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const reorder = trpc.round.reorder.useMutation({ const {
onSuccess: () => { attributes,
utils.program.list.invalidate({ includeRounds: true }) listeners,
}, setNodeRef,
}) transform,
transition,
isDragging,
} = useSortable({ id: round.id })
const moveUp = () => { const style = {
if (index <= 0) return transform: CSS.Transform.toString(transform),
const ids = [...allRoundIds] transition,
;[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]]
reorder.mutate({ programId, roundIds: ids })
}
const moveDown = () => {
if (index >= totalRounds - 1) return
const ids = [...allRoundIds]
;[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]]
reorder.mutate({ programId, roundIds: ids })
} }
const updateStatus = trpc.round.updateStatus.useMutation({ const updateStatus = trpc.round.updateStatus.useMutation({
@ -239,17 +349,16 @@ function RoundRow({
const getVotingWindow = () => { const getVotingWindow = () => {
if (!round.votingStartAt || !round.votingEndAt) { if (!round.votingStartAt || !round.votingEndAt) {
return <span className="text-muted-foreground">Not set</span> return <span className="text-muted-foreground text-sm">Not set</span>
} }
const start = new Date(round.votingStartAt) const start = new Date(round.votingStartAt)
const end = new Date(round.votingEndAt) const end = new Date(round.votingEndAt)
const now = new Date()
if (isFuture(start)) { if (isFuture(start)) {
return ( return (
<span className="text-sm"> <span className="text-sm">
Opens {format(start, 'MMM d, yyyy')} Opens {format(start, 'MMM d')}
</span> </span>
) )
} }
@ -257,68 +366,79 @@ function RoundRow({
if (isPast(end)) { if (isPast(end)) {
return ( return (
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Ended {format(end, 'MMM d, yyyy')} Ended {format(end, 'MMM d')}
</span> </span>
) )
} }
return ( return (
<span className="text-sm"> <span className="text-sm">
Until {format(end, 'MMM d, yyyy')} Until {format(end, 'MMM d')}
</span> </span>
) )
} }
return ( return (
<TableRow> <div
<TableCell> ref={setNodeRef}
style={style}
className={cn(
'grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 items-center px-3 py-2.5 rounded-lg border bg-card transition-all',
isDragging && 'shadow-lg ring-2 ring-primary/20 z-50 opacity-90',
isReordering && !isDragging && 'opacity-50'
)}
>
{/* Order number with drag handle */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <button
variant="ghost" {...attributes}
size="icon" {...listeners}
className="h-7 w-7" className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none"
onClick={moveUp} disabled={isReordering}
disabled={index === 0 || reorder.isPending}
> >
<ChevronUp className="h-4 w-4" /> <GripVertical className="h-4 w-4 text-muted-foreground" />
</Button> </button>
<Button <span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-primary/10 text-primary text-sm font-bold">
variant="ghost" {index}
size="icon" </span>
className="h-7 w-7"
onClick={moveDown}
disabled={index === totalRounds - 1 || reorder.isPending}
>
<ChevronDown className="h-4 w-4" />
</Button>
</div> </div>
</TableCell>
<TableCell> {/* Round name */}
<div>
<Link <Link
href={`/admin/rounds/${round.id}`} href={`/admin/rounds/${round.id}`}
className="font-medium hover:underline" className="font-medium hover:underline"
> >
{round.name} {round.name}
</Link> </Link>
</TableCell> <p className="text-xs text-muted-foreground capitalize">
<TableCell>{getStatusBadge()}</TableCell> {round.roundType?.toLowerCase().replace('_', ' ')}
<TableCell>{getVotingWindow()}</TableCell> </p>
<TableCell> </div>
<div className="flex items-center gap-1">
{/* Status */}
<div>{getStatusBadge()}</div>
{/* Voting window */}
<div>{getVotingWindow()}</div>
{/* Projects */}
<div className="flex items-center gap-1.5">
<FileText className="h-4 w-4 text-muted-foreground" /> <FileText className="h-4 w-4 text-muted-foreground" />
{round._count?.roundProjects || 0} <span className="font-medium">{round._count?.roundProjects || 0}</span>
</div> </div>
</TableCell>
<TableCell> {/* Assignments */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5">
<Users className="h-4 w-4 text-muted-foreground" /> <Users className="h-4 w-4 text-muted-foreground" />
{round._count?.assignments || 0} <span className="font-medium">{round._count?.assignments || 0}</span>
</div> </div>
</TableCell>
<TableCell className="text-right"> {/* Actions */}
<div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" aria-label="Round actions"> <Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Round actions">
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -408,8 +528,8 @@ function RoundRow({
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</TableCell> </div>
</TableRow> </div>
) )
} }

View File

@ -33,6 +33,7 @@ const formSchema = z.object({
smtp_port: z.string().regex(/^\d+$/, 'Port must be a number'), smtp_port: z.string().regex(/^\d+$/, 'Port must be a number'),
smtp_user: z.string().min(1, 'SMTP user is required'), smtp_user: z.string().min(1, 'SMTP user is required'),
smtp_password: z.string().optional(), smtp_password: z.string().optional(),
email_from_name: z.string().min(1, 'Sender name is required'),
email_from: z.string().email('Invalid email address'), email_from: z.string().email('Invalid email address'),
}) })
@ -44,6 +45,7 @@ interface EmailSettingsFormProps {
smtp_port?: string smtp_port?: string
smtp_user?: string smtp_user?: string
smtp_password?: string smtp_password?: string
email_from_name?: string
email_from?: string email_from?: string
} }
} }
@ -60,6 +62,7 @@ export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
smtp_port: settings.smtp_port || '587', smtp_port: settings.smtp_port || '587',
smtp_user: settings.smtp_user || '', smtp_user: settings.smtp_user || '',
smtp_password: '', smtp_password: '',
email_from_name: settings.email_from_name || 'MOPC Portal',
email_from: settings.email_from || 'noreply@monaco-opc.com', email_from: settings.email_from || 'noreply@monaco-opc.com',
}, },
}) })
@ -93,6 +96,7 @@ export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
{ key: 'smtp_host', value: data.smtp_host }, { key: 'smtp_host', value: data.smtp_host },
{ key: 'smtp_port', value: data.smtp_port }, { key: 'smtp_port', value: data.smtp_port },
{ key: 'smtp_user', value: data.smtp_user }, { key: 'smtp_user', value: data.smtp_user },
{ key: 'email_from_name', value: data.email_from_name },
{ key: 'email_from', value: data.email_from }, { key: 'email_from', value: data.email_from },
] ]
@ -188,23 +192,42 @@ export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
/> />
</div> </div>
<div className="grid gap-4 md:grid-cols-2">
<FormField <FormField
control={form.control} control={form.control}
name="email_from" name="email_from_name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>From Email Address</FormLabel> <FormLabel>Sender Display Name</FormLabel>
<FormControl> <FormControl>
<Input placeholder="noreply@monaco-opc.com" {...field} /> <Input placeholder="MOPC Portal" {...field} />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
Email address that will appear as the sender Name shown to recipients (e.g., "MOPC Portal")
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="email_from"
render={({ field }) => (
<FormItem>
<FormLabel>Sender Email Address</FormLabel>
<FormControl>
<Input placeholder="noreply@monaco-opc.com" {...field} />
</FormControl>
<FormDescription>
Email address for replies
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="submit" disabled={updateSettings.isPending}> <Button type="submit" disabled={updateSettings.isPending}>
{updateSettings.isPending ? ( {updateSettings.isPending ? (

View File

@ -15,7 +15,7 @@ async function getTransporter(): Promise<{ transporter: Transporter; from: strin
// Read DB settings // Read DB settings
const dbSettings = await prisma.systemSettings.findMany({ const dbSettings = await prisma.systemSettings.findMany({
where: { where: {
key: { in: ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'email_from'] }, key: { in: ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'email_from_name', 'email_from'] },
}, },
select: { key: true, value: true }, select: { key: true, value: true },
}) })
@ -30,7 +30,11 @@ async function getTransporter(): Promise<{ transporter: Transporter; from: strin
const port = db.smtp_port || process.env.SMTP_PORT || '587' const port = db.smtp_port || process.env.SMTP_PORT || '587'
const user = db.smtp_user || process.env.SMTP_USER || '' const user = db.smtp_user || process.env.SMTP_USER || ''
const pass = db.smtp_password || process.env.SMTP_PASS || '' const pass = db.smtp_password || process.env.SMTP_PASS || ''
const from = db.email_from || process.env.EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.com>'
// Combine sender name and email into "Name <email>" format
const fromName = db.email_from_name || 'MOPC Portal'
const fromEmail = db.email_from || process.env.EMAIL_FROM || 'noreply@monaco-opc.com'
const from = `${fromName} <${fromEmail}>`
// Check if config changed since last call // Check if config changed since last call
const configHash = `${host}:${port}:${user}:${pass}:${from}` const configHash = `${host}:${port}:${user}:${pass}:${from}`

View File

@ -94,14 +94,39 @@ export const roundRouter = router({
} }
const { settingsJson, sortOrder: _so, ...rest } = input const { settingsJson, sortOrder: _so, ...rest } = input
// Auto-activate if voting start date is in the past
const now = new Date()
const shouldAutoActivate = input.votingStartAt && input.votingStartAt <= now
const round = await ctx.prisma.round.create({ const round = await ctx.prisma.round.create({
data: { data: {
...rest, ...rest,
sortOrder, sortOrder,
status: shouldAutoActivate ? 'ACTIVE' : 'DRAFT',
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined, settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
}, },
}) })
// For FILTERING rounds, automatically add all projects from the program
if (input.roundType === 'FILTERING') {
const projects = await ctx.prisma.project.findMany({
where: { programId: input.programId },
select: { id: true },
})
if (projects.length > 0) {
await ctx.prisma.roundProject.createMany({
data: projects.map((p) => ({
roundId: round.id,
projectId: p.id,
status: 'SUBMITTED',
})),
skipDuplicates: true,
})
}
}
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await ctx.prisma.auditLog.create({
data: { data: {
@ -148,10 +173,24 @@ export const roundRouter = router({
} }
} }
// Check if we should auto-activate (if voting start is in the past and round is DRAFT)
const now = new Date()
let autoActivate = false
if (data.votingStartAt && data.votingStartAt <= now) {
const existingRound = await ctx.prisma.round.findUnique({
where: { id },
select: { status: true },
})
if (existingRound?.status === 'DRAFT') {
autoActivate = true
}
}
const round = await ctx.prisma.round.update({ const round = await ctx.prisma.round.update({
where: { id }, where: { id },
data: { data: {
...data, ...data,
...(autoActivate && { status: 'ACTIVE' }),
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined, settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
}, },
}) })

View File

@ -72,6 +72,9 @@ export type DocumentCheckConfig = {
export type AIScreeningConfig = { export type AIScreeningConfig = {
criteriaText: string criteriaText: string
action: 'FLAG' // AI screening always flags for human review action: 'FLAG' // AI screening always flags for human review
// Performance settings
batchSize?: number // Projects per API call (1-50, default 20)
parallelBatches?: number // Concurrent API calls (1-10, default 1)
} }
export type RuleConfig = FieldRuleConfig | DocumentCheckConfig | AIScreeningConfig export type RuleConfig = FieldRuleConfig | DocumentCheckConfig | AIScreeningConfig
@ -124,7 +127,11 @@ interface FilteringRuleInput {
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
const BATCH_SIZE = 20 const DEFAULT_BATCH_SIZE = 20
const MAX_BATCH_SIZE = 50
const MIN_BATCH_SIZE = 1
const DEFAULT_PARALLEL_BATCHES = 1
const MAX_PARALLEL_BATCHES = 10
// Optimized system prompt (compressed for token efficiency) // Optimized system prompt (compressed for token efficiency)
const AI_SCREENING_SYSTEM_PROMPT = `Project screening assistant. Evaluate against criteria, return JSON. const AI_SCREENING_SYSTEM_PROMPT = `Project screening assistant. Evaluate against criteria, return JSON.
@ -441,7 +448,18 @@ export async function executeAIScreening(
} }
const model = await getConfiguredModel() const model = await getConfiguredModel()
console.log(`[AI Filtering] Using model: ${model} for ${projects.length} projects`)
// Get batch settings from config
const batchSize = Math.min(
MAX_BATCH_SIZE,
Math.max(MIN_BATCH_SIZE, config.batchSize ?? DEFAULT_BATCH_SIZE)
)
const parallelBatches = Math.min(
MAX_PARALLEL_BATCHES,
Math.max(1, config.parallelBatches ?? DEFAULT_PARALLEL_BATCHES)
)
console.log(`[AI Filtering] Using model: ${model} for ${projects.length} projects (batch size: ${batchSize}, parallel: ${parallelBatches})`)
// Convert and anonymize projects // Convert and anonymize projects
const projectsWithRelations = projects.map(toProjectWithRelations) const projectsWithRelations = projects.map(toProjectWithRelations)
@ -454,39 +472,56 @@ export async function executeAIScreening(
} }
let totalTokens = 0 let totalTokens = 0
const totalBatches = Math.ceil(anonymized.length / BATCH_SIZE) const totalBatches = Math.ceil(anonymized.length / batchSize)
let processedBatches = 0
// Process in batches // Create batch chunks for parallel processing
for (let i = 0; i < anonymized.length; i += BATCH_SIZE) { const batches: Array<{ anon: typeof anonymized; maps: typeof mappings; index: number }> = []
const batchAnon = anonymized.slice(i, i + BATCH_SIZE) for (let i = 0; i < anonymized.length; i += batchSize) {
const batchMappings = mappings.slice(i, i + BATCH_SIZE) batches.push({
const currentBatch = Math.floor(i / BATCH_SIZE) + 1 anon: anonymized.slice(i, i + batchSize),
maps: mappings.slice(i, i + batchSize),
index: batches.length,
})
}
console.log(`[AI Filtering] Processing batch ${currentBatch}/${totalBatches}`) // Process batches in parallel chunks
for (let i = 0; i < batches.length; i += parallelBatches) {
const parallelChunk = batches.slice(i, i + parallelBatches)
console.log(`[AI Filtering] Processing batches ${i + 1}-${Math.min(i + parallelBatches, batches.length)} of ${totalBatches} (${parallelChunk.length} in parallel)`)
// Run parallel batches concurrently
const batchPromises = parallelChunk.map(async (batch) => {
const { results: batchResults, tokensUsed } = await processAIBatch( const { results: batchResults, tokensUsed } = await processAIBatch(
openai, openai,
model, model,
config.criteriaText, config.criteriaText,
batchAnon, batch.anon,
batchMappings, batch.maps,
userId, userId,
entityId entityId
) )
return { batchResults, tokensUsed, index: batch.index }
})
const parallelResults = await Promise.all(batchPromises)
// Merge results from all parallel batches
for (const { batchResults, tokensUsed } of parallelResults) {
totalTokens += tokensUsed totalTokens += tokensUsed
// Merge batch results
for (const [id, result] of batchResults) { for (const [id, result] of batchResults) {
results.set(id, result) results.set(id, result)
} }
processedBatches++
}
// Report progress // Report progress after each parallel chunk
if (onProgress) { if (onProgress) {
await onProgress({ await onProgress({
currentBatch, currentBatch: processedBatches,
totalBatches, totalBatches,
processedCount: Math.min((currentBatch) * BATCH_SIZE, anonymized.length), processedCount: Math.min(processedBatches * batchSize, anonymized.length),
tokensUsed: totalTokens, tokensUsed: totalTokens,
}) })
} }