Consolidated round management, AI filtering enhancements, MinIO storage restructure
- Fix STAGE_ACTIVE bug in assignment router (now ROUND_ACTIVE)
- Add evaluation form CRUD (getForm + upsertForm endpoints)
- Add advanceProjects mutation for manual project advancement
- Rewrite round detail page: 7-tab consolidated interface
- Add filtering rules UI with full CRUD (field-based, document check, AI screening)
- Add pageCount field to ProjectFile for document page limit filtering
- Enhance AI filtering: per-file page limits, category/region-aware guidelines
- Restructure MinIO paths: {ProjectName}/{RoundName}/{timestamp}-{file}
- Update dashboard and pool page links from /admin/competitions to /admin/rounds
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
845554fdb8
commit
8e5fc18da6
|
|
@ -683,10 +683,11 @@ model ProjectFile {
|
||||||
requirementId String? // FK to FileRequirement (if uploaded against a requirement)
|
requirementId String? // FK to FileRequirement (if uploaded against a requirement)
|
||||||
|
|
||||||
// File info
|
// File info
|
||||||
fileType FileType
|
fileType FileType
|
||||||
fileName String
|
fileName String
|
||||||
mimeType String
|
mimeType String
|
||||||
size Int // bytes
|
size Int // bytes
|
||||||
|
pageCount Int? // Number of pages (PDFs, presentations, etc.)
|
||||||
|
|
||||||
// MinIO location
|
// MinIO location
|
||||||
bucket String
|
bucket String
|
||||||
|
|
|
||||||
|
|
@ -467,13 +467,13 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
<Link href="/admin/competitions" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
|
<Link href="/admin/rounds" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
|
||||||
<div className="rounded-xl bg-blue-50 p-2.5 transition-colors group-hover:bg-blue-100">
|
<div className="rounded-xl bg-blue-50 p-2.5 transition-colors group-hover:bg-blue-100">
|
||||||
<Plus className="h-4 w-4 text-blue-600" />
|
<Plus className="h-4 w-4 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Competitions</p>
|
<p className="text-sm font-medium">Rounds</p>
|
||||||
<p className="text-xs text-muted-foreground">Manage rounds & competitions</p>
|
<p className="text-xs text-muted-foreground">Manage competition rounds</p>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/admin/projects/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-emerald-500/30 hover:bg-emerald-500/5">
|
<Link href="/admin/projects/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-emerald-500/30 hover:bg-emerald-500/5">
|
||||||
|
|
@ -517,7 +517,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/competitions"
|
href="/admin/rounds"
|
||||||
className="flex items-center gap-1 text-sm font-medium text-primary hover:underline"
|
className="flex items-center gap-1 text-sm font-medium text-primary hover:underline"
|
||||||
>
|
>
|
||||||
View all <ArrowRight className="h-3.5 w-3.5" />
|
View all <ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
|
@ -532,10 +532,10 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||||
No rounds created yet
|
No rounds created yet
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/competitions"
|
href="/admin/rounds"
|
||||||
className="mt-4 text-sm font-medium text-primary hover:underline"
|
className="mt-4 text-sm font-medium text-primary hover:underline"
|
||||||
>
|
>
|
||||||
Set up your competition
|
Set up your rounds
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -682,7 +682,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{pendingCOIs > 0 && (
|
{pendingCOIs > 0 && (
|
||||||
<Link href="/admin/competitions" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
<Link href="/admin/rounds" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShieldAlert className="h-4 w-4 text-amber-500" />
|
<ShieldAlert className="h-4 w-4 text-amber-500" />
|
||||||
<span className="text-sm">COI declarations to review</span>
|
<span className="text-sm">COI declarations to review</span>
|
||||||
|
|
@ -700,7 +700,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{draftRounds > 0 && (
|
{draftRounds > 0 && (
|
||||||
<Link href="/admin/competitions" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
<Link href="/admin/rounds" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CircleDot className="h-4 w-4 text-blue-500" />
|
<CircleDot className="h-4 w-4 text-blue-500" />
|
||||||
<span className="text-sm">Draft rounds to activate</span>
|
<span className="text-sm">Draft rounds to activate</span>
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,7 @@ export default function ProjectPoolPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/competitions/${urlCompetitionId}/rounds/${urlRoundId}` as Route}
|
href={`/admin/rounds/${urlRoundId}` as Route}
|
||||||
>
|
>
|
||||||
<Button variant="outline" size="sm" className="shrink-0">
|
<Button variant="outline" size="sm" className="shrink-0">
|
||||||
<ArrowLeft className="h-3.5 w-3.5 mr-1" />
|
<ArrowLeft className="h-3.5 w-3.5 mr-1" />
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -37,6 +37,13 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible'
|
||||||
import {
|
import {
|
||||||
Play,
|
Play,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
|
@ -55,6 +62,13 @@ import {
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Search,
|
Search,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
FileText,
|
||||||
|
Brain,
|
||||||
|
ListFilter,
|
||||||
|
GripVertical,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
|
|
@ -385,6 +399,9 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Filtering Rules */}
|
||||||
|
<FilteringRulesSection roundId={roundId} />
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
{statsLoading ? (
|
{statsLoading ? (
|
||||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-5">
|
<div className="grid gap-4 grid-cols-2 lg:grid-cols-5">
|
||||||
|
|
@ -922,3 +939,738 @@ function ConfidenceIndicator({ value }: { value: number }) {
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Filtering Rules Section ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type RuleType = 'FIELD_BASED' | 'DOCUMENT_CHECK' | 'AI_SCREENING'
|
||||||
|
|
||||||
|
const RULE_TYPE_META: Record<RuleType, { label: string; icon: typeof ListFilter; color: string; description: string }> = {
|
||||||
|
FIELD_BASED: { label: 'Field-Based', icon: ListFilter, color: 'bg-blue-100 text-blue-800 border-blue-200', description: 'Evaluate project fields (category, founding date, location, etc.)' },
|
||||||
|
DOCUMENT_CHECK: { label: 'Document Check', icon: FileText, color: 'bg-teal-100 text-teal-800 border-teal-200', description: 'Validate file uploads (min count, formats, page limits)' },
|
||||||
|
AI_SCREENING: { label: 'AI Screening', icon: Brain, color: 'bg-purple-100 text-purple-800 border-purple-200', description: 'GPT evaluates projects against natural language criteria' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIELD_OPTIONS = [
|
||||||
|
{ value: 'competitionCategory', label: 'Competition Category', operators: ['equals', 'not_equals'] },
|
||||||
|
{ value: 'foundedAt', label: 'Founded Date', operators: ['older_than_years', 'newer_than_years'] },
|
||||||
|
{ value: 'country', label: 'Country', operators: ['equals', 'not_equals', 'in', 'not_in'] },
|
||||||
|
{ value: 'geographicZone', label: 'Geographic Zone', operators: ['equals', 'not_equals', 'contains'] },
|
||||||
|
{ value: 'tags', label: 'Tags', operators: ['contains', 'in'] },
|
||||||
|
{ value: 'oceanIssue', label: 'Ocean Issue', operators: ['equals', 'not_equals', 'in'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const FILE_TYPES = [
|
||||||
|
{ value: 'EXEC_SUMMARY', label: 'Executive Summary' },
|
||||||
|
{ value: 'PRESENTATION', label: 'Presentation' },
|
||||||
|
{ value: 'BUSINESS_PLAN', label: 'Business Plan' },
|
||||||
|
{ value: 'VIDEO', label: 'Video' },
|
||||||
|
{ value: 'VIDEO_PITCH', label: 'Video Pitch' },
|
||||||
|
{ value: 'SUPPORTING_DOC', label: 'Supporting Doc' },
|
||||||
|
{ value: 'OTHER', label: 'Other' },
|
||||||
|
]
|
||||||
|
|
||||||
|
type FieldCondition = {
|
||||||
|
field: string
|
||||||
|
operator: string
|
||||||
|
value: string | number | string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuleFormData = {
|
||||||
|
name: string
|
||||||
|
ruleType: RuleType
|
||||||
|
priority: number
|
||||||
|
// FIELD_BASED
|
||||||
|
conditions: FieldCondition[]
|
||||||
|
logic: 'AND' | 'OR'
|
||||||
|
fieldAction: 'PASS' | 'REJECT' | 'FLAG'
|
||||||
|
// DOCUMENT_CHECK
|
||||||
|
requiredFileTypes: string[]
|
||||||
|
minFileCount: number | ''
|
||||||
|
maxPages: number | ''
|
||||||
|
maxPagesByFileType: Record<string, number>
|
||||||
|
docAction: 'PASS' | 'REJECT' | 'FLAG'
|
||||||
|
// AI_SCREENING
|
||||||
|
criteriaText: string
|
||||||
|
aiAction: 'PASS' | 'REJECT' | 'FLAG'
|
||||||
|
batchSize: number
|
||||||
|
parallelBatches: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FORM: RuleFormData = {
|
||||||
|
name: '',
|
||||||
|
ruleType: 'FIELD_BASED',
|
||||||
|
priority: 0,
|
||||||
|
conditions: [{ field: 'competitionCategory', operator: 'equals', value: '' }],
|
||||||
|
logic: 'AND',
|
||||||
|
fieldAction: 'REJECT',
|
||||||
|
requiredFileTypes: [],
|
||||||
|
minFileCount: '',
|
||||||
|
maxPages: '',
|
||||||
|
maxPagesByFileType: {},
|
||||||
|
docAction: 'REJECT',
|
||||||
|
criteriaText: '',
|
||||||
|
aiAction: 'FLAG',
|
||||||
|
batchSize: 20,
|
||||||
|
parallelBatches: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfigJson(form: RuleFormData): Record<string, unknown> {
|
||||||
|
switch (form.ruleType) {
|
||||||
|
case 'FIELD_BASED':
|
||||||
|
return {
|
||||||
|
conditions: form.conditions.map((c) => ({
|
||||||
|
field: c.field,
|
||||||
|
operator: c.operator,
|
||||||
|
value: c.value,
|
||||||
|
})),
|
||||||
|
logic: form.logic,
|
||||||
|
action: form.fieldAction,
|
||||||
|
}
|
||||||
|
case 'DOCUMENT_CHECK': {
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
action: form.docAction,
|
||||||
|
}
|
||||||
|
if (form.requiredFileTypes.length > 0) config.requiredFileTypes = form.requiredFileTypes
|
||||||
|
if (form.minFileCount !== '' && form.minFileCount > 0) config.minFileCount = form.minFileCount
|
||||||
|
if (form.maxPages !== '' && form.maxPages > 0) config.maxPages = form.maxPages
|
||||||
|
if (Object.keys(form.maxPagesByFileType).length > 0) config.maxPagesByFileType = form.maxPagesByFileType
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
case 'AI_SCREENING':
|
||||||
|
return {
|
||||||
|
criteriaText: form.criteriaText,
|
||||||
|
action: form.aiAction,
|
||||||
|
batchSize: form.batchSize,
|
||||||
|
parallelBatches: form.parallelBatches,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfigToForm(rule: { name: string; ruleType: string; configJson: unknown; priority: number }): RuleFormData {
|
||||||
|
const config = (rule.configJson || {}) as Record<string, unknown>
|
||||||
|
const base = { ...DEFAULT_FORM, name: rule.name, ruleType: rule.ruleType as RuleType, priority: rule.priority }
|
||||||
|
|
||||||
|
switch (rule.ruleType) {
|
||||||
|
case 'FIELD_BASED':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
conditions: (config.conditions as FieldCondition[]) || [{ field: 'competitionCategory', operator: 'equals', value: '' }],
|
||||||
|
logic: (config.logic as 'AND' | 'OR') || 'AND',
|
||||||
|
fieldAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'REJECT',
|
||||||
|
}
|
||||||
|
case 'DOCUMENT_CHECK':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
requiredFileTypes: (config.requiredFileTypes as string[]) || [],
|
||||||
|
minFileCount: (config.minFileCount as number) || '',
|
||||||
|
maxPages: (config.maxPages as number) || '',
|
||||||
|
maxPagesByFileType: (config.maxPagesByFileType as Record<string, number>) || {},
|
||||||
|
docAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'REJECT',
|
||||||
|
}
|
||||||
|
case 'AI_SCREENING':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
criteriaText: (config.criteriaText as string) || '',
|
||||||
|
aiAction: (config.action as 'PASS' | 'REJECT' | 'FLAG') || 'FLAG',
|
||||||
|
batchSize: (config.batchSize as number) || 20,
|
||||||
|
parallelBatches: (config.parallelBatches as number) || 1,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(true)
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editingRule, setEditingRule] = useState<string | null>(null)
|
||||||
|
const [form, setForm] = useState<RuleFormData>({ ...DEFAULT_FORM })
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const { data: rules, isLoading } = trpc.filtering.getRules.useQuery({ roundId })
|
||||||
|
|
||||||
|
const createMutation = trpc.filtering.createRule.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.filtering.getRules.invalidate({ roundId })
|
||||||
|
setDialogOpen(false)
|
||||||
|
setForm({ ...DEFAULT_FORM })
|
||||||
|
toast.success('Rule created')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = trpc.filtering.updateRule.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.filtering.getRules.invalidate({ roundId })
|
||||||
|
setDialogOpen(false)
|
||||||
|
setEditingRule(null)
|
||||||
|
setForm({ ...DEFAULT_FORM })
|
||||||
|
toast.success('Rule updated')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = trpc.filtering.deleteRule.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.filtering.getRules.invalidate({ roundId })
|
||||||
|
setDeleteConfirmId(null)
|
||||||
|
toast.success('Rule deleted')
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleActiveMutation = trpc.filtering.updateRule.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.filtering.getRules.invalidate({ roundId })
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const configJson = buildConfigJson(form)
|
||||||
|
if (editingRule) {
|
||||||
|
updateMutation.mutate({ id: editingRule, name: form.name, ruleType: form.ruleType, configJson, priority: form.priority })
|
||||||
|
} else {
|
||||||
|
createMutation.mutate({ roundId, name: form.name, ruleType: form.ruleType, configJson, priority: form.priority })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (rule: any) => {
|
||||||
|
setEditingRule(rule.id)
|
||||||
|
setForm(parseConfigToForm(rule))
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingRule(null)
|
||||||
|
setForm({ ...DEFAULT_FORM, priority: (rules?.length ?? 0) })
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = RULE_TYPE_META[form.ruleType]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<Card>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<CardHeader className="cursor-pointer hover:bg-muted/30 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ListFilter className="h-5 w-5 text-[#053d57]" />
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base">Filtering Rules</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{rules?.length ?? 0} active rule{(rules?.length ?? 0) !== 1 ? 's' : ''} — executed in priority order
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => { e.stopPropagation(); openCreate() }}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Add Rule
|
||||||
|
</Button>
|
||||||
|
{isOpen ? <ChevronUp className="h-4 w-4 text-muted-foreground" /> : <ChevronDown className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
<CollapsibleContent>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
|
||||||
|
</div>
|
||||||
|
) : rules && rules.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rules.map((rule: any, idx: number) => {
|
||||||
|
const typeMeta = RULE_TYPE_META[rule.ruleType as RuleType] || RULE_TYPE_META.FIELD_BASED
|
||||||
|
const Icon = typeMeta.icon
|
||||||
|
const config = (rule.configJson || {}) as Record<string, unknown>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rule.id}
|
||||||
|
className="flex items-center gap-3 rounded-lg border p-3 bg-background hover:bg-muted/30 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<GripVertical className="h-4 w-4 opacity-0 group-hover:opacity-50" />
|
||||||
|
<span className="text-xs font-mono w-5 text-center">{idx + 1}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Badge variant="outline" className={`text-xs shrink-0 ${typeMeta.color}`}>
|
||||||
|
<Icon className="h-3 w-3 mr-1" />
|
||||||
|
{typeMeta.label}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{rule.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
|
{rule.ruleType === 'FIELD_BASED' && (
|
||||||
|
<>
|
||||||
|
{((config.conditions as any[]) || []).length} condition{((config.conditions as any[]) || []).length !== 1 ? 's' : ''} ({config.logic as string || 'AND'}) → {config.action as string}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{rule.ruleType === 'DOCUMENT_CHECK' && (
|
||||||
|
<>
|
||||||
|
{config.minFileCount ? `Min ${config.minFileCount} files` : ''}
|
||||||
|
{config.requiredFileTypes ? ` \u00b7 Types: ${(config.requiredFileTypes as string[]).join(', ')}` : ''}
|
||||||
|
{config.maxPages ? ` \u00b7 Max ${config.maxPages} pages` : ''}
|
||||||
|
{config.maxPagesByFileType && Object.keys(config.maxPagesByFileType as object).length > 0
|
||||||
|
? ` \u00b7 Page limits per type`
|
||||||
|
: ''}
|
||||||
|
{' \u2192 '}{config.action as string}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{rule.ruleType === 'AI_SCREENING' && (
|
||||||
|
<>
|
||||||
|
{((config.criteriaText as string) || '').substring(0, 80)}{((config.criteriaText as string) || '').length > 80 ? '...' : ''} → {config.action as string}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<Switch
|
||||||
|
checked={rule.isActive}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
toggleActiveMutation.mutate({ id: rule.id, isActive: checked })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => openEdit(rule)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
onClick={() => setDeleteConfirmId(rule.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<ListFilter className="h-8 w-8 text-muted-foreground mb-3" />
|
||||||
|
<p className="text-sm font-medium">No filtering rules configured</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 mb-3">
|
||||||
|
Add rules to define how projects are screened
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" onClick={openCreate}>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Add First Rule
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Card>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* Create/Edit Rule Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={(open) => {
|
||||||
|
setDialogOpen(open)
|
||||||
|
if (!open) { setEditingRule(null); setForm({ ...DEFAULT_FORM }) }
|
||||||
|
}}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingRule ? 'Edit Rule' : 'Create Filtering Rule'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editingRule ? 'Update this filtering rule configuration' : 'Define a new rule for screening projects'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Rule Name + Priority */}
|
||||||
|
<div className="grid grid-cols-[1fr_100px] gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium mb-1.5 block">Rule Name</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. Startup Age Check"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium mb-1.5 block">Priority</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={form.priority}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, priority: parseInt(e.target.value) || 0 }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rule Type Selector */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium mb-2 block">Rule Type</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{(Object.entries(RULE_TYPE_META) as [RuleType, typeof RULE_TYPE_META[RuleType]][]).map(([type, m]) => {
|
||||||
|
const Icon = m.icon
|
||||||
|
const selected = form.ruleType === type
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
className={`flex flex-col items-start gap-1.5 rounded-lg border p-3 text-left transition-all ${
|
||||||
|
selected ? 'border-[#053d57] bg-[#053d57]/5 ring-1 ring-[#053d57]/20' : 'hover:border-muted-foreground/30'
|
||||||
|
}`}
|
||||||
|
onClick={() => setForm((f) => ({ ...f, ruleType: type }))}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Icon className={`h-4 w-4 ${selected ? 'text-[#053d57]' : 'text-muted-foreground'}`} />
|
||||||
|
<span className={`text-sm font-medium ${selected ? 'text-[#053d57]' : ''}`}>{m.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground leading-snug">{m.description}</p>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type-Specific Config */}
|
||||||
|
{form.ruleType === 'FIELD_BASED' && (
|
||||||
|
<div className="space-y-3 rounded-lg border p-4 bg-muted/20">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-sm font-medium">Conditions</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={form.logic} onValueChange={(v) => setForm((f) => ({ ...f, logic: v as 'AND' | 'OR' }))}>
|
||||||
|
<SelectTrigger className="w-20 h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="AND">AND</SelectItem>
|
||||||
|
<SelectItem value="OR">OR</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.conditions.map((cond, i) => {
|
||||||
|
const fieldMeta = FIELD_OPTIONS.find((f) => f.value === cond.field)
|
||||||
|
return (
|
||||||
|
<div key={i} className="grid grid-cols-[1fr_1fr_1fr_36px] gap-2 items-end">
|
||||||
|
<div>
|
||||||
|
{i === 0 && <Label className="text-xs text-muted-foreground mb-1 block">Field</Label>}
|
||||||
|
<Select
|
||||||
|
value={cond.field}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
const newConds = [...form.conditions]
|
||||||
|
const newFieldMeta = FIELD_OPTIONS.find((f) => f.value === v)
|
||||||
|
newConds[i] = { field: v, operator: newFieldMeta?.operators[0] || 'equals', value: '' }
|
||||||
|
setForm((f) => ({ ...f, conditions: newConds }))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{FIELD_OPTIONS.map((fo) => (
|
||||||
|
<SelectItem key={fo.value} value={fo.value}>{fo.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{i === 0 && <Label className="text-xs text-muted-foreground mb-1 block">Operator</Label>}
|
||||||
|
<Select
|
||||||
|
value={cond.operator}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
const newConds = [...form.conditions]
|
||||||
|
newConds[i] = { ...newConds[i], operator: v }
|
||||||
|
setForm((f) => ({ ...f, conditions: newConds }))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(fieldMeta?.operators || ['equals']).map((op) => (
|
||||||
|
<SelectItem key={op} value={op}>{op.replace(/_/g, ' ')}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{i === 0 && <Label className="text-xs text-muted-foreground mb-1 block">Value</Label>}
|
||||||
|
<Input
|
||||||
|
className="h-8 text-xs"
|
||||||
|
placeholder="Value..."
|
||||||
|
value={typeof cond.value === 'object' ? (cond.value as string[]).join(', ') : String(cond.value)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newConds = [...form.conditions]
|
||||||
|
const val = ['in', 'not_in'].includes(cond.operator)
|
||||||
|
? e.target.value.split(',').map((s) => s.trim())
|
||||||
|
: ['older_than_years', 'newer_than_years'].includes(cond.operator)
|
||||||
|
? parseInt(e.target.value) || 0
|
||||||
|
: e.target.value
|
||||||
|
newConds[i] = { ...newConds[i], value: val }
|
||||||
|
setForm((f) => ({ ...f, conditions: newConds }))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-red-500"
|
||||||
|
disabled={form.conditions.length <= 1}
|
||||||
|
onClick={() => {
|
||||||
|
const newConds = form.conditions.filter((_, j) => j !== i)
|
||||||
|
setForm((f) => ({ ...f, conditions: newConds }))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
conditions: [...f.conditions, { field: 'competitionCategory', operator: 'equals', value: '' }],
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
Add Condition
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground mb-1 block">Action when conditions match</Label>
|
||||||
|
<Select value={form.fieldAction} onValueChange={(v) => setForm((f) => ({ ...f, fieldAction: v as any }))}>
|
||||||
|
<SelectTrigger className="w-40 h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="PASS">Pass (keep project)</SelectItem>
|
||||||
|
<SelectItem value="REJECT">Reject (filter out)</SelectItem>
|
||||||
|
<SelectItem value="FLAG">Flag (manual review)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.ruleType === 'DOCUMENT_CHECK' && (
|
||||||
|
<div className="space-y-4 rounded-lg border p-4 bg-muted/20">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground mb-1 block">Minimum File Count</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
placeholder="e.g. 3"
|
||||||
|
value={form.minFileCount}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, minFileCount: e.target.value ? parseInt(e.target.value) : '' }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground mb-1 block">Max Pages (any file)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
placeholder="e.g. 10"
|
||||||
|
value={form.maxPages}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, maxPages: e.target.value ? parseInt(e.target.value) : '' }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground mb-1.5 block">Required File Formats</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{['pdf', 'docx', 'pptx', 'mp4', 'xlsx'].map((ext) => (
|
||||||
|
<label key={ext} className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={form.requiredFileTypes.includes(ext)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
requiredFileTypes: checked
|
||||||
|
? [...f.requiredFileTypes, ext]
|
||||||
|
: f.requiredFileTypes.filter((t) => t !== ext),
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-mono">.{ext}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground mb-1.5 block">Max Pages by File Type</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{FILE_TYPES.map((ft) => {
|
||||||
|
const limit = form.maxPagesByFileType[ft.value]
|
||||||
|
const hasLimit = limit !== undefined
|
||||||
|
return (
|
||||||
|
<div key={ft.value} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={hasLimit}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setForm((f) => {
|
||||||
|
const next = { ...f.maxPagesByFileType }
|
||||||
|
if (checked) next[ft.value] = 10
|
||||||
|
else delete next[ft.value]
|
||||||
|
return { ...f, maxPagesByFileType: next }
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs w-32">{ft.label}</span>
|
||||||
|
{hasLimit && (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="h-7 text-xs w-20"
|
||||||
|
value={limit}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
maxPagesByFileType: { ...f.maxPagesByFileType, [ft.value]: parseInt(e.target.value) || 1 },
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasLimit && <span className="text-xs text-muted-foreground">pages max</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground mb-1 block">Action when check fails</Label>
|
||||||
|
<Select value={form.docAction} onValueChange={(v) => setForm((f) => ({ ...f, docAction: v as any }))}>
|
||||||
|
<SelectTrigger className="w-40 h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="REJECT">Reject (filter out)</SelectItem>
|
||||||
|
<SelectItem value="FLAG">Flag (manual review)</SelectItem>
|
||||||
|
<SelectItem value="PASS">Pass (informational)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.ruleType === 'AI_SCREENING' && (
|
||||||
|
<div className="space-y-4 rounded-lg border p-4 bg-muted/20">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground mb-1.5 block">Screening Criteria</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Write the criteria the AI should evaluate against. Example: 1. Ocean conservation impact must be clearly stated 2. Documents must be in English 3. For Business Concepts, academic rigor is acceptable 4. For African projects, apply a lower quality threshold (score >= 5/10)"
|
||||||
|
value={form.criteriaText}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, criteriaText: e.target.value }))}
|
||||||
|
rows={8}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
The AI has access to: category, country, region, founded year, ocean issue, tags, description, file details (type, page count, size), and team size.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground mb-1 block">Action</Label>
|
||||||
|
<Select value={form.aiAction} onValueChange={(v) => setForm((f) => ({ ...f, aiAction: v as any }))}>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="FLAG">Flag for review</SelectItem>
|
||||||
|
<SelectItem value="REJECT">Auto-reject</SelectItem>
|
||||||
|
<SelectItem value="PASS">Auto-pass</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground mb-1 block">Batch Size</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
value={form.batchSize}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, batchSize: parseInt(e.target.value) || 20 }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-muted-foreground mb-1 block">Parallel Batches</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
value={form.parallelBatches}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, parallelBatches: parseInt(e.target.value) || 1 }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => { setDialogOpen(false); setEditingRule(null); setForm({ ...DEFAULT_FORM }) }}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!form.name.trim() || createMutation.isPending || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
{(createMutation.isPending || updateMutation.isPending) && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
{editingRule ? 'Update Rule' : 'Create Rule'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<AlertDialog open={!!deleteConfirmId} onOpenChange={(open) => { if (!open) setDeleteConfirmId(null) }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Filtering Rule?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently remove this rule. Projects already filtered will not be affected.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
onClick={() => {
|
||||||
|
if (deleteConfirmId) deleteMutation.mutate({ id: deleteConfirmId })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,14 +119,39 @@ export async function deleteObject(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique object key for a project file
|
* Sanitize a name for use as a MinIO path segment.
|
||||||
|
* Removes special characters, replaces spaces with underscores, limits length.
|
||||||
*/
|
*/
|
||||||
export function generateObjectKey(
|
function sanitizePath(name: string): string {
|
||||||
projectId: string,
|
return (
|
||||||
fileName: string
|
name
|
||||||
): string {
|
.trim()
|
||||||
const timestamp = Date.now()
|
.replace(/[^a-zA-Z0-9\-_ ]/g, '')
|
||||||
const sanitizedName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
|
.replace(/\s+/g, '_')
|
||||||
return `projects/${projectId}/${timestamp}-${sanitizedName}`
|
.substring(0, 100) || 'unnamed'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique object key for a project file.
|
||||||
|
*
|
||||||
|
* Structure: {ProjectName}/{RoundName}/{timestamp}-{fileName}
|
||||||
|
* - projectName: human-readable project title (sanitized)
|
||||||
|
* - roundName: round name for submission context (sanitized), defaults to "general"
|
||||||
|
* - fileName: original file name (sanitized)
|
||||||
|
*
|
||||||
|
* Existing files with old-style keys (projects/{id}/...) are unaffected
|
||||||
|
* because retrieval uses the objectKey stored in the database.
|
||||||
|
*/
|
||||||
|
export function generateObjectKey(
|
||||||
|
projectName: string,
|
||||||
|
fileName: string,
|
||||||
|
roundName?: string
|
||||||
|
): string {
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const sanitizedProject = sanitizePath(projectName)
|
||||||
|
const sanitizedRound = roundName ? sanitizePath(roundName) : 'general'
|
||||||
|
const sanitizedFile = fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
|
||||||
|
return `${sanitizedProject}/${sanitizedRound}/${timestamp}-${sanitizedFile}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import crypto from 'crypto'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { router, publicProcedure, protectedProcedure } from '../trpc'
|
import { router, publicProcedure, protectedProcedure } from '../trpc'
|
||||||
import { getPresignedUrl } from '@/lib/minio'
|
import { getPresignedUrl, generateObjectKey } from '@/lib/minio'
|
||||||
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
|
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
import { createNotification } from '../services/in-app-notification'
|
import { createNotification } from '../services/in-app-notification'
|
||||||
|
|
@ -306,9 +306,17 @@ export const applicantRouter = router({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = Date.now()
|
// Fetch round name for storage path (if uploading against a round)
|
||||||
const sanitizedName = input.fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
|
let roundName: string | undefined
|
||||||
const objectKey = `${project.id}/${input.fileType}/${timestamp}-${sanitizedName}`
|
if (input.roundId) {
|
||||||
|
const round = await ctx.prisma.round.findUnique({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { name: true },
|
||||||
|
})
|
||||||
|
roundName = round?.name
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectKey = generateObjectKey(project.title, input.fileName, roundName)
|
||||||
|
|
||||||
const url = await getPresignedUrl(SUBMISSIONS_BUCKET, objectKey, 'PUT', 3600)
|
const url = await getPresignedUrl(SUBMISSIONS_BUCKET, objectKey, 'PUT', 3600)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -241,7 +241,7 @@ export const assignmentRouter = router({
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const where: Record<string, unknown> = {
|
const where: Record<string, unknown> = {
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
round: { status: 'STAGE_ACTIVE' },
|
round: { status: 'ROUND_ACTIVE' },
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.roundId) {
|
if (input.roundId) {
|
||||||
|
|
|
||||||
|
|
@ -1019,6 +1019,129 @@ export const evaluationRouter = router({
|
||||||
return discussion
|
return discussion
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Evaluation Form CRUD (Admin)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active evaluation form for a round (admin view with full details)
|
||||||
|
*/
|
||||||
|
getForm: adminProcedure
|
||||||
|
.input(z.object({ roundId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const form = await ctx.prisma.evaluationForm.findFirst({
|
||||||
|
where: { roundId: input.roundId, isActive: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!form) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: form.id,
|
||||||
|
roundId: form.roundId,
|
||||||
|
version: form.version,
|
||||||
|
isActive: form.isActive,
|
||||||
|
criteriaJson: form.criteriaJson as Array<{
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
weight?: number
|
||||||
|
minScore?: number
|
||||||
|
maxScore?: number
|
||||||
|
}>,
|
||||||
|
scalesJson: form.scalesJson as Record<string, unknown> | null,
|
||||||
|
createdAt: form.createdAt,
|
||||||
|
updatedAt: form.updatedAt,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update the evaluation form for a round.
|
||||||
|
* Deactivates any existing active form and creates a new versioned one.
|
||||||
|
*/
|
||||||
|
upsertForm: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
criteria: z.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
label: z.string().min(1).max(255),
|
||||||
|
description: z.string().max(2000).optional(),
|
||||||
|
weight: z.number().min(0).max(100).optional(),
|
||||||
|
minScore: z.number().int().min(0).optional(),
|
||||||
|
maxScore: z.number().int().min(1).optional(),
|
||||||
|
})
|
||||||
|
).min(1),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { roundId, criteria } = input
|
||||||
|
|
||||||
|
// Verify round exists
|
||||||
|
await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId } })
|
||||||
|
|
||||||
|
// Get current max version for this round
|
||||||
|
const latestForm = await ctx.prisma.evaluationForm.findFirst({
|
||||||
|
where: { roundId },
|
||||||
|
orderBy: { version: 'desc' },
|
||||||
|
select: { version: true },
|
||||||
|
})
|
||||||
|
const nextVersion = (latestForm?.version ?? 0) + 1
|
||||||
|
|
||||||
|
// Build criteriaJson with defaults
|
||||||
|
const criteriaJson = criteria.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: c.label,
|
||||||
|
description: c.description || '',
|
||||||
|
weight: c.weight ?? 1,
|
||||||
|
scale: `${c.minScore ?? 1}-${c.maxScore ?? 10}`,
|
||||||
|
required: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Auto-generate scalesJson from criteria min/max ranges
|
||||||
|
const scaleSet = new Set(criteriaJson.map((c) => c.scale))
|
||||||
|
const scalesJson: Record<string, { min: number; max: number }> = {}
|
||||||
|
for (const scale of scaleSet) {
|
||||||
|
const [min, max] = scale.split('-').map(Number)
|
||||||
|
scalesJson[scale] = { min, max }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction: deactivate old → create new
|
||||||
|
const form = await ctx.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.evaluationForm.updateMany({
|
||||||
|
where: { roundId, isActive: true },
|
||||||
|
data: { isActive: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
return tx.evaluationForm.create({
|
||||||
|
data: {
|
||||||
|
roundId,
|
||||||
|
version: nextVersion,
|
||||||
|
criteriaJson,
|
||||||
|
scalesJson,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'UPSERT_EVALUATION_FORM',
|
||||||
|
entityType: 'EvaluationForm',
|
||||||
|
entityId: form.id,
|
||||||
|
detailsJson: {
|
||||||
|
roundId,
|
||||||
|
version: nextVersion,
|
||||||
|
criteriaCount: criteria.length,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return form
|
||||||
|
}),
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Phase 4: Stage-scoped evaluation procedures
|
// Phase 4: Stage-scoped evaluation procedures
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -149,20 +149,27 @@ export const fileRouter = router({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let isLate = false
|
// Fetch project title and optional round name for storage path
|
||||||
if (input.roundId) {
|
const [project, roundInfo] = await Promise.all([
|
||||||
const stage = await ctx.prisma.round.findUnique({
|
ctx.prisma.project.findUniqueOrThrow({
|
||||||
where: { id: input.roundId },
|
where: { id: input.projectId },
|
||||||
select: { windowCloseAt: true },
|
select: { title: true },
|
||||||
})
|
}),
|
||||||
|
input.roundId
|
||||||
|
? ctx.prisma.round.findUnique({
|
||||||
|
where: { id: input.roundId },
|
||||||
|
select: { name: true, windowCloseAt: true },
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
])
|
||||||
|
|
||||||
if (stage?.windowCloseAt) {
|
let isLate = false
|
||||||
isLate = new Date() > stage.windowCloseAt
|
if (roundInfo?.windowCloseAt) {
|
||||||
}
|
isLate = new Date() > roundInfo.windowCloseAt
|
||||||
}
|
}
|
||||||
|
|
||||||
const bucket = BUCKET_NAME
|
const bucket = BUCKET_NAME
|
||||||
const objectKey = generateObjectKey(input.projectId, input.fileName)
|
const objectKey = generateObjectKey(project.title, input.fileName, roundInfo?.name)
|
||||||
|
|
||||||
const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600) // 1 hour
|
const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600) // 1 hour
|
||||||
|
|
||||||
|
|
@ -1122,8 +1129,20 @@ export const fileRouter = router({
|
||||||
else if (input.mimeType.includes('presentation') || input.mimeType.includes('powerpoint'))
|
else if (input.mimeType.includes('presentation') || input.mimeType.includes('powerpoint'))
|
||||||
fileType = 'PRESENTATION'
|
fileType = 'PRESENTATION'
|
||||||
|
|
||||||
|
// Fetch project title and window name for storage path
|
||||||
|
const [project, submissionWindow] = await Promise.all([
|
||||||
|
ctx.prisma.project.findUniqueOrThrow({
|
||||||
|
where: { id: input.projectId },
|
||||||
|
select: { title: true },
|
||||||
|
}),
|
||||||
|
ctx.prisma.submissionWindow.findUniqueOrThrow({
|
||||||
|
where: { id: input.submissionWindowId },
|
||||||
|
select: { name: true },
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
const bucket = BUCKET_NAME
|
const bucket = BUCKET_NAME
|
||||||
const objectKey = generateObjectKey(input.projectId, input.fileName)
|
const objectKey = generateObjectKey(project.title, input.fileName, submissionWindow.name)
|
||||||
const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600)
|
const uploadUrl = await getPresignedUrl(bucket, objectKey, 'PUT', 3600)
|
||||||
|
|
||||||
// Remove any existing file for this project+requirement combo (replace)
|
// Remove any existing file for this project+requirement combo (replace)
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
||||||
project: {
|
project: {
|
||||||
include: {
|
include: {
|
||||||
files: {
|
files: {
|
||||||
select: { id: true, fileName: true, fileType: true },
|
select: { id: true, fileName: true, fileType: true, size: true, pageCount: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -513,7 +513,7 @@ export const filteringRouter = router({
|
||||||
project: {
|
project: {
|
||||||
include: {
|
include: {
|
||||||
files: {
|
files: {
|
||||||
select: { id: true, fileName: true, fileType: true },
|
select: { id: true, fileName: true, fileType: true, size: true, pageCount: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,137 @@ export const roundRouter = router({
|
||||||
return existing
|
return existing
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Project Advancement (Manual Only)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance PASSED projects from one round to the next.
|
||||||
|
* This is ALWAYS manual — no auto-advancement after AI filtering.
|
||||||
|
* Admin must explicitly trigger this after reviewing results.
|
||||||
|
*/
|
||||||
|
advanceProjects: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
roundId: z.string(),
|
||||||
|
targetRoundId: z.string().optional(),
|
||||||
|
projectIds: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { roundId, targetRoundId, projectIds } = input
|
||||||
|
|
||||||
|
// Get current round with competition context
|
||||||
|
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: roundId },
|
||||||
|
select: { id: true, name: true, competitionId: true, sortOrder: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Determine target round
|
||||||
|
let targetRound: { id: string; name: string }
|
||||||
|
if (targetRoundId) {
|
||||||
|
targetRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
|
where: { id: targetRoundId },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Find next round in same competition by sortOrder
|
||||||
|
const nextRound = await ctx.prisma.round.findFirst({
|
||||||
|
where: {
|
||||||
|
competitionId: currentRound.competitionId,
|
||||||
|
sortOrder: { gt: currentRound.sortOrder },
|
||||||
|
},
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
if (!nextRound) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'No subsequent round exists in this competition. Create the next round first.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
targetRound = nextRound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which projects to advance
|
||||||
|
let idsToAdvance: string[]
|
||||||
|
if (projectIds && projectIds.length > 0) {
|
||||||
|
idsToAdvance = projectIds
|
||||||
|
} else {
|
||||||
|
// Default: all PASSED projects in current round
|
||||||
|
const passedStates = await ctx.prisma.projectRoundState.findMany({
|
||||||
|
where: { roundId, state: 'PASSED' },
|
||||||
|
select: { projectId: true },
|
||||||
|
})
|
||||||
|
idsToAdvance = passedStates.map((s) => s.projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (idsToAdvance.length === 0) {
|
||||||
|
return { advancedCount: 0, targetRoundId: targetRound.id, targetRoundName: targetRound.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction: create entries in target round + mark current as COMPLETED
|
||||||
|
await ctx.prisma.$transaction(async (tx) => {
|
||||||
|
// Create ProjectRoundState in target round
|
||||||
|
await tx.projectRoundState.createMany({
|
||||||
|
data: idsToAdvance.map((projectId) => ({
|
||||||
|
projectId,
|
||||||
|
roundId: targetRound.id,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark current round states as COMPLETED
|
||||||
|
await tx.projectRoundState.updateMany({
|
||||||
|
where: {
|
||||||
|
roundId,
|
||||||
|
projectId: { in: idsToAdvance },
|
||||||
|
state: 'PASSED',
|
||||||
|
},
|
||||||
|
data: { state: 'COMPLETED' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update project status to ASSIGNED
|
||||||
|
await tx.project.updateMany({
|
||||||
|
where: { id: { in: idsToAdvance } },
|
||||||
|
data: { status: 'ASSIGNED' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Status history
|
||||||
|
await tx.projectStatusHistory.createMany({
|
||||||
|
data: idsToAdvance.map((projectId) => ({
|
||||||
|
projectId,
|
||||||
|
status: 'ASSIGNED',
|
||||||
|
changedBy: ctx.user?.id,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'ADVANCE_PROJECTS',
|
||||||
|
entityType: 'Round',
|
||||||
|
entityId: roundId,
|
||||||
|
detailsJson: {
|
||||||
|
fromRound: currentRound.name,
|
||||||
|
toRound: targetRound.name,
|
||||||
|
targetRoundId: targetRound.id,
|
||||||
|
projectCount: idsToAdvance.length,
|
||||||
|
projectIds: idsToAdvance,
|
||||||
|
},
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
advancedCount: idsToAdvance.length,
|
||||||
|
targetRoundId: targetRound.id,
|
||||||
|
targetRoundName: targetRound.name,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Submission Window Management
|
// Submission Window Management
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,8 @@ export type FieldRuleConfig = {
|
||||||
export type DocumentCheckConfig = {
|
export type DocumentCheckConfig = {
|
||||||
requiredFileTypes?: string[] // e.g. ['pdf', 'docx']
|
requiredFileTypes?: string[] // e.g. ['pdf', 'docx']
|
||||||
minFileCount?: number
|
minFileCount?: number
|
||||||
|
maxPages?: number // Max pages for ANY file
|
||||||
|
maxPagesByFileType?: Record<string, number> // e.g. { "EXECUTIVE_SUMMARY": 2, "PITCH_DECK": 10 }
|
||||||
action: 'PASS' | 'REJECT' | 'FLAG'
|
action: 'PASS' | 'REJECT' | 'FLAG'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,7 +112,7 @@ interface ProjectForFiltering {
|
||||||
institution?: string | null
|
institution?: string | null
|
||||||
submissionSource?: SubmissionSource
|
submissionSource?: SubmissionSource
|
||||||
submittedAt?: Date | null
|
submittedAt?: Date | null
|
||||||
files: Array<{ id: string; fileName: string; fileType?: FileType | null }>
|
files: Array<{ id: string; fileName: string; fileType?: FileType | null; size?: number; pageCount?: number | null }>
|
||||||
_count?: {
|
_count?: {
|
||||||
teamMembers?: number
|
teamMembers?: number
|
||||||
files?: number
|
files?: number
|
||||||
|
|
@ -162,10 +164,22 @@ Return a JSON object with this exact structure:
|
||||||
- 3-4: Weak — significant shortcomings against criteria
|
- 3-4: Weak — significant shortcomings against criteria
|
||||||
- 1-2: Poor — does not meet criteria or appears low-quality/spam
|
- 1-2: Poor — does not meet criteria or appears low-quality/spam
|
||||||
|
|
||||||
|
## Available Data Per Project
|
||||||
|
- category: STARTUP or BUSINESS_CONCEPT
|
||||||
|
- country, region: geographic location (use for regional considerations)
|
||||||
|
- founded_year: when the company/initiative was founded (use for age checks)
|
||||||
|
- ocean_issue: the ocean conservation area
|
||||||
|
- file_count, file_types: uploaded documents summary
|
||||||
|
- files[]: per-file details with file_type, page_count (if known), and size_kb
|
||||||
|
- description: project summary text
|
||||||
|
- tags: topic tags
|
||||||
|
|
||||||
## Guidelines
|
## Guidelines
|
||||||
- Evaluate ONLY against the provided criteria, not your own standards
|
- Evaluate ONLY against the provided criteria, not your own standards
|
||||||
- A confidence of 1.0 means absolute certainty; 0.5 means borderline
|
- A confidence of 1.0 means absolute certainty; 0.5 means borderline
|
||||||
- Flag spam_risk=true for: AI-generated filler text, copied content, or irrelevant submissions
|
- Flag spam_risk=true for: AI-generated filler text, copied content, or irrelevant submissions
|
||||||
|
- When criteria differ by category (e.g. stricter for STARTUP vs BUSINESS_CONCEPT), apply the appropriate threshold
|
||||||
|
- When criteria mention regional considerations (e.g. African projects), use the country/region fields
|
||||||
- Do not include any personal identifiers in reasoning
|
- Do not include any personal identifiers in reasoning
|
||||||
- If project data is insufficient to evaluate, set confidence below 0.3`
|
- If project data is insufficient to evaluate, set confidence below 0.3`
|
||||||
|
|
||||||
|
|
@ -293,6 +307,25 @@ export function evaluateDocumentRule(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check global max pages (any file exceeding limit fails)
|
||||||
|
if (config.maxPages !== undefined) {
|
||||||
|
const overLimit = files.some((f) => f.pageCount != null && f.pageCount > config.maxPages!)
|
||||||
|
if (overLimit) {
|
||||||
|
return { passed: false, action: config.action }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check per-fileType max pages (e.g. EXECUTIVE_SUMMARY: 2, PITCH_DECK: 10)
|
||||||
|
if (config.maxPagesByFileType && Object.keys(config.maxPagesByFileType).length > 0) {
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.fileType || file.pageCount == null) continue
|
||||||
|
const limit = config.maxPagesByFileType[file.fileType]
|
||||||
|
if (limit !== undefined && file.pageCount > limit) {
|
||||||
|
return { passed: false, action: config.action }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { passed: true, action: config.action }
|
return { passed: true, action: config.action }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,12 @@ export interface AnonymizationResult {
|
||||||
* Comprehensive anonymized project data for AI filtering
|
* Comprehensive anonymized project data for AI filtering
|
||||||
* Includes all fields needed for flexible filtering criteria
|
* Includes all fields needed for flexible filtering criteria
|
||||||
*/
|
*/
|
||||||
|
export interface AnonymizedFileInfo {
|
||||||
|
file_type: string // FileType enum value
|
||||||
|
page_count: number | null // Number of pages if known
|
||||||
|
size_kb: number // File size in KB
|
||||||
|
}
|
||||||
|
|
||||||
export interface AnonymizedProjectForAI {
|
export interface AnonymizedProjectForAI {
|
||||||
project_id: string // P1, P2, etc.
|
project_id: string // P1, P2, etc.
|
||||||
title: string // Sanitized
|
title: string // Sanitized
|
||||||
|
|
@ -94,6 +100,7 @@ export interface AnonymizedProjectForAI {
|
||||||
has_description: boolean
|
has_description: boolean
|
||||||
file_count: number
|
file_count: number
|
||||||
file_types: string[] // FileType values
|
file_types: string[] // FileType values
|
||||||
|
files: AnonymizedFileInfo[] // Per-file details for document analysis
|
||||||
wants_mentorship: boolean
|
wants_mentorship: boolean
|
||||||
submission_source: SubmissionSource
|
submission_source: SubmissionSource
|
||||||
submitted_date: string | null // YYYY-MM-DD only
|
submitted_date: string | null // YYYY-MM-DD only
|
||||||
|
|
@ -121,7 +128,7 @@ export interface ProjectWithRelations {
|
||||||
teamMembers?: number
|
teamMembers?: number
|
||||||
files?: number
|
files?: number
|
||||||
}
|
}
|
||||||
files?: Array<{ fileType: FileType | null }>
|
files?: Array<{ fileType: FileType | null; size?: number; pageCount?: number | null }>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -153,7 +160,7 @@ export function toProjectWithRelations(project: {
|
||||||
submissionSource?: string
|
submissionSource?: string
|
||||||
submittedAt?: Date | null
|
submittedAt?: Date | null
|
||||||
_count?: { teamMembers?: number; files?: number }
|
_count?: { teamMembers?: number; files?: number }
|
||||||
files?: Array<{ fileType?: string | null; [key: string]: unknown }>
|
files?: Array<{ fileType?: string | null; size?: number; pageCount?: number | null; [key: string]: unknown }>
|
||||||
}): ProjectWithRelations {
|
}): ProjectWithRelations {
|
||||||
return {
|
return {
|
||||||
id: project.id,
|
id: project.id,
|
||||||
|
|
@ -173,7 +180,11 @@ export function toProjectWithRelations(project: {
|
||||||
teamMembers: project._count?.teamMembers ?? 0,
|
teamMembers: project._count?.teamMembers ?? 0,
|
||||||
files: project._count?.files ?? project.files?.length ?? 0,
|
files: project._count?.files ?? project.files?.length ?? 0,
|
||||||
},
|
},
|
||||||
files: project.files?.map((f) => ({ fileType: (f.fileType as FileType) ?? null })) ?? [],
|
files: project.files?.map((f) => ({
|
||||||
|
fileType: (f.fileType as FileType) ?? null,
|
||||||
|
size: f.size,
|
||||||
|
pageCount: f.pageCount ?? null,
|
||||||
|
})) ?? [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -288,6 +299,11 @@ export function anonymizeProjectForAI(
|
||||||
file_types: project.files
|
file_types: project.files
|
||||||
?.map((f) => f.fileType)
|
?.map((f) => f.fileType)
|
||||||
.filter((ft): ft is FileType => ft !== null) ?? [],
|
.filter((ft): ft is FileType => ft !== null) ?? [],
|
||||||
|
files: project.files?.map((f) => ({
|
||||||
|
file_type: f.fileType ?? 'OTHER',
|
||||||
|
page_count: f.pageCount ?? null,
|
||||||
|
size_kb: Math.round((f.size ?? 0) / 1024),
|
||||||
|
})) ?? [],
|
||||||
wants_mentorship: project.wantsMentorship ?? false,
|
wants_mentorship: project.wantsMentorship ?? false,
|
||||||
submission_source: project.submissionSource,
|
submission_source: project.submissionSource,
|
||||||
submitted_date: project.submittedAt?.toISOString().split('T')[0] ?? null,
|
submitted_date: project.submittedAt?.toISOString().split('T')[0] ?? null,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue