Compare commits

..

10 Commits

Author SHA1 Message Date
Matt
e5b7cdf670 Add document analysis: page count, text extraction & language detection
Some checks failed
Build and Push Docker Image / build (push) Failing after 11s
Introduces a document analyzer service that extracts page count (via pdf-parse),
text preview, and detected language (via franc) from uploaded files. Analysis runs
automatically on upload (configurable via SystemSettings) and can be triggered
retroactively for existing files. Results are displayed as badges in the FileViewer
and fed to AI screening for language-based filtering criteria.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:08:20 +01:00
Matt
90f36ac9b2 Retroactive auto-PASS for projects with complete documents
Wire batchCheckRequirementsAndTransition into round activation and reopen
so pre-existing projects that already have all required docs get auto-
passed. Also adds checkDocumentCompletion endpoint for manual sweeps on
already-active rounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:08:20 +01:00
Matt
a921731c52 Pass tag confidence scores to AI assignment for weighted matching
The AI assignment path was receiving project tags as flat strings, losing
the confidence scores from AI tagging. Now both the GPT path and the
fallback algorithm weight tag matches by confidence — a 0.9 tag matters
more than a 0.5 one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:08:20 +01:00
fc8e58f985 Auto-transition projects to PASSED when all required documents uploaded
Add checkRequirementsAndTransition() to round-engine that checks if all
required FileRequirements for a round are satisfied by uploaded files.
When all are met and the project is PENDING/IN_PROGRESS, it auto-
transitions to PASSED. Also adds batchCheckRequirementsAndTransition()
for bulk operations.

Wired into:
- file.adminUploadForRoundRequirement (admin bulk upload)
- applicant.saveFileMetadata (applicant self-upload)

Non-fatal: failures in the check never break the upload itself.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:08:20 +01:00
Matt
e547d2bd03 Add auto-pass & advance for intake rounds (no manual marking needed)
Some checks failed
Build and Push Docker Image / build (push) Failing after 7s
For INTAKE, SUBMISSION, and MENTORING rounds, the Advance Projects dialog
now shows a simplified "Advance All" flow that auto-passes all pending
projects and advances them in one click. Backend accepts autoPassPending
flag to bulk-set PENDING→PASSED before advancing. Jury/evaluation rounds
keep the existing per-project selection workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 19:09:23 +01:00
Matt
f731f96a0a Hide jury stat card in header for non-jury rounds (INTAKE, FILTERING, etc.)
Some checks failed
Build and Push Docker Image / build (push) Failing after 8s
The jury selector card in the stats bar was still visible on round types
where juries don't apply. Now conditionally rendered based on hasJury,
with the grid adjusting from 4 to 3 columns accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 18:33:08 +01:00
Matt
09049d2911 Round management: tab cleanup, date pickers, advancement workflow
- Remove Document Windows tab (round dates + file requirements in
  Config are sufficient, separate SubmissionWindow was redundant)
- Restrict Jury and Awards tabs to round types that use them
  (EVALUATION, LIVE_FINAL, DELIBERATION only)
- Add Round Dates card in Config tab with DateTimePicker for
  start/end dates (supports past and future dates)
- Make Advance Projects button always visible when projects exist
  (dimmed with guidance when no projects are PASSED yet)
- Add Close & Advance combined quick action to streamline round
  progression workflow
- Add target round selector to Advance Projects dialog so admin
  can pick which round to advance projects into

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:43:23 +01:00
Matt
3fb0d128a1 Fix missing query invalidations across member management
Add utils.user.list.invalidate() after mutations that change user
status to ensure member lists refresh without manual page reload:
- Member detail page: after update and send invitation
- User mobile actions: after send invitation
- Add member dialog: after send invitation in jury group flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:16:23 +01:00
Matt
5965f7889d Platform-wide UX fixes: assignment dialog, invalidation, settings, dashboard
1. Assignment dialog overhaul: replace raw UUID inputs with searchable
   juror Combobox (shows name, email, capacity) and multi-select project
   checklist with bulk assignment support

2. Query invalidation sweep: fix missing invalidations in
   assignment-preview-sheet (roundAssignment.execute) and
   filtering-dashboard (filtering.finalizeResults) so data refreshes
   without page reload

3. Rename Submissions tab to Document Windows with descriptive
   header explaining upload window configuration

4. Connect 6 disconnected settings: storage_provider, local_storage_path,
   avatar_max_size_mb, allowed_image_types, whatsapp_enabled,
   whatsapp_provider - all now accessible in Settings UI

5. Admin dashboard redesign: branded Editorial Command Center with
   Dark Blue gradient header, colored border-l-4 stat cards, staggered
   animations, 2-column layout, action-required panel, activity timeline

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:05:25 +01:00
Matt
b2279067e2 Add LiteLLM proxy support for ChatGPT subscription AI access
- Add ai_provider setting: 'openai' (API key) or 'litellm' (ChatGPT subscription proxy)
- Auto-strip max_tokens/max_completion_tokens for chatgpt/ prefix models
  (ChatGPT subscription backend rejects token limit fields)
- LiteLLM mode: dummy API key when none configured, base URL required
- isOpenAIConfigured() checks base URL instead of API key for LiteLLM
- listAvailableModels() returns manualEntry flag for LiteLLM (no models.list)
- Settings UI: conditional fields, info banner, manual model input with
  chatgpt/ prefix examples when LiteLLM selected
- All 7 AI services work transparently via buildCompletionParams()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:48:34 +01:00
30 changed files with 2306 additions and 744 deletions

48
package-lock.json generated
View File

@@ -49,6 +49,7 @@
"cmdk": "^1.0.4",
"csv-parse": "^6.1.0",
"date-fns": "^4.1.0",
"franc": "^6.2.0",
"html2canvas": "^1.4.1",
"jspdf": "^4.1.0",
"jspdf-autotable": "^5.0.7",
@@ -6147,6 +6148,16 @@
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/collapse-white-space": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz",
"integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -7736,6 +7747,19 @@
}
}
},
"node_modules/franc": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/franc/-/franc-6.2.0.tgz",
"integrity": "sha512-rcAewP7PSHvjq7Kgd7dhj82zE071kX5B4W1M4ewYMf/P+i6YsDQmj62Xz3VQm9zyUzUXwhIde/wHLGCMrM+yGg==",
"license": "MIT",
"dependencies": {
"trigram-utils": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@@ -10441,6 +10465,16 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/n-gram": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/n-gram/-/n-gram-2.0.2.tgz",
"integrity": "sha512-S24aGsn+HLBxUGVAUFOwGpKs7LBcG4RudKU//eWzt/mQ97/NMKQxDWHyHx63UNWk/OOdihgmzoETn1tf5nQDzQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -13110,6 +13144,20 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/trigram-utils": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/trigram-utils/-/trigram-utils-2.0.1.tgz",
"integrity": "sha512-nfWIXHEaB+HdyslAfMxSqWKDdmqY9I32jS7GnqpdWQnLH89r6A5sdk3fDVYqGAZ0CrT8ovAFSAo6HRiWcWNIGQ==",
"license": "MIT",
"dependencies": {
"collapse-white-space": "^2.0.0",
"n-gram": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",

View File

@@ -62,6 +62,7 @@
"cmdk": "^1.0.4",
"csv-parse": "^6.1.0",
"date-fns": "^4.1.0",
"franc": "^6.2.0",
"html2canvas": "^1.4.1",
"jspdf": "^4.1.0",
"jspdf-autotable": "^5.0.7",

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "ProjectFile" ADD COLUMN "textPreview" TEXT;
ALTER TABLE "ProjectFile" ADD COLUMN "detectedLang" TEXT;
ALTER TABLE "ProjectFile" ADD COLUMN "langConfidence" DOUBLE PRECISION;
ALTER TABLE "ProjectFile" ADD COLUMN "analyzedAt" TIMESTAMP(3);

View File

@@ -689,6 +689,12 @@ model ProjectFile {
size Int // bytes
pageCount Int? // Number of pages (PDFs, presentations, etc.)
// Document analysis (optional, populated by document-analyzer service)
textPreview String? @db.Text // First ~2000 chars of extracted text
detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und')
langConfidence Float? // 0.01.0 confidence
analyzedAt DateTime? // When analysis last ran
// MinIO location
bucket String
objectKey String

File diff suppressed because it is too large Load Diff

View File

@@ -58,6 +58,7 @@ import {
export default function MemberDetailPage() {
const params = useParams()
const router = useRouter()
const utils = trpc.useUtils()
const userId = params.id as string
const { data: user, isLoading, error, refetch } = trpc.user.get.useQuery({ id: userId })
@@ -103,6 +104,8 @@ export default function MemberDetailPage() {
expertiseTags,
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
})
utils.user.get.invalidate({ id: userId })
utils.user.list.invalidate()
toast.success('Member updated successfully')
router.push('/admin/members')
} catch (error) {
@@ -115,6 +118,7 @@ export default function MemberDetailPage() {
await sendInvitation.mutateAsync({ userId })
toast.success('Invitation email sent successfully')
refetch()
utils.user.list.invalidate()
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
}

View File

@@ -49,7 +49,10 @@ import {
Heart,
Crown,
UserPlus,
Loader2,
ScanSearch,
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDate, formatDateOnly } from '@/lib/utils'
interface PageProps {
@@ -529,6 +532,8 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<AnimatedCard index={4}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-rose-500/10 p-1.5">
<FileText className="h-4 w-4 text-rose-500" />
@@ -538,6 +543,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<CardDescription>
Project documents and materials organized by competition round
</CardDescription>
</div>
<AnalyzeDocumentsButton projectId={projectId} onComplete={() => utils.file.listByProject.invalidate({ projectId })} />
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Requirements organized by round */}
@@ -664,6 +672,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
size: f.size,
bucket: f.bucket,
objectKey: f.objectKey,
pageCount: f.pageCount,
textPreview: f.textPreview,
detectedLang: f.detectedLang,
langConfidence: f.langConfidence,
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
}))}
/>
</div>
@@ -847,6 +860,36 @@ function ProjectDetailSkeleton() {
)
}
function AnalyzeDocumentsButton({ projectId, onComplete }: { projectId: string; onComplete: () => void }) {
const analyzeMutation = trpc.file.analyzeProjectFiles.useMutation({
onSuccess: (result) => {
toast.success(
`Analyzed ${result.analyzed} file${result.analyzed !== 1 ? 's' : ''}${result.failed > 0 ? ` (${result.failed} failed)` : ''}`
)
onComplete()
},
onError: (error) => {
toast.error(error.message || 'Analysis failed')
},
})
return (
<Button
variant="outline"
size="sm"
onClick={() => analyzeMutation.mutate({ projectId })}
disabled={analyzeMutation.isPending}
>
{analyzeMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ScanSearch className="mr-2 h-4 w-4" />
)}
{analyzeMutation.isPending ? 'Analyzing...' : 'Analyze Documents'}
</Button>
)
}
export default function ProjectDetailPage({ params }: PageProps) {
const { id } = use(params)

View File

@@ -78,16 +78,34 @@ import {
ArrowRight,
RotateCcw,
X,
Check,
ChevronsUpDown,
Search,
} from 'lucide-react'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager'
// SubmissionWindowManager removed — round dates + file requirements in Config are sufficient
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
import { AnimatedCard } from '@/components/shared/animated-container'
import { DateTimePicker } from '@/components/ui/datetime-picker'
import { AddMemberDialog } from '@/components/admin/jury/add-member-dialog'
import { motion } from 'motion/react'
@@ -160,6 +178,7 @@ export default function RoundDetailPage() {
const [createJuryOpen, setCreateJuryOpen] = useState(false)
const [newJuryName, setNewJuryName] = useState('')
const [addMemberOpen, setAddMemberOpen] = useState(false)
const [closeAndAdvance, setCloseAndAdvance] = useState(false)
const utils = trpc.useUtils()
@@ -226,8 +245,16 @@ export default function RoundDetailPage() {
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round closed')
if (closeAndAdvance) {
setCloseAndAdvance(false)
// Small delay to let cache invalidation complete before opening dialog
setTimeout(() => setAdvanceDialogOpen(true), 300)
}
},
onError: (err) => {
setCloseAndAdvance(false)
toast.error(err.message)
},
onError: (err) => toast.error(err.message),
})
const reopenMutation = trpc.roundEngine.reopen.useMutation({
@@ -299,7 +326,10 @@ export default function RoundDetailPage() {
onSuccess: (data) => {
utils.round.getById.invalidate({ id: roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
toast.success(`Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`)
const msg = data.autoPassedCount
? `Passed ${data.autoPassedCount} and advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`
: `Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`
toast.success(msg)
setAdvanceDialogOpen(false)
},
onError: (err) => toast.error(err.message),
@@ -349,6 +379,9 @@ export default function RoundDetailPage() {
const isFiltering = round?.roundType === 'FILTERING'
const isEvaluation = round?.roundType === 'EVALUATION'
const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
const hasAwards = hasJury
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
@@ -406,7 +439,7 @@ export default function RoundDetailPage() {
action: projectCount === 0 ? poolLink : undefined,
actionLabel: 'Assign Projects',
},
...((isEvaluation || isFiltering)
...(hasJury
? [{
label: 'Jury group set',
ready: !!juryGroup,
@@ -553,7 +586,7 @@ export default function RoundDetailPage() {
</motion.div>
{/* ===== STATS BAR — Accent-bordered cards ===== */}
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
<div className={cn("grid gap-3 grid-cols-2", hasJury ? "sm:grid-cols-4" : "sm:grid-cols-3")}>
{/* Projects */}
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-[#557f8c] hover:shadow-md transition-shadow">
@@ -576,7 +609,8 @@ export default function RoundDetailPage() {
</Card>
</AnimatedCard>
{/* Jury (with inline group selector) */}
{/* Jury (with inline group selector) — only for jury-relevant rounds */}
{hasJury && (
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-purple-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
@@ -623,9 +657,10 @@ export default function RoundDetailPage() {
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Window */}
<AnimatedCard index={2}>
<AnimatedCard index={hasJury ? 2 : 1}>
<Card className="border-l-4 border-l-emerald-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2.5">
@@ -658,7 +693,7 @@ export default function RoundDetailPage() {
</AnimatedCard>
{/* Advancement */}
<AnimatedCard index={3}>
<AnimatedCard index={hasJury ? 3 : 2}>
<Card className="border-l-4 border-l-amber-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2.5">
@@ -694,10 +729,9 @@ export default function RoundDetailPage() {
{ value: 'projects', label: 'Projects', icon: Layers },
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments', icon: ClipboardList }] : []),
{ value: 'jury', label: 'Jury', icon: Users },
...(hasJury ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
{ value: 'config', label: 'Config', icon: Settings },
{ value: 'windows', label: 'Submissions', icon: Clock },
{ value: 'awards', label: 'Awards', icon: Trophy },
...(hasAwards ? [{ value: 'awards', label: 'Awards', icon: Trophy }] : []),
].map((tab) => (
<TabsTrigger
key={tab.value}
@@ -936,25 +970,56 @@ export default function RoundDetailPage() {
</div>
</button>
{/* Advance projects (shown when PASSED > 0) */}
{passedCount > 0 && (
{/* Advance projects (always visible when projects exist) */}
{projectCount > 0 && (
<button
onClick={() => setAdvanceDialogOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-emerald-500 bg-emerald-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
onClick={() => (isSimpleAdvance || passedCount > 0)
? setAdvanceDialogOpen(true)
: toast.info('Mark projects as "Passed" first in the Projects tab')}
className={cn(
'flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left',
(isSimpleAdvance || passedCount > 0)
? 'border-l-4 border-l-emerald-500 bg-emerald-50/30'
: 'border-dashed opacity-60',
)}
>
<ArrowRight className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', (isSimpleAdvance || passedCount > 0) ? 'text-emerald-600' : 'text-muted-foreground')} />
<div>
<p className="text-sm font-medium">Advance Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
Move {passedCount} passed project(s) to the next round
{isSimpleAdvance
? `Advance all ${projectCount} project(s) to the next round`
: passedCount > 0
? `Move ${passedCount} passed project(s) to the next round`
: 'Mark projects as "Passed" first, then advance'}
</p>
</div>
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{passedCount}</Badge>
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{isSimpleAdvance ? projectCount : passedCount}</Badge>
</button>
)}
{/* Jury assignment for evaluation/filtering */}
{(isEvaluation || isFiltering) && !juryGroup && (
{/* Close & Advance (active rounds with passed projects) */}
{status === 'ROUND_ACTIVE' && passedCount > 0 && (
<button
onClick={() => {
setCloseAndAdvance(true)
closeMutation.mutate({ roundId })
}}
disabled={isTransitioning}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-purple-500 bg-purple-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<Square className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Close & Advance</p>
<p className="text-xs text-muted-foreground mt-0.5">
Close this round and advance {passedCount} passed project(s) to the next round
</p>
</div>
</button>
)}
{/* Jury assignment for rounds that use jury */}
{hasJury && !juryGroup && (
<button
onClick={() => {
const el = document.querySelector('[data-jury-select]')
@@ -1037,9 +1102,17 @@ export default function RoundDetailPage() {
open={advanceDialogOpen}
onOpenChange={setAdvanceDialogOpen}
roundId={roundId}
roundType={round?.roundType}
projectStates={projectStates}
config={config}
advanceMutation={advanceMutation}
competitionRounds={competition?.rounds?.map((r: any) => ({
id: r.id,
name: r.name,
sortOrder: r.sortOrder,
roundType: r.roundType,
}))}
currentSortOrder={round?.sortOrder}
/>
{/* AI Shortlist Confirmation Dialog */}
@@ -1171,6 +1244,7 @@ export default function RoundDetailPage() {
)}
{/* ═══════════ JURY TAB ═══════════ */}
{hasJury && (
<TabsContent value="jury" className="space-y-6">
{/* Jury Group Selector + Create */}
<Card>
@@ -1411,6 +1485,7 @@ export default function RoundDetailPage() {
/>
)}
</TabsContent>
)}
{/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */}
{isEvaluation && (
@@ -1476,7 +1551,7 @@ export default function RoundDetailPage() {
</div>
{/* Individual Assignments Table */}
<IndividualAssignmentsTable roundId={roundId} />
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
{/* Unassigned Queue */}
<RoundUnassignedQueue roundId={roundId} />
@@ -1495,6 +1570,38 @@ export default function RoundDetailPage() {
{/* ═══════════ CONFIG TAB ═══════════ */}
<TabsContent value="config" className="space-y-6">
{/* Round Dates */}
<Card>
<CardHeader className="border-b">
<CardTitle className="text-base">Round Dates</CardTitle>
<CardDescription>
When this round starts and ends. Defines the active period for document uploads and evaluations.
</CardDescription>
</CardHeader>
<CardContent className="pt-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Start Date</Label>
<DateTimePicker
value={round.windowOpenAt ? new Date(round.windowOpenAt) : null}
onChange={(date) => updateMutation.mutate({ id: roundId, windowOpenAt: date })}
placeholder="Select start date & time"
clearable
/>
</div>
<div className="space-y-2">
<Label>End Date</Label>
<DateTimePicker
value={round.windowCloseAt ? new Date(round.windowCloseAt) : null}
onChange={(date) => updateMutation.mutate({ id: roundId, windowCloseAt: date })}
placeholder="Select end date & time"
clearable
/>
</div>
</div>
</CardContent>
</Card>
{/* General Round Settings */}
<Card>
<CardHeader className="border-b">
@@ -1633,12 +1740,8 @@ export default function RoundDetailPage() {
</Card>
</TabsContent>
{/* ═══════════ SUBMISSION WINDOWS TAB ═══════════ */}
<TabsContent value="windows" className="space-y-4">
<SubmissionWindowManager competitionId={competitionId} roundId={roundId} />
</TabsContent>
{/* ═══════════ AWARDS TAB ═══════════ */}
{hasAwards && (
<TabsContent value="awards" className="space-y-4">
<Card>
<CardContent className="p-6">
@@ -1707,6 +1810,7 @@ export default function RoundDetailPage() {
</CardContent>
</Card>
</TabsContent>
)}
</Tabs>
</div>
)
@@ -1969,10 +2073,18 @@ function ExportEvaluationsDialog({
// ── Individual Assignments Table ─────────────────────────────────────────
function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
function IndividualAssignmentsTable({
roundId,
projectStates,
}: {
roundId: string
projectStates: any[] | undefined
}) {
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [newUserId, setNewUserId] = useState('')
const [newProjectId, setNewProjectId] = useState('')
const [selectedJurorId, setSelectedJurorId] = useState('')
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(new Set())
const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
const [projectSearch, setProjectSearch] = useState('')
const utils = trpc.useUtils()
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
@@ -1980,9 +2092,15 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
{ refetchInterval: 15_000 },
)
const { data: juryMembers } = trpc.user.getJuryMembers.useQuery(
{ roundId },
{ enabled: addDialogOpen },
)
const deleteMutation = trpc.assignment.delete.useMutation({
onSuccess: () => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
toast.success('Assignment removed')
},
onError: (err) => toast.error(err.message),
@@ -1991,14 +2109,102 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
const createMutation = trpc.assignment.create.useMutation({
onSuccess: () => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.user.getJuryMembers.invalidate({ roundId })
toast.success('Assignment created')
setAddDialogOpen(false)
setNewUserId('')
setNewProjectId('')
resetDialog()
},
onError: (err) => toast.error(err.message),
})
const bulkCreateMutation = trpc.assignment.bulkCreate.useMutation({
onSuccess: (result) => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.user.getJuryMembers.invalidate({ roundId })
toast.success(`${result.created} assignment(s) created`)
resetDialog()
},
onError: (err) => toast.error(err.message),
})
const resetDialog = useCallback(() => {
setAddDialogOpen(false)
setSelectedJurorId('')
setSelectedProjectIds(new Set())
setProjectSearch('')
}, [])
const selectedJuror = useMemo(
() => juryMembers?.find((j: any) => j.id === selectedJurorId),
[juryMembers, selectedJurorId],
)
// Filter projects by search term
const filteredProjects = useMemo(() => {
const items = projectStates ?? []
if (!projectSearch) return items
const q = projectSearch.toLowerCase()
return items.filter((ps: any) =>
ps.project?.title?.toLowerCase().includes(q) ||
ps.project?.teamName?.toLowerCase().includes(q) ||
ps.project?.competitionCategory?.toLowerCase().includes(q)
)
}, [projectStates, projectSearch])
// Existing assignments for the selected juror (to grey out already-assigned projects)
const jurorExistingProjectIds = useMemo(() => {
if (!selectedJurorId || !assignments) return new Set<string>()
return new Set(
assignments
.filter((a: any) => a.userId === selectedJurorId)
.map((a: any) => a.projectId)
)
}, [selectedJurorId, assignments])
const toggleProject = useCallback((projectId: string) => {
setSelectedProjectIds(prev => {
const next = new Set(prev)
if (next.has(projectId)) {
next.delete(projectId)
} else {
next.add(projectId)
}
return next
})
}, [])
const selectAllUnassigned = useCallback(() => {
const unassigned = filteredProjects
.filter((ps: any) => !jurorExistingProjectIds.has(ps.project?.id))
.map((ps: any) => ps.project?.id)
.filter(Boolean)
setSelectedProjectIds(new Set(unassigned))
}, [filteredProjects, jurorExistingProjectIds])
const handleCreate = useCallback(() => {
if (!selectedJurorId || selectedProjectIds.size === 0) return
const projectIds = Array.from(selectedProjectIds)
if (projectIds.length === 1) {
createMutation.mutate({
userId: selectedJurorId,
projectId: projectIds[0],
roundId,
})
} else {
bulkCreateMutation.mutate({
roundId,
assignments: projectIds.map(projectId => ({
userId: selectedJurorId,
projectId,
})),
})
}
}, [selectedJurorId, selectedProjectIds, roundId, createMutation, bulkCreateMutation])
const isMutating = createMutation.isPending || bulkCreateMutation.isPending
return (
<Card>
<CardHeader>
@@ -2071,44 +2277,220 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
</CardContent>
{/* Add Assignment Dialog */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent>
<Dialog open={addDialogOpen} onOpenChange={(open) => {
if (!open) resetDialog()
else setAddDialogOpen(true)
}}>
<DialogContent className="sm:max-w-[540px]">
<DialogHeader>
<DialogTitle>Add Assignment</DialogTitle>
<DialogDescription>
Manually assign a juror to evaluate a project
Select a juror and one or more projects to assign
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Juror Selector */}
<div className="space-y-2">
<Label className="text-sm">Juror User ID</Label>
<Input
placeholder="Enter jury member user ID..."
value={newUserId}
onChange={(e) => setNewUserId(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm">Project ID</Label>
<Input
placeholder="Enter project ID..."
value={newProjectId}
onChange={(e) => setNewProjectId(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddDialogOpen(false)}>Cancel</Button>
<Label className="text-sm font-medium">Juror</Label>
<Popover open={jurorPopoverOpen} onOpenChange={setJurorPopoverOpen}>
<PopoverTrigger asChild>
<Button
onClick={() => createMutation.mutate({
userId: newUserId,
projectId: newProjectId,
roundId,
})}
disabled={!newUserId || !newProjectId || createMutation.isPending}
variant="outline"
role="combobox"
aria-expanded={jurorPopoverOpen}
className="w-full justify-between font-normal"
>
{createMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Create Assignment
{selectedJuror
? (
<span className="flex items-center gap-2 truncate">
<span className="truncate">{selectedJuror.name || selectedJuror.email}</span>
<Badge variant="secondary" className="text-[10px] shrink-0">
{selectedJuror.currentAssignments}/{selectedJuror.maxAssignments ?? '\u221E'}
</Badge>
</span>
)
: <span className="text-muted-foreground">Select a jury member...</span>
}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[480px] p-0" align="start">
<Command>
<CommandInput placeholder="Search by name or email..." />
<CommandList>
<CommandEmpty>No jury members found.</CommandEmpty>
<CommandGroup>
{juryMembers?.map((juror: any) => {
const atCapacity = juror.maxAssignments !== null && juror.availableSlots === 0
return (
<CommandItem
key={juror.id}
value={`${juror.name ?? ''} ${juror.email}`}
disabled={atCapacity}
onSelect={() => {
setSelectedJurorId(juror.id === selectedJurorId ? '' : juror.id)
setSelectedProjectIds(new Set())
setJurorPopoverOpen(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedJurorId === juror.id ? 'opacity-100' : 'opacity-0',
)}
/>
<div className="flex flex-1 items-center justify-between min-w-0">
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{juror.name || 'Unnamed'}
</p>
<p className="text-xs text-muted-foreground truncate">
{juror.email}
</p>
</div>
<Badge
variant={atCapacity ? 'destructive' : 'secondary'}
className="text-[10px] ml-2 shrink-0"
>
{juror.currentAssignments}/{juror.maxAssignments ?? '\u221E'}
{atCapacity ? ' full' : ''}
</Badge>
</div>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* Project Multi-Select */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Projects
{selectedProjectIds.size > 0 && (
<span className="ml-1.5 text-muted-foreground font-normal">
({selectedProjectIds.size} selected)
</span>
)}
</Label>
{selectedJurorId && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={selectAllUnassigned}
>
Select all
</Button>
{selectedProjectIds.size > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setSelectedProjectIds(new Set())}
>
Clear
</Button>
)}
</div>
)}
</div>
{/* Search input */}
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filter projects..."
value={projectSearch}
onChange={(e) => setProjectSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
{/* Project checklist */}
<ScrollArea className="h-[240px] rounded-md border">
<div className="p-2 space-y-0.5">
{!selectedJurorId ? (
<p className="text-sm text-muted-foreground text-center py-8">
Select a juror first
</p>
) : filteredProjects.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No projects found
</p>
) : (
filteredProjects.map((ps: any) => {
const project = ps.project
if (!project) return null
const alreadyAssigned = jurorExistingProjectIds.has(project.id)
const isSelected = selectedProjectIds.has(project.id)
return (
<label
key={project.id}
className={cn(
'flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors',
alreadyAssigned
? 'opacity-50 cursor-not-allowed'
: isSelected
? 'bg-accent'
: 'hover:bg-muted/50',
)}
>
<Checkbox
checked={isSelected}
disabled={alreadyAssigned}
onCheckedChange={() => toggleProject(project.id)}
/>
<div className="flex flex-1 items-center justify-between min-w-0">
<span className="truncate">{project.title}</span>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
{project.competitionCategory && (
<Badge variant="outline" className="text-[10px]">
{project.competitionCategory === 'STARTUP'
? 'Startup'
: project.competitionCategory === 'BUSINESS_CONCEPT'
? 'Concept'
: project.competitionCategory}
</Badge>
)}
{alreadyAssigned && (
<Badge variant="secondary" className="text-[10px]">
Assigned
</Badge>
)}
</div>
</div>
</label>
)
})
)}
</div>
</ScrollArea>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={resetDialog}>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={!selectedJurorId || selectedProjectIds.size === 0 || isMutating}
>
{isMutating && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
{selectedProjectIds.size <= 1
? 'Create Assignment'
: `Create ${selectedProjectIds.size} Assignments`
}
</Button>
</DialogFooter>
</DialogContent>
@@ -2125,20 +2507,43 @@ function AdvanceProjectsDialog({
open,
onOpenChange,
roundId,
roundType,
projectStates,
config,
advanceMutation,
competitionRounds,
currentSortOrder,
}: {
open: boolean
onOpenChange: (open: boolean) => void
roundId: string
roundType?: string
projectStates: any[] | undefined
config: Record<string, unknown>
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[] }) => void; isPending: boolean }
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string; autoPassPending?: boolean }) => void; isPending: boolean }
competitionRounds?: Array<{ id: string; name: string; sortOrder: number; roundType: string }>
currentSortOrder?: number
}) {
// For non-jury rounds (INTAKE, SUBMISSION, MENTORING), offer a simpler "advance all" flow
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(roundType ?? '')
// Target round selector
const availableTargets = useMemo(() =>
(competitionRounds ?? [])
.filter((r) => r.sortOrder > (currentSortOrder ?? -1) && r.id !== roundId)
.sort((a, b) => a.sortOrder - b.sortOrder),
[competitionRounds, currentSortOrder, roundId])
const [targetRoundId, setTargetRoundId] = useState<string>('')
// Default to first available target when dialog opens
if (open && !targetRoundId && availableTargets.length > 0) {
setTargetRoundId(availableTargets[0].id)
}
const allProjects = projectStates ?? []
const pendingCount = allProjects.filter((ps: any) => ps.state === 'PENDING').length
const passedProjects = useMemo(() =>
(projectStates ?? []).filter((ps: any) => ps.state === 'PASSED'),
[projectStates])
allProjects.filter((ps: any) => ps.state === 'PASSED'),
[allProjects])
const startups = useMemo(() =>
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'STARTUP'),
@@ -2191,17 +2596,32 @@ function AdvanceProjectsDialog({
})
}
const handleAdvance = () => {
const handleAdvance = (autoPass?: boolean) => {
if (autoPass) {
// Auto-pass all pending then advance all
advanceMutation.mutate({
roundId,
autoPassPending: true,
...(targetRoundId ? { targetRoundId } : {}),
})
} else {
const ids = Array.from(selected)
if (ids.length === 0) return
advanceMutation.mutate({ roundId, projectIds: ids })
advanceMutation.mutate({
roundId,
projectIds: ids,
...(targetRoundId ? { targetRoundId } : {}),
})
}
onOpenChange(false)
setSelected(new Set())
setTargetRoundId('')
}
const handleClose = () => {
onOpenChange(false)
setSelected(new Set())
setTargetRoundId('')
}
const renderCategorySection = (
@@ -2257,32 +2677,89 @@ function AdvanceProjectsDialog({
)
}
const totalProjectCount = allProjects.length
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Advance Projects</DialogTitle>
<DialogDescription>
Select which passed projects to advance to the next round.
{selected.size} of {passedProjects.length} selected.
{isSimpleAdvance
? `Move all ${totalProjectCount} projects to the next round.`
: `Select which passed projects to advance. ${selected.size} of ${passedProjects.length} selected.`
}
</DialogDescription>
</DialogHeader>
{/* Target round selector */}
{availableTargets.length > 0 && (
<div className="space-y-2 pb-2 border-b">
<Label className="text-sm">Advance to</Label>
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select target round" />
</SelectTrigger>
<SelectContent>
{availableTargets.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name} ({r.roundType.replace('_', ' ').toLowerCase()})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{availableTargets.length === 0 && (
<div className="text-sm text-amber-600 bg-amber-50 rounded-md p-3">
No subsequent rounds found. Projects will advance to the next round by sort order.
</div>
)}
{isSimpleAdvance ? (
/* Simple mode for INTAKE/SUBMISSION/MENTORING — no per-project selection needed */
<div className="py-4 space-y-3">
<div className="rounded-lg border bg-muted/30 p-4 text-center space-y-1">
<p className="text-3xl font-bold">{totalProjectCount}</p>
<p className="text-sm text-muted-foreground">projects will be advanced</p>
</div>
{pendingCount > 0 && (
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2">
<p className="text-xs text-blue-700">
{pendingCount} pending project{pendingCount !== 1 ? 's' : ''} will be automatically marked as passed and advanced.
{passedProjects.length > 0 && ` ${passedProjects.length} already passed.`}
</p>
</div>
)}
</div>
) : (
/* Detailed mode for jury/evaluation rounds — per-project selection */
<div className="flex-1 overflow-y-auto space-y-4 py-2">
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')}
{other.length > 0 && renderCategorySection('Other / Uncategorized', other, 0, 'bg-gray-100 text-gray-700')}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={handleClose}>Cancel</Button>
{isSimpleAdvance ? (
<Button
onClick={handleAdvance}
onClick={() => handleAdvance(true)}
disabled={totalProjectCount === 0 || advanceMutation.isPending}
>
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Advance All {totalProjectCount} Project{totalProjectCount !== 1 ? 's' : ''}
</Button>
) : (
<Button
onClick={() => handleAdvance()}
disabled={selected.size === 0 || advanceMutation.isPending}
>
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -9,7 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { SettingsContent } from '@/components/settings/settings-content'
// Categories that only super admins can access
const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY'])
const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY', 'WHATSAPP'])
async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) {
const settings = await prisma.systemSettings.findMany({

View File

@@ -45,6 +45,8 @@ export function AssignmentPreviewSheet({
toast.success(`Created ${result.created} assignments`)
utils.roundAssignment.coverageReport.invalidate({ roundId })
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
onOpenChange(false)
},
onError: (err) => {

View File

@@ -88,6 +88,7 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
const { mutate: sendInvitation } = trpc.user.sendInvitation.useMutation({
onSuccess: (result) => {
toast.success(`Invitation sent to ${result.email}`)
utils.user.list.invalidate()
},
onError: (err) => {
// Don't block — user was created and added, just invitation failed

View File

@@ -198,6 +198,8 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
onSuccess: (data) => {
utils.filtering.getResults.invalidate()
utils.filtering.getResultStats.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.project.list.invalidate()
toast.success(
`Finalized: ${data.passed} passed, ${data.filteredOut} filtered out` +
(data.advancedToStageName ? `. Next round: ${data.advancedToStageName}` : '')
@@ -1597,7 +1599,7 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
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.
The AI has access to: category, country, region, founded year, ocean issue, tags, description, file details (type, page count, size, detected language), and team size.
</p>
</div>

View File

@@ -250,6 +250,7 @@ export function UserMobileActions({
try {
await sendInvitation.mutateAsync({ userId })
toast.success(`Invitation sent to ${userEmail}`)
utils.user.list.invalidate()
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
} finally {

View File

@@ -4,7 +4,7 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { Cog, Loader2, Zap, AlertCircle, RefreshCw, SlidersHorizontal } from 'lucide-react'
import { Cog, Loader2, Zap, AlertCircle, RefreshCw, SlidersHorizontal, Info } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -67,7 +67,10 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
},
})
// Fetch available models from OpenAI API
const watchProvider = form.watch('ai_provider')
const isLiteLLM = watchProvider === 'litellm'
// Fetch available models from OpenAI API (skip for LiteLLM — no models.list support)
const {
data: modelsData,
isLoading: modelsLoading,
@@ -76,6 +79,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
} = trpc.settings.listAIModels.useQuery(undefined, {
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
retry: false,
enabled: !isLiteLLM,
})
const updateSettings = trpc.settings.updateMultiple.useMutation({
@@ -182,32 +186,50 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="openai">OpenAI (API Key)</SelectItem>
<SelectItem value="litellm">LiteLLM Proxy (ChatGPT Subscription)</SelectItem>
</SelectContent>
</Select>
<FormDescription>
AI provider for smart assignment suggestions
{field.value === 'litellm'
? 'Route AI calls through a LiteLLM proxy connected to your ChatGPT Plus/Pro subscription'
: 'Direct OpenAI API access using your API key'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{isLiteLLM && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong>LiteLLM Proxy Mode</strong> AI calls will be routed through your LiteLLM proxy
using your ChatGPT subscription. Token limits are automatically stripped (not supported by ChatGPT backend).
Make sure your LiteLLM proxy is running and accessible.
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="openai_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormLabel>{isLiteLLM ? 'API Key (Optional)' : 'API Key'}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={settings.openai_api_key ? '••••••••' : 'Enter API key'}
placeholder={isLiteLLM
? 'Optional — leave blank for default'
: (settings.openai_api_key ? '••••••••' : 'Enter API key')}
{...field}
/>
</FormControl>
<FormDescription>
Your OpenAI API key. Leave blank to keep the existing key.
{isLiteLLM
? 'LiteLLM proxy usually does not require an API key. Leave blank to use default.'
: 'Your OpenAI API key. Leave blank to keep the existing key.'}
</FormDescription>
<FormMessage />
</FormItem>
@@ -219,16 +241,26 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
name="openai_base_url"
render={({ field }) => (
<FormItem>
<FormLabel>API Base URL (Optional)</FormLabel>
<FormLabel>{isLiteLLM ? 'LiteLLM Proxy URL' : 'API Base URL (Optional)'}</FormLabel>
<FormControl>
<Input
placeholder="https://api.openai.com/v1"
placeholder={isLiteLLM ? 'http://localhost:4000' : 'https://api.openai.com/v1'}
{...field}
/>
</FormControl>
<FormDescription>
{isLiteLLM ? (
<>
URL of your LiteLLM proxy. Typically{' '}
<code className="text-xs bg-muted px-1 rounded">http://localhost:4000</code>{' '}
or your server address.
</>
) : (
<>
Custom base URL for OpenAI-compatible providers. Leave blank for OpenAI.
Use <code className="text-xs bg-muted px-1 rounded">https://openrouter.ai/api/v1</code> for OpenRouter (access Claude, Gemini, Llama, etc.)
Use <code className="text-xs bg-muted px-1 rounded">https://openrouter.ai/api/v1</code> for OpenRouter.
</>
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -242,7 +274,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Model</FormLabel>
{modelsData?.success && (
{!isLiteLLM && modelsData?.success && !modelsData?.manualEntry && (
<Button
type="button"
variant="ghost"
@@ -256,7 +288,13 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
)}
</div>
{modelsLoading ? (
{isLiteLLM || modelsData?.manualEntry ? (
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
placeholder="chatgpt/gpt-5.2"
/>
) : modelsLoading ? (
<Skeleton className="h-10 w-full" />
) : modelsError || !modelsData?.success ? (
<div className="space-y-2">
@@ -303,7 +341,15 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
</Select>
)}
<FormDescription>
{form.watch('ai_model')?.startsWith('o') ? (
{isLiteLLM ? (
<>
Enter the model ID with the{' '}
<code className="text-xs bg-muted px-1 rounded">chatgpt/</code> prefix.
Examples:{' '}
<code className="text-xs bg-muted px-1 rounded">chatgpt/gpt-5.2</code>,{' '}
<code className="text-xs bg-muted px-1 rounded">chatgpt/gpt-5.2-codex</code>
</>
) : form.watch('ai_model')?.startsWith('o') ? (
<span className="flex items-center gap-1 text-purple-600">
<SlidersHorizontal className="h-3 w-3" />
Reasoning model - optimized for complex analysis tasks

View File

@@ -25,6 +25,7 @@ import {
ShieldAlert,
Globe,
Webhook,
MessageCircle,
} from 'lucide-react'
import Link from 'next/link'
import { AnimatedCard } from '@/components/shared/animated-container'
@@ -103,8 +104,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
])
const storageSettings = getSettingsByKeys([
'storage_provider',
'local_storage_path',
'max_file_size_mb',
'avatar_max_size_mb',
'allowed_file_types',
'allowed_image_types',
])
const securitySettings = getSettingsByKeys([
@@ -147,6 +152,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
'anomaly_off_hours_end',
])
const whatsappSettings = getSettingsByKeys([
'whatsapp_enabled',
'whatsapp_provider',
])
const localizationSettings = getSettingsByKeys([
'localization_enabled_locales',
'localization_default_locale',
@@ -183,6 +193,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<Newspaper className="h-4 w-4" />
Digest
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="whatsapp" className="gap-2 shrink-0">
<MessageCircle className="h-4 w-4" />
WhatsApp
</TabsTrigger>
)}
{isSuperAdmin && (
<TabsTrigger value="security" className="gap-2 shrink-0">
<Shield className="h-4 w-4" />
@@ -259,6 +275,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<Newspaper className="h-4 w-4" />
Digest
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="whatsapp" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<MessageCircle className="h-4 w-4" />
WhatsApp
</TabsTrigger>
)}
</TabsList>
</div>
<div>
@@ -502,6 +524,24 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</Card>
</AnimatedCard>
</TabsContent>
{isSuperAdmin && (
<TabsContent value="whatsapp" className="space-y-6">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle>WhatsApp Notifications</CardTitle>
<CardDescription>
Configure WhatsApp messaging for notifications
</CardDescription>
</CardHeader>
<CardContent>
<WhatsAppSettingsSection settings={whatsappSettings} />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
)}
</div>{/* end content area */}
</div>{/* end lg:flex */}
</Tabs>
@@ -794,6 +834,29 @@ function AuditSettingsSection({ settings }: { settings: Record<string, string> }
)
}
function WhatsAppSettingsSection({ settings }: { settings: Record<string, string> }) {
return (
<div className="space-y-4">
<SettingToggle
label="Enable WhatsApp Notifications"
description="Send notifications via WhatsApp in addition to email"
settingKey="whatsapp_enabled"
value={settings.whatsapp_enabled || 'false'}
/>
<SettingSelect
label="WhatsApp Provider"
description="Select the API provider for sending WhatsApp messages"
settingKey="whatsapp_provider"
value={settings.whatsapp_provider || 'META'}
options={[
{ value: 'META', label: 'Meta (WhatsApp Business API)' },
{ value: 'TWILIO', label: 'Twilio' },
]}
/>
</div>
)
}
function LocalizationSettingsSection({ settings }: { settings: Record<string, string> }) {
const mutation = useSettingsMutation()
const enabledLocales = (settings.localization_enabled_locales || 'en').split(',')

View File

@@ -22,6 +22,14 @@ import {
} from '@/components/ui/form'
// Note: Storage provider cache is cleared server-side when settings are updated
const COMMON_IMAGE_TYPES = [
{ value: 'image/png', label: 'PNG (.png)' },
{ value: 'image/jpeg', label: 'JPEG (.jpg, .jpeg)' },
{ value: 'image/webp', label: 'WebP (.webp)' },
{ value: 'image/gif', label: 'GIF (.gif)' },
{ value: 'image/svg+xml', label: 'SVG (.svg)' },
]
const COMMON_FILE_TYPES = [
{ value: 'application/pdf', label: 'PDF Documents (.pdf)' },
{ value: 'video/mp4', label: 'MP4 Video (.mp4)' },
@@ -41,6 +49,7 @@ const formSchema = z.object({
max_file_size_mb: z.string().regex(/^\d+$/, 'Must be a number'),
avatar_max_size_mb: z.string().regex(/^\d+$/, 'Must be a number'),
allowed_file_types: z.array(z.string()).min(1, 'Select at least one file type'),
allowed_image_types: z.array(z.string()).min(1, 'Select at least one image type'),
})
type FormValues = z.infer<typeof formSchema>
@@ -52,6 +61,7 @@ interface StorageSettingsFormProps {
max_file_size_mb?: string
avatar_max_size_mb?: string
allowed_file_types?: string
allowed_image_types?: string
}
}
@@ -68,6 +78,16 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
allowedTypes = ['application/pdf', 'video/mp4', 'video/quicktime', 'image/png', 'image/jpeg']
}
// Parse allowed image types from JSON string
let allowedImageTypes: string[] = []
try {
allowedImageTypes = settings.allowed_image_types
? JSON.parse(settings.allowed_image_types)
: ['image/png', 'image/jpeg', 'image/webp']
} catch {
allowedImageTypes = ['image/png', 'image/jpeg', 'image/webp']
}
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
@@ -76,6 +96,7 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
max_file_size_mb: settings.max_file_size_mb || '500',
avatar_max_size_mb: settings.avatar_max_size_mb || '5',
allowed_file_types: allowedTypes,
allowed_image_types: allowedImageTypes,
},
})
@@ -99,6 +120,7 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
{ key: 'max_file_size_mb', value: data.max_file_size_mb },
{ key: 'avatar_max_size_mb', value: data.avatar_max_size_mb },
{ key: 'allowed_file_types', value: JSON.stringify(data.allowed_file_types) },
{ key: 'allowed_image_types', value: JSON.stringify(data.allowed_image_types) },
],
})
}
@@ -255,6 +277,57 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
)}
/>
<FormField
control={form.control}
name="allowed_image_types"
render={() => (
<FormItem>
<div className="mb-4">
<FormLabel>Allowed Image Types (Avatars/Logos)</FormLabel>
<FormDescription>
Select which image formats can be used for profile pictures and project logos
</FormDescription>
</div>
<div className="grid gap-3 md:grid-cols-2">
{COMMON_IMAGE_TYPES.map((type) => (
<FormField
key={type.value}
control={form.control}
name="allowed_image_types"
render={({ field }) => {
return (
<FormItem
key={type.value}
className="flex items-start space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={field.value?.includes(type.value)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, type.value])
: field.onChange(
field.value?.filter(
(value) => value !== type.value
)
)
}}
/>
</FormControl>
<FormLabel className="cursor-pointer text-sm font-normal">
{type.label}
</FormLabel>
</FormItem>
)
}}
/>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
{storageProvider === 's3' && (
<div className="rounded-lg border border-muted bg-muted/50 p-4">
<p className="text-sm text-muted-foreground">

View File

@@ -65,6 +65,12 @@ interface ProjectFile {
isLate?: boolean
requirementId?: string | null
requirement?: FileRequirementInfo | null
// Document analysis fields
pageCount?: number | null
textPreview?: string | null
detectedLang?: string | null
langConfidence?: number | null
analyzedAt?: Date | string | null
}
interface RoundGroup {
@@ -270,6 +276,25 @@ function FileItem({ file }: { file: ProjectFile }) {
</Badge>
)}
<span>{formatFileSize(file.size)}</span>
{file.pageCount != null && (
<Badge variant="outline" className="text-xs gap-1">
<FileText className="h-3 w-3" />
{file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'}
</Badge>
)}
{file.detectedLang && file.detectedLang !== 'und' && (
<Badge
variant="outline"
className={cn('text-xs font-mono uppercase', {
'border-green-300 text-green-700 bg-green-50': file.langConfidence != null && file.langConfidence >= 0.8,
'border-amber-300 text-amber-700 bg-amber-50': file.langConfidence != null && file.langConfidence >= 0.4 && file.langConfidence < 0.8,
'border-red-300 text-red-700 bg-red-50': file.langConfidence != null && file.langConfidence < 0.4,
})}
title={`Language: ${file.detectedLang} (${Math.round((file.langConfidence ?? 0) * 100)}% confidence)`}
>
{file.detectedLang.toUpperCase()}
</Badge>
)}
</div>
</div>

View File

@@ -8,6 +8,33 @@ const globalForOpenAI = globalThis as unknown as {
openaiInitialized: boolean
}
// ─── Provider Detection ─────────────────────────────────────────────────────
/**
* Get the configured AI provider from SystemSettings.
* Returns 'openai' (default) or 'litellm' (ChatGPT subscription proxy).
*/
export async function getConfiguredProvider(): Promise<'openai' | 'litellm'> {
try {
const setting = await prisma.systemSettings.findUnique({
where: { key: 'ai_provider' },
})
const value = setting?.value || 'openai'
return value === 'litellm' ? 'litellm' : 'openai'
} catch {
return 'openai'
}
}
/**
* Check if a model ID indicates LiteLLM ChatGPT subscription routing.
* Models like 'chatgpt/gpt-5.2' use the chatgpt/ prefix.
* Used by buildCompletionParams (sync) to strip unsupported token limit fields.
*/
export function isLiteLLMChatGPTModel(model: string): boolean {
return model.toLowerCase().startsWith('chatgpt/')
}
// ─── Model Type Detection ────────────────────────────────────────────────────
/**
@@ -168,6 +195,12 @@ export function buildCompletionParams(
params.response_format = { type: 'json_object' }
}
// LiteLLM ChatGPT subscription models reject token limit fields
if (isLiteLLMChatGPTModel(model)) {
delete params.max_tokens
delete params.max_completion_tokens
}
return params
}
@@ -209,8 +242,12 @@ async function getBaseURL(): Promise<string | undefined> {
*/
async function createOpenAIClient(): Promise<OpenAI | null> {
const apiKey = await getOpenAIApiKey()
const provider = await getConfiguredProvider()
if (!apiKey) {
// LiteLLM proxy may not require a real API key
const effectiveApiKey = apiKey || (provider === 'litellm' ? 'sk-litellm' : null)
if (!effectiveApiKey) {
console.warn('OpenAI API key not configured')
return null
}
@@ -218,11 +255,11 @@ async function createOpenAIClient(): Promise<OpenAI | null> {
const baseURL = await getBaseURL()
if (baseURL) {
console.log(`[OpenAI] Using custom base URL: ${baseURL}`)
console.log(`[OpenAI] Using custom base URL: ${baseURL} (provider: ${provider})`)
}
return new OpenAI({
apiKey,
apiKey: effectiveApiKey,
...(baseURL ? { baseURL } : {}),
})
}
@@ -259,6 +296,12 @@ export function resetOpenAIClient(): void {
* Check if OpenAI is configured and available
*/
export async function isOpenAIConfigured(): Promise<boolean> {
const provider = await getConfiguredProvider()
if (provider === 'litellm') {
// LiteLLM just needs a base URL configured
const baseURL = await getBaseURL()
return !!baseURL
}
const apiKey = await getOpenAIApiKey()
return !!apiKey
}
@@ -270,8 +313,20 @@ export async function listAvailableModels(): Promise<{
success: boolean
models?: string[]
error?: string
manualEntry?: boolean
}> {
try {
const provider = await getConfiguredProvider()
// LiteLLM proxy for ChatGPT subscription doesn't support models.list()
if (provider === 'litellm') {
return {
success: true,
models: [],
manualEntry: true,
}
}
const client = await getOpenAI()
if (!client) {

View File

@@ -6,6 +6,7 @@ import { getPresignedUrl, generateObjectKey } from '@/lib/minio'
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification'
import { checkRequirementsAndTransition } from '../services/round-engine'
// Bucket for applicant submissions
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
@@ -410,6 +411,24 @@ export const applicantRouter = router({
},
})
// Auto-transition: if uploading against a round requirement, check completion
if (roundId && requirementId) {
await checkRequirementsAndTransition(
projectId,
roundId,
ctx.user.id,
ctx.prisma,
)
}
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
import('../services/document-analyzer').then(({ analyzeFileDelayed, isAutoAnalysisEnabled }) =>
isAutoAnalysisEnabled().then((enabled) => {
if (enabled) analyzeFileDelayed(file.id).catch((err) =>
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
})
).catch(() => {})
return file
}),

View File

@@ -74,10 +74,22 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
description: true,
tags: true,
teamName: true,
projectTags: {
select: { tag: { select: { name: true } }, confidence: true },
},
_count: { select: { assignments: { where: { roundId } } } },
},
})
// Enrich projects with tag confidence data for AI matching
const projectsWithConfidence = projects.map((p) => ({
...p,
tagConfidences: p.projectTags.map((pt) => ({
name: pt.tag.name,
confidence: pt.confidence,
})),
}))
const existingAssignments = await prisma.assignment.findMany({
where: { roundId },
select: { userId: true, projectId: true },
@@ -124,7 +136,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
const result = await generateAIAssignments(
jurors,
projects,
projectsWithConfidence,
constraints,
userId,
roundId,

View File

@@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getPresignedUrl, generateObjectKey, deleteObject, BUCKET_NAME } from '@/lib/minio'
import { logAudit } from '../utils/audit'
import { checkRequirementsAndTransition } from '../services/round-engine'
export const fileRouter = router({
/**
@@ -205,6 +206,14 @@ export const fileRouter = router({
userAgent: ctx.userAgent,
})
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
import('../services/document-analyzer').then(({ analyzeFileDelayed, isAutoAnalysisEnabled }) =>
isAutoAnalysisEnabled().then((enabled) => {
if (enabled) analyzeFileDelayed(file.id).catch((err) =>
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
})
).catch(() => {})
return {
uploadUrl,
file,
@@ -1200,6 +1209,14 @@ export const fileRouter = router({
userAgent: ctx.userAgent,
})
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
import('../services/document-analyzer').then(({ analyzeFileDelayed, isAutoAnalysisEnabled }) =>
isAutoAnalysisEnabled().then((enabled) => {
if (enabled) analyzeFileDelayed(file.id).catch((err) =>
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
})
).catch(() => {})
return { uploadUrl, file }
}),
@@ -1501,6 +1518,22 @@ export const fileRouter = router({
userAgent: ctx.userAgent,
})
// Auto-transition: check if all required documents are now uploaded
await checkRequirementsAndTransition(
input.projectId,
input.roundId,
ctx.user.id,
ctx.prisma,
)
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
import('../services/document-analyzer').then(({ analyzeFileDelayed, isAutoAnalysisEnabled }) =>
isAutoAnalysisEnabled().then((enabled) => {
if (enabled) analyzeFileDelayed(file.id).catch((err) =>
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
})
).catch(() => {})
return { uploadUrl, file }
}),
@@ -1536,4 +1569,25 @@ export const fileRouter = router({
)
return results
}),
/**
* Analyze all files for a specific project (page count, language, text preview).
* Retroactive: re-analyzes even previously analyzed files.
*/
analyzeProjectFiles: adminProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ input }) => {
const { analyzeProjectFiles } = await import('../services/document-analyzer')
return analyzeProjectFiles(input.projectId)
}),
/**
* Batch analyze all unanalyzed files across the platform.
* For retroactive analysis of files uploaded before this feature.
*/
analyzeAllFiles: adminProcedure
.mutation(async () => {
const { analyzeAllUnanalyzed } = await import('../services/document-analyzer')
return analyzeAllUnanalyzed()
}),
})

View File

@@ -69,6 +69,8 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
mimeType: true,
size: true,
pageCount: true,
detectedLang: true,
langConfidence: true,
objectKey: true,
roundId: true,
createdAt: true,

View File

@@ -243,10 +243,11 @@ export const roundRouter = router({
roundId: z.string(),
targetRoundId: z.string().optional(),
projectIds: z.array(z.string()).optional(),
autoPassPending: z.boolean().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, targetRoundId, projectIds } = input
const { roundId, targetRoundId, projectIds, autoPassPending } = input
// Get current round with competition context
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
@@ -280,6 +281,16 @@ export const roundRouter = router({
targetRound = nextRound
}
// Auto-pass all PENDING projects first (for intake/bulk workflows)
let autoPassedCount = 0
if (autoPassPending) {
const result = await ctx.prisma.projectRoundState.updateMany({
where: { roundId, state: 'PENDING' },
data: { state: 'PASSED' },
})
autoPassedCount = result.count
}
// Determine which projects to advance
let idsToAdvance: string[]
if (projectIds && projectIds.length > 0) {
@@ -346,6 +357,7 @@ export const roundRouter = router({
toRound: targetRound.name,
targetRoundId: targetRound.id,
projectCount: idsToAdvance.length,
autoPassedCount,
projectIds: idsToAdvance,
},
ipAddress: ctx.ip,
@@ -354,6 +366,7 @@ export const roundRouter = router({
return {
advancedCount: idsToAdvance.length,
autoPassedCount,
targetRoundId: targetRound.id,
targetRoundName: targetRound.name,
}

View File

@@ -263,4 +263,41 @@ export const roundEngineRouter = router({
return { success: true, removedCount: deleted.count }
}),
/**
* Retroactive document check: auto-PASS any PENDING/IN_PROGRESS projects
* that already have all required documents uploaded for this round.
* Useful for rounds activated before the auto-transition feature was deployed.
*/
checkDocumentCompletion: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const { batchCheckRequirementsAndTransition } = await import('../services/round-engine')
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: {
roundId: input.roundId,
state: { in: ['PENDING', 'IN_PROGRESS'] },
},
select: { projectId: true },
})
if (projectStates.length === 0) {
return { transitionedCount: 0, checkedCount: 0, projectIds: [] }
}
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
const result = await batchCheckRequirementsAndTransition(
input.roundId,
projectIds,
ctx.user.id,
ctx.prisma,
)
return {
transitionedCount: result.transitionedCount,
checkedCount: projectIds.length,
projectIds: result.projectIds,
}
}),
})

View File

@@ -201,8 +201,8 @@ export const settingsRouter = router({
clearStorageProviderCache()
}
// Reset OpenAI client if API key or base URL changed
if (input.settings.some((s) => s.key === 'openai_api_key' || s.key === 'openai_base_url' || s.key === 'ai_model')) {
// Reset OpenAI client if API key, base URL, model, or provider changed
if (input.settings.some((s) => s.key === 'openai_api_key' || s.key === 'openai_base_url' || s.key === 'ai_model' || s.key === 'ai_provider')) {
const { resetOpenAIClient } = await import('@/lib/openai')
resetOpenAIClient()
}
@@ -247,6 +247,15 @@ export const settingsRouter = router({
listAIModels: superAdminProcedure.query(async () => {
const result = await listAvailableModels()
// LiteLLM mode: manual model entry, no listing available
if (result.manualEntry) {
return {
success: true,
models: [],
manualEntry: true,
}
}
if (!result.success || !result.models) {
return {
success: false,

View File

@@ -38,7 +38,7 @@ const ASSIGNMENT_SYSTEM_PROMPT = `You are an expert jury assignment optimizer fo
Match jurors to projects based on expertise alignment, workload balance, and coverage requirements.
## Matching Criteria (Weighted)
- Expertise Match (50%): How well juror tags/expertise align with project topics
- Expertise Match (50%): How well juror tags/expertise align with project topics. Project tags include a confidence score (0-1) — weight higher-confidence tags more heavily as they are more reliably assigned. A tag with confidence 0.9 is a strong signal; one with 0.5 is uncertain.
- Workload Balance (30%): Distribute assignments evenly; prefer jurors below capacity
- Minimum Target (20%): Prioritize jurors who haven't reached their minimum assignment count
@@ -99,6 +99,7 @@ interface ProjectForAssignment {
title: string
description?: string | null
tags: string[]
tagConfidences?: Array<{ name: string; confidence: number }>
teamName?: string | null
_count?: {
assignments: number
@@ -539,7 +540,7 @@ export function generateFallbackAssignments(
return {
juror,
score: calculateExpertiseScore(juror.expertiseTags, project.tags),
score: calculateExpertiseScore(juror.expertiseTags, project.tags, project.tagConfidences),
loadScore: calculateLoadScore(currentLoad, maxLoad),
underMinBonus: calculateUnderMinBonus(currentLoad, minTarget),
}
@@ -586,24 +587,44 @@ export function generateFallbackAssignments(
/**
* Calculate expertise match score based on tag overlap
* When tagConfidences are available, weights matches by confidence
*/
function calculateExpertiseScore(
jurorTags: string[],
projectTags: string[]
projectTags: string[],
tagConfidences?: Array<{ name: string; confidence: number }>
): number {
if (jurorTags.length === 0 || projectTags.length === 0) {
return 0.5 // Neutral score if no tags
}
const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase()))
// If we have confidence data, use weighted scoring
if (tagConfidences && tagConfidences.length > 0) {
let weightedMatches = 0
let totalWeight = 0
for (const tc of tagConfidences) {
totalWeight += tc.confidence
if (jurorTagsLower.has(tc.name.toLowerCase())) {
weightedMatches += tc.confidence
}
}
if (totalWeight === 0) return 0.5
const weightedRatio = weightedMatches / totalWeight
const hasExpertise = weightedMatches > 0 ? 0.2 : 0
return Math.min(1, weightedRatio * 0.8 + hasExpertise)
}
// Fallback: unweighted matching using flat tags
const matchingTags = projectTags.filter((t) =>
jurorTagsLower.has(t.toLowerCase())
)
// Score based on percentage of project tags matched
const matchRatio = matchingTags.length / projectTags.length
// Boost for having expertise, even if not all match
const hasExpertise = matchingTags.length > 0 ? 0.2 : 0
return Math.min(1, matchRatio * 0.8 + hasExpertise)

View File

@@ -179,10 +179,11 @@ Return a JSON object with this exact structure:
- 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), size_kb, round_name (which round the file was submitted for), and is_current_round flag
- files[]: per-file details with file_type, page_count (if known), size_kb, detected_lang (ISO 639-3 language code like 'eng', 'fra'), lang_confidence (0-1), round_name (which round the file was submitted for), and is_current_round flag
- description: project summary text
- tags: topic tags
- If document content is provided (text_content field in files), use it for deeper analysis. Pay SPECIAL ATTENTION to files from the current round (is_current_round=true) as they are the most recent and relevant submissions.
- If detected_lang is provided, use it to evaluate language requirements (e.g. 'eng' = English, 'fra' = French). lang_confidence indicates detection reliability.
## Guidelines
- Evaluate ONLY against the provided criteria, not your own standards

View File

@@ -52,7 +52,7 @@ export interface AnonymizedProject {
anonymousId: string
title: string
description: string | null
tags: string[]
tags: Array<{ name: string; confidence: number }>
teamName: string | null
}
@@ -83,6 +83,8 @@ 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
detected_lang?: string | null // ISO 639-3 language code (e.g. 'eng', 'fra')
lang_confidence?: number | null // 0.01.0 confidence score
round_name?: string | null // Which round the file was submitted for
is_current_round?: boolean // Whether this file belongs to the current filtering/evaluation round
text_content?: string // Extracted text content (when aiParseFiles is enabled)
@@ -209,6 +211,7 @@ interface ProjectInput {
title: string
description?: string | null
tags: string[]
tagConfidences?: Array<{ name: string; confidence: number }>
teamName?: string | null
}
@@ -253,7 +256,9 @@ export function anonymizeForAI(
description: project.description
? truncateAndSanitize(project.description, DESCRIPTION_LIMITS.ASSIGNMENT)
: null,
tags: project.tags,
tags: project.tagConfidences && project.tagConfidences.length > 0
? project.tagConfidences
: project.tags.map((t) => ({ name: t, confidence: 1.0 })),
teamName: project.teamName ? `Team ${index + 1}` : null,
}
}
@@ -306,6 +311,8 @@ export function anonymizeProjectForAI(
file_type: f.fileType ?? 'OTHER',
page_count: f.pageCount ?? null,
size_kb: Math.round((f.size ?? 0) / 1024),
...(f.detectedLang ? { detected_lang: f.detectedLang } : {}),
...(f.langConfidence != null ? { lang_confidence: f.langConfidence } : {}),
...(f.roundName ? { round_name: f.roundName } : {}),
...(f.isCurrentRound !== undefined ? { is_current_round: f.isCurrentRound } : {}),
...(f.textContent ? { text_content: f.textContent } : {}),
@@ -524,7 +531,7 @@ export function validateAnonymization(data: AnonymizationResult): boolean {
if (!checkText(project.title)) return false
if (!checkText(project.description)) return false
for (const tag of project.tags) {
if (!checkText(tag)) return false
if (!checkText(typeof tag === 'string' ? tag : tag.name)) return false
}
}

View File

@@ -0,0 +1,367 @@
/**
* Document Analyzer Service
*
* Extracts metadata from uploaded files:
* - Page count (PDFs)
* - Text preview (first ~2000 chars)
* - Language detection via franc
*
* Runs optionally on upload (controlled by SystemSettings) and
* retroactively via admin endpoint.
*/
import { getStorageProvider } from '@/lib/storage'
import { isParseableMimeType } from './file-content-extractor'
import { prisma } from '@/lib/prisma'
const TEXT_PREVIEW_LIMIT = 2000
const BATCH_SIZE = 10
// ─── Types ──────────────────────────────────────────────────────────────────
export type AnalysisResult = {
fileId: string
pageCount: number | null
textPreview: string | null
detectedLang: string | null
langConfidence: number | null
error?: string
}
// ─── Language Detection ──────────────────────────────────────────────────────
/**
* Detect language using franc. Returns ISO 639-3 code and confidence.
* franc returns a distance-based score where lower = better match.
* We convert to 0-1 confidence where 1 = perfect match.
*/
async function detectLanguage(
text: string
): Promise<{ lang: string; confidence: number }> {
if (!text || text.trim().length < 20) {
return { lang: 'und', confidence: 0 }
}
// Use a reasonable sample for detection (first 5000 chars)
const sample = text.slice(0, 5000)
const { francAll } = await import('franc')
const results = francAll(sample, { minLength: 10 })
if (!results || results.length === 0 || results[0][0] === 'und') {
return { lang: 'und', confidence: 0 }
}
const topLang = results[0][0]
const topScore = results[0][1] // 1.0 = best match, 0.0 = worst
// franc scores: 1.0 is best match, scale drops from there
// Convert to a 0-1 confidence
const confidence = Math.max(0, Math.min(1, topScore))
return { lang: topLang, confidence: Math.round(confidence * 100) / 100 }
}
// ─── Core Analysis ──────────────────────────────────────────────────────────
/**
* Analyze a single file: extract page count, text preview, and detect language.
* Downloads the file from storage, parses it, and returns results.
*/
export async function analyzeFileContent(
objectKey: string,
bucket: string,
mimeType: string,
fileName: string,
fileId: string
): Promise<AnalysisResult> {
const result: AnalysisResult = {
fileId,
pageCount: null,
textPreview: null,
detectedLang: null,
langConfidence: null,
}
if (!isParseableMimeType(mimeType)) {
return { ...result, error: 'Unsupported mime type for analysis' }
}
try {
const storage = await getStorageProvider()
const buffer = await storage.getObject(objectKey)
let text = ''
let pageCount: number | null = null
if (mimeType === 'application/pdf') {
const pdfParseModule = await import('pdf-parse')
const pdfParse =
typeof pdfParseModule === 'function'
? pdfParseModule
: (pdfParseModule as any).default ?? pdfParseModule
const pdf = await pdfParse(buffer)
text = pdf.text || ''
pageCount = pdf.numpages ?? null
} else {
// Text-based files (plain text, CSV, markdown, HTML, RTF)
text = buffer.toString('utf-8')
}
result.pageCount = pageCount
// Text preview
if (text.trim()) {
result.textPreview =
text.length > TEXT_PREVIEW_LIMIT
? text.slice(0, TEXT_PREVIEW_LIMIT)
: text
}
// Language detection
if (text.trim().length >= 20) {
const langResult = await detectLanguage(text)
result.detectedLang = langResult.lang
result.langConfidence = langResult.confidence
}
return result
} catch (error) {
console.warn(
`[DocAnalyzer] Failed to analyze ${fileName}:`,
error instanceof Error ? error.message : error
)
return {
...result,
error: error instanceof Error ? error.message : 'Analysis failed',
}
}
}
// ─── DB-Integrated Operations ───────────────────────────────────────────────
/**
* Analyze a single file by ID and persist results to DB.
*/
export async function analyzeFile(fileId: string): Promise<AnalysisResult> {
const file = await prisma.projectFile.findUnique({
where: { id: fileId },
select: {
id: true,
objectKey: true,
bucket: true,
mimeType: true,
fileName: true,
},
})
if (!file) {
return {
fileId,
pageCount: null,
textPreview: null,
detectedLang: null,
langConfidence: null,
error: 'File not found',
}
}
const result = await analyzeFileContent(
file.objectKey,
file.bucket,
file.mimeType,
file.fileName,
file.id
)
// Persist results
await prisma.projectFile.update({
where: { id: fileId },
data: {
pageCount: result.pageCount,
textPreview: result.textPreview,
detectedLang: result.detectedLang,
langConfidence: result.langConfidence,
analyzedAt: new Date(),
},
})
return result
}
/**
* Analyze a single file by ID with a delay (for post-upload use).
* The delay accounts for presigned URL uploads where the file
* may not be in storage yet when the DB record is created.
*/
export async function analyzeFileDelayed(
fileId: string,
delayMs = 3000
): Promise<AnalysisResult> {
await new Promise((resolve) => setTimeout(resolve, delayMs))
return analyzeFile(fileId)
}
/**
* Analyze all files for a specific project.
*/
export async function analyzeProjectFiles(
projectId: string
): Promise<{ analyzed: number; failed: number; total: number }> {
const files = await prisma.projectFile.findMany({
where: { projectId },
select: {
id: true,
objectKey: true,
bucket: true,
mimeType: true,
fileName: true,
},
})
let analyzed = 0
let failed = 0
// Process in batches
for (let i = 0; i < files.length; i += BATCH_SIZE) {
const batch = files.slice(i, i + BATCH_SIZE)
const results = await Promise.allSettled(
batch.map(async (file) => {
if (!isParseableMimeType(file.mimeType)) {
// Mark non-parseable files as analyzed with no data
await prisma.projectFile.update({
where: { id: file.id },
data: { analyzedAt: new Date() },
})
return 'skipped'
}
const result = await analyzeFileContent(
file.objectKey,
file.bucket,
file.mimeType,
file.fileName,
file.id
)
await prisma.projectFile.update({
where: { id: file.id },
data: {
pageCount: result.pageCount,
textPreview: result.textPreview,
detectedLang: result.detectedLang,
langConfidence: result.langConfidence,
analyzedAt: new Date(),
},
})
return result.error ? 'failed' : 'analyzed'
})
)
for (const r of results) {
if (r.status === 'fulfilled') {
if (r.value === 'analyzed') analyzed++
else if (r.value === 'failed') failed++
} else {
failed++
}
}
}
return { analyzed, failed, total: files.length }
}
/**
* Retroactive batch analysis: analyze all files that haven't been analyzed yet.
* Returns counts. Processes in batches to avoid memory issues.
*/
export async function analyzeAllUnanalyzed(): Promise<{
analyzed: number
failed: number
skipped: number
total: number
}> {
const files = await prisma.projectFile.findMany({
where: { analyzedAt: null },
select: {
id: true,
objectKey: true,
bucket: true,
mimeType: true,
fileName: true,
},
orderBy: { createdAt: 'desc' },
})
let analyzed = 0
let failed = 0
let skipped = 0
for (let i = 0; i < files.length; i += BATCH_SIZE) {
const batch = files.slice(i, i + BATCH_SIZE)
const results = await Promise.allSettled(
batch.map(async (file) => {
if (!isParseableMimeType(file.mimeType)) {
await prisma.projectFile.update({
where: { id: file.id },
data: { analyzedAt: new Date() },
})
return 'skipped'
}
const result = await analyzeFileContent(
file.objectKey,
file.bucket,
file.mimeType,
file.fileName,
file.id
)
await prisma.projectFile.update({
where: { id: file.id },
data: {
pageCount: result.pageCount,
textPreview: result.textPreview,
detectedLang: result.detectedLang,
langConfidence: result.langConfidence,
analyzedAt: new Date(),
},
})
return result.error ? 'failed' : 'analyzed'
})
)
for (const r of results) {
if (r.status === 'fulfilled') {
if (r.value === 'analyzed') analyzed++
else if (r.value === 'failed') failed++
else if (r.value === 'skipped') skipped++
} else {
failed++
}
}
console.log(
`[DocAnalyzer] Batch progress: ${i + batch.length}/${files.length} (${analyzed} analyzed, ${skipped} skipped, ${failed} failed)`
)
}
return { analyzed, failed, skipped, total: files.length }
}
/**
* Check if auto-analysis is enabled via SystemSettings.
*/
export async function isAutoAnalysisEnabled(): Promise<boolean> {
try {
const setting = await prisma.systemSettings.findUnique({
where: { key: 'file_analysis_auto_enabled' },
})
// Default to true if setting doesn't exist
return setting?.value !== 'false'
} catch {
return true
}
}

View File

@@ -143,6 +143,24 @@ export async function activateRound(
detailsJson: { name: round.name, roundType: round.roundType },
})
// Retroactive check: auto-PASS any projects that already have all required docs uploaded
// Non-fatal — runs after activation so it never blocks the transition
try {
const projectStates = await prisma.projectRoundState.findMany({
where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
select: { projectId: true },
})
if (projectStates.length > 0) {
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
const result = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
if (result.transitionedCount > 0) {
console.log(`[RoundEngine] On activation: auto-passed ${result.transitionedCount} projects with complete documents`)
}
}
} catch (retroError) {
console.error('[RoundEngine] Retroactive document check failed (non-fatal):', retroError)
}
return {
success: true,
round: { id: updated.id, status: updated.status },
@@ -429,6 +447,23 @@ export async function reopenRound(
},
})
// Retroactive check: auto-PASS any projects that already have all required docs
try {
const projectStates = await prisma.projectRoundState.findMany({
where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
select: { projectId: true },
})
if (projectStates.length > 0) {
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
const batchResult = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
if (batchResult.transitionedCount > 0) {
console.log(`[RoundEngine] On reopen: auto-passed ${batchResult.transitionedCount} projects with complete documents`)
}
}
} catch (retroError) {
console.error('[RoundEngine] Retroactive document check on reopen failed (non-fatal):', retroError)
}
return {
success: true,
round: { id: result.updated.id, status: result.updated.status },
@@ -625,6 +660,109 @@ export async function getProjectRoundState(
})
}
// ─── Auto-Transition on Document Completion ─────────────────────────────────
/**
* Check if a project has fulfilled all required FileRequirements for a round.
* If yes, and the project is currently PENDING, transition it to PASSED.
*
* Called after file uploads (admin bulk upload or applicant upload).
* Non-fatal: errors are logged but never propagated to callers.
*/
export async function checkRequirementsAndTransition(
projectId: string,
roundId: string,
actorId: string,
prisma: PrismaClient | any,
): Promise<{ transitioned: boolean; newState?: string }> {
try {
// Get all required FileRequirements for this round
const requirements = await prisma.fileRequirement.findMany({
where: { roundId, isRequired: true },
select: { id: true },
})
// If the round has no file requirements, nothing to check
if (requirements.length === 0) {
return { transitioned: false }
}
// Check which requirements this project has satisfied (has a file uploaded)
const fulfilledFiles = await prisma.projectFile.findMany({
where: {
projectId,
roundId,
requirementId: { in: requirements.map((r: { id: string }) => r.id) },
},
select: { requirementId: true },
})
const fulfilledIds = new Set(
fulfilledFiles
.map((f: { requirementId: string | null }) => f.requirementId)
.filter(Boolean)
)
// Check if all required requirements are met
const allMet = requirements.every((r: { id: string }) => fulfilledIds.has(r.id))
if (!allMet) {
return { transitioned: false }
}
// Check current state — only transition if PENDING or IN_PROGRESS
const currentState = await prisma.projectRoundState.findUnique({
where: { projectId_roundId: { projectId, roundId } },
select: { state: true },
})
const eligibleStates = ['PENDING', 'IN_PROGRESS']
if (!currentState || !eligibleStates.includes(currentState.state)) {
return { transitioned: false }
}
// All requirements met — transition to PASSED
const result = await transitionProject(projectId, roundId, 'PASSED' as ProjectRoundStateValue, actorId, prisma)
if (result.success) {
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to PASSED in round ${roundId} (all ${requirements.length} requirements met)`)
return { transitioned: true, newState: 'PASSED' }
}
return { transitioned: false }
} catch (error) {
// Non-fatal — log and continue
console.error('[RoundEngine] checkRequirementsAndTransition failed:', error)
return { transitioned: false }
}
}
/**
* Batch version: check all projects in a round and transition any that
* have all required documents uploaded. Useful after bulk upload.
*/
export async function batchCheckRequirementsAndTransition(
roundId: string,
projectIds: string[],
actorId: string,
prisma: PrismaClient | any,
): Promise<{ transitionedCount: number; projectIds: string[] }> {
const transitioned: string[] = []
for (const projectId of projectIds) {
const result = await checkRequirementsAndTransition(projectId, roundId, actorId, prisma)
if (result.transitioned) {
transitioned.push(projectId)
}
}
if (transitioned.length > 0) {
console.log(`[RoundEngine] Batch auto-transition: ${transitioned.length}/${projectIds.length} projects moved to PASSED in round ${roundId}`)
}
return { transitionedCount: transitioned.length, projectIds: transitioned }
}
// ─── Internals ──────────────────────────────────────────────────────────────
function isTerminalState(state: ProjectRoundStateValue): boolean {