Compare commits

..

12 Commits

Author SHA1 Message Date
Matt
2864579e92 fix: type-safe route for award master dashboard links
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m23s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:50:38 -04:00
Matt
8800f2bcc7 feat: add award master voting page with project detail, documents, scores, justification, and chair confirmation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:48:46 -04:00
Matt
0be8c5ecc7 feat: add chair toggle to admin award juror management
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:45:25 -04:00
Matt
fbc8b5165a feat: add award master tRPC procedures — enhanced detail, vote with justification, confirm winner, set chair
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:42:21 -04:00
Matt
9368c1221f feat: add award master dashboard page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:36:47 -04:00
Matt
9fcafec346 feat: add award-master route group layout and nav component
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:36:20 -04:00
Matt
f61cebe6ae feat: wire AWARD_MASTER into auth redirects and role-switch navigation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:35:34 -04:00
Matt
4d68392ada feat: add award winner resolver with tiebreak logic and tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:34:44 -04:00
Matt
de12b232d9 fix: allow AWARD_MASTER role to access project files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:34:38 -04:00
Matt
a002f544a6 schema: add isChair to AwardJuror, justification to AwardVote
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:33:13 -04:00
Matt
3ccf9b0542 feat: per-category evaluation criteria (startup vs business concept)
Add ability to define completely different evaluation criteria for each
competition category. Admins toggle "Separate Criteria per Category" in
round config, then configure criteria independently via tabbed editor.

- Schema: add nullable `category` to EvaluationForm with updated constraints
- Config: add `perCategoryCriteria` boolean to EvaluationConfigSchema
- Helper: new `findActiveForm()` with category-aware resolution + fallback
- Backend: getForm, upsertForm, getStageForm, startStage all category-aware
- AI services: use project category for form lookup in summaries + ranking
- Export/ranking: merge criteria from all active forms for cross-category reports
- Admin UI: toggle switch + tabbed criteria editor with per-category builders
- Jury UI: auto-selects correct form based on project category (invisible to juror)
- Fully backwards compatible: toggle defaults OFF, existing forms unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:03:22 -04:00
Matt
7ead21114e fix: pipeline progress, message variables, jury invite flow, accept-invite UX
- Pipeline: SUBMISSION rounds count IN_PROGRESS + COMPLETED for progress %
- Round engine: remove phantom SubmissionFileRequirement check blocking auto-transition
- Messages: implement {{userName}}, {{projectName}}, {{roundName}}, {{programName}}, {{deadline}} substitution
- Email preview: show greeting, CTA button, and footer matching actual sent email
- Message composer: add green dot indicator for active rounds in round selector
- User create: generate invite token atomically (prevents stuck INVITED state on email failure)
- Jury invites: use jury-specific email template mentioning round context
- Bulk invite: animated progress bar, batch size hint, success/failure counts
- Accept invite: distinguish server errors (retry button) from expired tokens (redirect)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:47:42 -04:00
37 changed files with 1727 additions and 219 deletions

View File

@@ -0,0 +1,17 @@
-- AlterTable: add nullable category column to EvaluationForm
ALTER TABLE "EvaluationForm" ADD COLUMN "category" "CompetitionCategory";
-- Drop old unique constraint
ALTER TABLE "EvaluationForm" DROP CONSTRAINT "EvaluationForm_roundId_version_key";
-- Add new unique constraint including category
ALTER TABLE "EvaluationForm" ADD CONSTRAINT "EvaluationForm_roundId_version_category_key" UNIQUE ("roundId", "version", "category");
-- Partial unique index: prevent duplicate shared forms at the same version
-- (PostgreSQL treats NULLs as distinct in unique constraints, so we need this)
CREATE UNIQUE INDEX "EvaluationForm_roundId_version_null_category"
ON "EvaluationForm" ("roundId", "version") WHERE "category" IS NULL;
-- Compound index for category-aware active form lookups
CREATE INDEX "EvaluationForm_roundId_isActive_category_idx"
ON "EvaluationForm" ("roundId", "isActive", "category");

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "AwardJuror" ADD COLUMN "isChair" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "AwardVote" ADD COLUMN "justification" TEXT;

View File

@@ -519,9 +519,10 @@ model WizardTemplate {
// =============================================================================
model EvaluationForm {
id String @id @default(cuid())
roundId String
version Int @default(1)
id String @id @default(cuid())
roundId String
version Int @default(1)
category CompetitionCategory? // null=shared form, STARTUP or BUSINESS_CONCEPT=category-specific
// Form configuration
// criteriaJson: Array of { id, label, description, scale, weight, required }
@@ -537,8 +538,9 @@ model EvaluationForm {
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
evaluations Evaluation[]
@@unique([roundId, version])
@@unique([roundId, version, category])
@@index([roundId, isActive])
@@index([roundId, isActive, category])
}
// =============================================================================
@@ -1644,9 +1646,10 @@ model AwardEligibility {
}
model AwardJuror {
id String @id @default(cuid())
id String @id @default(cuid())
awardId String
userId String
isChair Boolean @default(false)
createdAt DateTime @default(now())
@@ -1664,8 +1667,9 @@ model AwardVote {
awardId String
userId String
projectId String
rank Int? // For RANKED mode
votedAt DateTime @default(now())
rank Int? // For RANKED mode
justification String? @db.Text
votedAt DateTime @default(now())
// Relations
award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)

View File

@@ -477,6 +477,13 @@ export default function AwardDetailPage({
const removeJuror = trpc.specialAward.removeJuror.useMutation({
onSuccess: () => utils.specialAward.listJurors.invalidate({ awardId }),
})
const setChair = trpc.specialAward.setChair.useMutation({
onSuccess: () => {
utils.specialAward.listJurors.invalidate({ awardId })
toast.success('Chair status updated')
},
onError: () => toast.error('Failed to update chair status'),
})
const setWinner = trpc.specialAward.setWinner.useMutation({
onSuccess: invalidateAward,
})
@@ -1328,6 +1335,7 @@ export default function AwardDetailPage({
<TableRow>
<TableHead>Member</TableHead>
<TableHead>Role</TableHead>
<TableHead>Chair</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -1352,6 +1360,15 @@ export default function AwardDetailPage({
{j.user.role.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell>
<Switch
checked={j.isChair}
onCheckedChange={(checked) =>
setChair.mutate({ awardId, userId: j.userId, isChair: checked })
}
disabled={setChair.isPending}
/>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useCallback, useMemo } from 'react'
import { useState, useCallback, useMemo, useEffect, useRef } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import Papa from 'papaparse'
@@ -270,6 +270,8 @@ export default function MemberInvitePage() {
skipped: number
assignmentsCreated?: number
invitationSent?: boolean
emailsSent?: number
emailErrors?: string[]
} | null>(null)
// Pre-assignment state
@@ -511,10 +513,34 @@ export default function MemberInvitePage() {
setParsedUsers(parsedUsers.filter((u) => u.isValid))
// --- Send ---
// Simulated progress: ramps up gradually while waiting for the backend
const progressIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const stopProgressSimulation = useCallback(() => {
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current)
progressIntervalRef.current = null
}
}, [])
// Cleanup on unmount
useEffect(() => stopProgressSimulation, [stopProgressSimulation])
const handleSendInvites = async () => {
if (summary.valid === 0) return
setStep('sending')
setSendProgress(0)
// Simulate progress: advance quickly at first, then slow down (never reaches 100)
progressIntervalRef.current = setInterval(() => {
setSendProgress((prev) => {
if (prev >= 90) return prev // Cap at 90 — real completion sets 100
// Slow down as we approach 90
const increment = Math.max(0.5, (90 - prev) * 0.04)
return Math.min(90, prev + increment)
})
}, 300)
try {
const result = await bulkCreate.mutateAsync({
users: summary.validUsers.map((u) => ({
@@ -526,10 +552,12 @@ export default function MemberInvitePage() {
})),
sendInvitation,
})
stopProgressSimulation()
setSendProgress(100)
setResult(result)
setStep('complete')
} catch {
stopProgressSimulation()
setStep('preview')
}
}
@@ -999,9 +1027,16 @@ export default function MemberInvitePage() {
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<p className="mt-4 font-medium">
{sendInvitation ? 'Creating members and sending invitations...' : 'Creating members...'}
{sendInvitation
? `Creating ${summary.valid} member${summary.valid !== 1 ? 's' : ''} and sending invitations...`
: `Creating ${summary.valid} member${summary.valid !== 1 ? 's' : ''}...`}
</p>
<Progress value={sendProgress} className="mt-4 w-48" />
<Progress value={sendProgress} className="mt-4 w-64" />
{sendInvitation && summary.valid > 3 && (
<p className="text-muted-foreground text-sm mt-3">
This may take a moment for large batches
</p>
)}
</CardContent>
</Card>
)
@@ -1019,6 +1054,16 @@ export default function MemberInvitePage() {
<p className="text-muted-foreground text-center max-w-sm mt-2">
{result?.created} member{result?.created !== 1 ? 's' : ''}{' '}
{result?.invitationSent ? 'created and invited' : 'created'}.
{result?.invitationSent && result.emailsSent != null && (
<span className="block mt-1">
{result.emailsSent} invitation{result.emailsSent !== 1 ? 's' : ''} sent successfully.
{result.emailErrors && result.emailErrors.length > 0 && (
<span className="text-destructive">
{' '}{result.emailErrors.length} failed to send.
</span>
)}
</span>
)}
{result?.skipped
? ` ${result.skipped} skipped (already exist).`
: ''}

View File

@@ -128,7 +128,7 @@ export default function MessagesPage() {
// Fetch supporting data
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
const rounds = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ ...s, program: { name: p.name } }))
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ ...s, program: { name: p.name } }))
) || []
const { data: templates } = trpc.message.listTemplates.useQuery()
const { data: users } = trpc.user.list.useQuery(
@@ -465,8 +465,9 @@ export default function MessagesPage() {
{rounds?.map((round) => {
const label = round.program ? `${round.program.name} - ${round.name}` : round.name
const isChecked = roundIds.includes(round.id)
const isActive = round.status === 'ROUND_ACTIVE'
return (
<div key={round.id} className="flex items-center gap-2">
<div key={round.id} className={`flex items-center gap-2 ${!isActive ? 'opacity-60' : ''}`}>
<Checkbox
id={`round-${round.id}`}
checked={isChecked}
@@ -478,7 +479,8 @@ export default function MessagesPage() {
)
}}
/>
<label htmlFor={`round-${round.id}`} className="text-sm cursor-pointer">
<label htmlFor={`round-${round.id}`} className="text-sm cursor-pointer flex items-center gap-1.5">
{isActive && <span className="inline-block h-2 w-2 rounded-full bg-green-500 shrink-0" />}
{label}
</label>
</div>

View File

@@ -1006,6 +1006,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
open={!!selectedEvalAssignment}
onOpenChange={(open) => { if (!open) setSelectedEvalAssignment(null) }}
onSaved={() => utils.project.getFullDetail.invalidate({ id: projectId })}
category={project?.competitionCategory}
/>
{/* AI Evaluation Summary */}

View File

@@ -2291,7 +2291,7 @@ export default function RoundDetailPage() {
/>
{/* Evaluation Criteria Editor (EVALUATION rounds only) */}
{isEvaluation && <EvaluationCriteriaEditor roundId={roundId} />}
{isEvaluation && <EvaluationCriteriaEditor roundId={roundId} perCategoryCriteria={!!(config.perCategoryCriteria)} />}
{/* Document Requirements — hidden for EVALUATION rounds unless requireDocumentUpload is on */}
{(!isEvaluation || !!(config.requireDocumentUpload as boolean)) && (

View File

@@ -11,7 +11,7 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock } from 'lucide-react'
import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock, RefreshCw } from 'lucide-react'
import Image from 'next/image'
import { trpc } from '@/lib/trpc/client'
import { AnimatedCard } from '@/components/shared/animated-container'
@@ -64,6 +64,48 @@ function ErrorRedirectCard({
)
}
function NetworkErrorCard({ onRetry, isRetrying }: { onRetry: () => void; isRetrying: boolean }) {
return (
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100">
<AlertCircle className="h-6 w-6 text-amber-600" />
</div>
<CardTitle className="text-xl">Something Went Wrong</CardTitle>
<CardDescription className="text-base">
We couldn&apos;t verify your invitation due to a server or network issue.
This is not a problem with your invitation link.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button
className="w-full"
onClick={onRetry}
disabled={isRetrying}
>
{isRetrying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Retrying...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Try Again
</>
)}
</Button>
<p className="text-xs text-center text-muted-foreground">
If the problem persists, please contact your administrator.
</p>
</CardContent>
</Card>
</AnimatedCard>
)
}
function AcceptInviteContent() {
const [state, setState] = useState<InviteState>('loading')
const [errorType, setErrorType] = useState<string | null>(null)
@@ -72,7 +114,7 @@ function AcceptInviteContent() {
const router = useRouter()
const token = searchParams.get('token') || ''
const { data, isLoading, error } = trpc.user.validateInviteToken.useQuery(
const { data, isLoading, error, refetch, isRefetching } = trpc.user.validateInviteToken.useQuery(
{ token },
{ enabled: !!token, retry: false }
)
@@ -197,8 +239,23 @@ function AcceptInviteContent() {
)
}
// Error state — auto-redirect to login after 4 seconds for known errors
// Error state
if (state === 'error') {
// Network/server errors get a retry button instead of auto-redirect
if (errorType === 'NETWORK_ERROR') {
return (
<NetworkErrorCard
onRetry={() => {
setState('loading')
setErrorType(null)
refetch()
}}
isRetrying={isRefetching}
/>
)
}
// Token validation errors auto-redirect to login after 4 seconds
const errorContent = getErrorContent()
const redirectTarget = errorContent.redirect || '/login'

View File

@@ -0,0 +1,579 @@
'use client'
import { use, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { Separator } from '@/components/ui/separator'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { toast } from 'sonner'
import {
ArrowLeft,
Trophy,
CheckCircle2,
Loader2,
ChevronDown,
ChevronUp,
FileText,
Star,
Users,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { CountryDisplay } from '@/components/shared/country-display'
import { ProjectFilesSection } from '@/components/jury/project-files-section'
export default function AwardMasterVotingPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id: awardId } = use(params)
const router = useRouter()
// State
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null
)
const [expandedProjectId, setExpandedProjectId] = useState<string | null>(
null
)
const [justification, setJustification] = useState('')
// Queries & mutations
const utils = trpc.useUtils()
const { data, isLoading } =
trpc.specialAward.getMyAwardDetailEnhanced.useQuery({ awardId })
const submitVote = trpc.specialAward.submitAwardMasterVote.useMutation({
onSuccess: () => {
utils.specialAward.getMyAwardDetailEnhanced.invalidate({ awardId })
toast.success('Vote submitted')
},
onError: (err) => toast.error(err.message),
})
const confirmWinner = trpc.specialAward.confirmWinner.useMutation({
onSuccess: () => {
utils.specialAward.getMyAwardDetailEnhanced.invalidate({ awardId })
toast.success('Winner confirmed and award closed')
},
onError: (err) => toast.error(err.message),
})
// Initialize selection from existing vote
const initializedRef = useRef(false)
if (data && !initializedRef.current && data.myVotes.length > 0) {
initializedRef.current = true
setSelectedProjectId(data.myVotes[0].projectId)
if (data.myVotes[0].justification) {
setJustification(data.myVotes[0].justification)
}
}
// Loading state
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-6 w-72" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<Skeleton key={i} className="h-44" />
))}
</div>
</div>
)
}
if (!data) return null
// Destructure data
const { award, projects, myVotes, isChair, otherVotes, totalJurors } = data
const hasVoted = myVotes.length > 0
const isVotingOpen = award.status === 'VOTING_OPEN'
const isClosed = award.status === 'CLOSED'
const selectedProject = projects.find((p) => p.id === selectedProjectId)
// Toggle project expansion
const handleProjectClick = (projectId: string) => {
if (isVotingOpen) setSelectedProjectId(projectId)
setExpandedProjectId(expandedProjectId === projectId ? null : projectId)
}
// Submit vote handler
const handleSubmitVote = () => {
if (!selectedProjectId) return
submitVote.mutate({
awardId,
projectId: selectedProjectId,
justification: justification.trim() || undefined,
})
}
// Confirm winner handler
const handleConfirmWinner = () => {
confirmWinner.mutate({ awardId })
}
// Find the winner project for closed state
const winnerProject = isClosed
? projects.find((p) => p.id === award.winnerProjectId)
: null
return (
<div className="space-y-6">
{/* Back button */}
<div className="flex items-center gap-4">
<Button
variant="ghost"
onClick={() => router.push('/award-master' as Route)}
className="-ml-4"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</div>
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Trophy className="h-6 w-6 text-amber-500" />
{award.name}
</h1>
<div className="mt-1 flex items-center gap-2">
<Badge
variant={
isVotingOpen
? 'default'
: isClosed
? 'secondary'
: 'outline'
}
>
{award.status.replace('_', ' ')}
</Badge>
{hasVoted && !isClosed && (
<Badge variant="outline" className="text-green-600">
<CheckCircle2 className="mr-1 h-3 w-3" />
Voted
</Badge>
)}
{award.competition && (
<span className="text-sm text-muted-foreground">
{award.competition.name}
</span>
)}
</div>
{award.criteriaText && (
<Card className="mt-3 bg-muted/30">
<CardContent className="py-3 px-4">
<p className="text-sm text-muted-foreground leading-relaxed">
<Star className="mr-1.5 inline h-4 w-4 text-amber-500" />
<span className="font-medium text-foreground">Criteria: </span>
{award.criteriaText}
</p>
</CardContent>
</Card>
)}
</div>
{/* Closed State */}
{isClosed ? (
<Card className="border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-full bg-amber-100 p-4 dark:bg-amber-900/40">
<Trophy className="h-12 w-12 text-amber-500" />
</div>
<p className="mt-4 text-lg font-semibold">Award Finalized</p>
{winnerProject ? (
<div className="mt-3 space-y-1">
<p className="text-xl font-bold text-[#053d57] dark:text-blue-300">
{winnerProject.title}
</p>
{winnerProject.teamName && (
<p className="text-sm text-muted-foreground">
{winnerProject.teamName}
</p>
)}
</div>
) : (
<p className="mt-2 text-sm text-muted-foreground">
This award has been finalized
</p>
)}
</CardContent>
</Card>
) : (
<>
{/* Project Grid */}
<div>
<h2 className="text-lg font-semibold mb-3">
Eligible Projects ({projects.length})
</h2>
{isVotingOpen && (
<p className="text-sm text-muted-foreground mb-4">
Click a project to select it as your pick and expand details
</p>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
<div
key={project.id}
className={cn(
expandedProjectId === project.id && 'sm:col-span-2 lg:col-span-3'
)}
>
<Card
className={cn(
'cursor-pointer transition-all',
selectedProjectId === project.id
? 'ring-2 ring-primary bg-primary/5'
: 'hover:bg-muted/50'
)}
onClick={() => handleProjectClick(project.id)}
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<CardTitle className="text-base">
{project.title}
</CardTitle>
{project.teamName && (
<CardDescription className="mt-0.5">
{project.teamName}
</CardDescription>
)}
</div>
<div className="ml-2 shrink-0">
{expandedProjectId === project.id ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-center gap-1.5">
{project.competitionCategory && (
<Badge variant="outline" className="text-xs">
{project.competitionCategory.replace(/_/g, ' ')}
</Badge>
)}
{project.country && (
<Badge variant="outline" className="text-xs">
<CountryDisplay country={project.country} />
</Badge>
)}
{project.evaluationScore && (
<Badge
variant="secondary"
className="text-xs bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300"
>
<Star className="mr-0.5 h-3 w-3" />
Avg: {project.evaluationScore.avg.toFixed(1)}/10 (
{project.evaluationScore.count}{' '}
{project.evaluationScore.count === 1
? 'review'
: 'reviews'}
)
</Badge>
)}
{selectedProjectId === project.id && (
<Badge className="text-xs bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300">
<CheckCircle2 className="mr-0.5 h-3 w-3" />
Selected
</Badge>
)}
</div>
</CardContent>
</Card>
{/* Expanded Project Detail */}
{expandedProjectId === project.id && (
<Card className="mt-2 border-dashed">
<CardContent className="space-y-4 py-4">
{project.description && (
<div>
<h4 className="text-sm font-medium mb-1 flex items-center gap-1.5">
<FileText className="h-4 w-4 text-muted-foreground" />
Description
</h4>
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-line">
{project.description}
</p>
</div>
)}
{award.evaluationRoundId && (
<div>
<h4 className="text-sm font-medium mb-2 flex items-center gap-1.5">
<FileText className="h-4 w-4 text-muted-foreground" />
Documents
</h4>
<ProjectFilesSection
projectId={project.id}
roundId={award.evaluationRoundId}
/>
</div>
)}
{project.evaluationScore && (
<Card className="bg-blue-50/50 dark:bg-blue-950/20">
<CardContent className="py-3 px-4">
<div className="flex items-center gap-2">
<Star className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<div>
<p className="text-sm font-medium">
Evaluation Score
</p>
<p className="text-lg font-bold text-blue-700 dark:text-blue-300">
{project.evaluationScore.avg.toFixed(1)} / 10
</p>
<p className="text-xs text-muted-foreground">
Based on {project.evaluationScore.count}{' '}
{project.evaluationScore.count === 1
? 'evaluation'
: 'evaluations'}
</p>
</div>
</div>
</CardContent>
</Card>
)}
</CardContent>
</Card>
)}
</div>
))}
</div>
</div>
{/* Vote Section */}
{isVotingOpen && (
<Card>
<CardHeader>
<CardTitle className="text-base">Your Vote</CardTitle>
<CardDescription>
{hasVoted
? 'You can update your vote until the award is finalized'
: 'Select a project above and submit your vote'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{selectedProject ? (
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-sm text-muted-foreground">
Your selection
</p>
<p className="font-semibold">{selectedProject.title}</p>
{selectedProject.teamName && (
<p className="text-sm text-muted-foreground">
{selectedProject.teamName}
</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground italic">
No project selected. Click a project card above to select it.
</p>
)}
<div className="space-y-2">
<label
htmlFor="justification"
className="text-sm font-medium"
>
Justification
</label>
<Textarea
id="justification"
value={justification}
onChange={(e) => setJustification(e.target.value)}
placeholder="Why did you choose this project? (optional)"
maxLength={2000}
rows={4}
/>
<p className="text-xs text-muted-foreground text-right">
{justification.length} / 2000
</p>
</div>
<div className="flex justify-end">
<Button
onClick={handleSubmitVote}
disabled={!selectedProjectId || submitVote.isPending}
>
{submitVote.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
{hasVoted ? 'Update Vote' : 'Submit Vote'}
</Button>
</div>
</CardContent>
</Card>
)}
{/* Chair Section */}
{isChair && isVotingOpen && (
<>
<Separator />
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-5 w-5 text-muted-foreground" />
Team Votes
</CardTitle>
<CardDescription>
As chair, you can view team votes and confirm the winner
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{otherVotes.length > 0 ? (
<div className="space-y-3">
{otherVotes.map((vote) => {
const votedProject = projects.find(
(p) => p.id === vote.projectId
)
return (
<div
key={vote.userId}
className="rounded-lg border p-3 space-y-1"
>
<div className="flex items-center justify-between">
<p className="font-medium text-sm">
{vote.userName || 'Anonymous Juror'}
</p>
<Badge variant="outline" className="text-xs">
voted for
</Badge>
</div>
<p className="text-sm font-semibold">
{votedProject?.title || 'Unknown project'}
</p>
{vote.justification && (
<p className="text-sm text-muted-foreground italic">
&ldquo;{vote.justification}&rdquo;
</p>
)}
</div>
)
})}
</div>
) : (
<p className="text-sm text-muted-foreground italic">
Waiting for other team members to vote
</p>
)}
{/* Vote tally */}
<div className="rounded-lg bg-muted/30 p-3">
<p className="text-sm font-medium">Vote Summary</p>
<p className="text-sm text-muted-foreground">
{otherVotes.length + (hasVoted ? 1 : 0)} of{' '}
{totalJurors} jurors have voted
</p>
{(() => {
const allVotes = [
...otherVotes.map((v) => v.projectId),
...(hasVoted && myVotes[0]
? [myVotes[0].projectId]
: []),
]
const tally = new Map<string, number>()
for (const pid of allVotes) {
tally.set(pid, (tally.get(pid) || 0) + 1)
}
const sorted = [...tally.entries()].sort(
(a, b) => b[1] - a[1]
)
if (sorted.length === 0) return null
return (
<div className="mt-2 space-y-1">
{sorted.map(([pid, count]) => {
const proj = projects.find((p) => p.id === pid)
return (
<div
key={pid}
className="flex items-center justify-between text-sm"
>
<span>{proj?.title || 'Unknown'}</span>
<Badge variant="secondary" className="text-xs">
{count} {count === 1 ? 'vote' : 'votes'}
</Badge>
</div>
)
})}
</div>
)
})()}
</div>
{/* Confirm Winner button */}
<div className="flex justify-end">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="default"
disabled={!hasVoted || confirmWinner.isPending}
>
{confirmWinner.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trophy className="mr-2 h-4 w-4" />
)}
Confirm Winner
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Confirm Award Winner
</AlertDialogTitle>
<AlertDialogDescription>
This will finalize the winner and close the award.
This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmWinner}>
Confirm Winner
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
</>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,91 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Trophy } from 'lucide-react'
export default function AwardMasterDashboard() {
const { data: awards, isLoading } = trpc.specialAward.getMyAwards.useQuery()
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-48" />
<div className="grid gap-4 sm:grid-cols-2">
{[...Array(2)].map((_, i) => (
<Skeleton key={i} className="h-40" />
))}
</div>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Award Master Dashboard
</h1>
<p className="text-muted-foreground">
Review eligible projects and select award winners
</p>
</div>
{awards && awards.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{awards.map((award) => (
<Link key={award.id} href={`/award-master/awards/${award.id}` as Route}>
<Card className="transition-colors hover:bg-muted/50 cursor-pointer h-full">
<CardHeader>
<div className="flex items-start justify-between">
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-amber-500" />
{award.name}
</CardTitle>
<Badge
variant={
award.status === 'VOTING_OPEN' ? 'default' : 'secondary'
}
>
{award.status.replace('_', ' ')}
</Badge>
</div>
{award.description && (
<CardDescription className="line-clamp-2">
{award.description}
</CardDescription>
)}
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{award._count.eligibilities} eligible projects
</p>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Trophy className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No awards assigned</p>
<p className="text-sm text-muted-foreground">
You will see your awards here when they are assigned to you
</p>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { requireRole } from '@/lib/auth-redirect'
import { AwardMasterNav } from '@/components/layouts/award-master-nav'
export const dynamic = 'force-dynamic'
export default async function AwardMasterLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await requireRole('AWARD_MASTER', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
return (
<div className="min-h-screen bg-background">
<AwardMasterNav
user={{
name: session.user.name,
email: session.user.email,
}}
/>
<main className="container-app py-6 lg:py-8">{children}</main>
</div>
)
}

View File

@@ -80,10 +80,10 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
const [coiCompleted, setCOICompleted] = useState(false)
const [coiHasConflict, setCOIHasConflict] = useState(false)
// Fetch the active evaluation form for this round
// Fetch the active evaluation form for this round (category-aware)
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
{ roundId },
{ enabled: !!roundId }
{ roundId, category: project?.competitionCategory },
{ enabled: !!roundId && !!project }
)
// Start evaluation mutation (creates draft)

View File

@@ -34,6 +34,8 @@ type EvaluationEditSheetProps = {
onOpenChange: (open: boolean) => void
/** Called after a successful feedback edit */
onSaved?: () => void
/** Optional project competition category for category-aware form lookup */
category?: 'STARTUP' | 'BUSINESS_CONCEPT' | null
}
export function EvaluationEditSheet({
@@ -41,6 +43,7 @@ export function EvaluationEditSheet({
open,
onOpenChange,
onSaved,
category,
}: EvaluationEditSheetProps) {
const [isEditing, setIsEditing] = useState(false)
const [editedFeedback, setEditedFeedback] = useState('')
@@ -115,7 +118,7 @@ export function EvaluationEditSheet({
{/* Criterion Scores */}
{hasScores && (
<CriterionScoresSection criterionScores={criterionScores} roundId={roundId} />
<CriterionScoresSection criterionScores={criterionScores} roundId={roundId} category={category ?? assignment.project?.competitionCategory} />
)}
{/* Feedback Text — editable */}
@@ -147,12 +150,14 @@ export function EvaluationEditSheet({
function CriterionScoresSection({
criterionScores,
roundId,
category,
}: {
criterionScores: Record<string, number | boolean | string>
roundId?: string
category?: 'STARTUP' | 'BUSINESS_CONCEPT' | null
}) {
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
{ roundId: roundId ?? '' },
{ roundId: roundId ?? '', category },
{ enabled: !!roundId }
)

View File

@@ -6,15 +6,165 @@ import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { Loader2 } from 'lucide-react'
import { EvaluationFormBuilder } from '@/components/forms/evaluation-form-builder'
import type { Criterion } from '@/components/forms/evaluation-form-builder'
export type EvaluationCriteriaEditorProps = {
roundId: string
perCategoryCriteria?: boolean
}
export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorProps) {
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
function parseCriteria(criteriaJson: unknown): Criterion[] {
if (!criteriaJson) return []
return (criteriaJson as Criterion[]).map((c) => {
const type = c.type || 'numeric'
if (type === 'numeric' && typeof c.scale === 'string') {
const parts = (c.scale as string).split('-').map(Number)
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
return { ...c, type: 'numeric' as const, scale: parts[1], minScore: parts[0], maxScore: parts[1] } as unknown as Criterion
}
}
return { ...c, type } as Criterion
})
}
function buildUpsertPayload(criteria: Criterion[]) {
return criteria.map((c) => ({
id: c.id,
label: c.label,
description: c.description,
type: c.type || 'numeric',
weight: c.weight,
scale: typeof c.scale === 'number' ? c.scale : undefined,
minScore: (c as any).minScore,
maxScore: (c as any).maxScore,
required: c.required,
maxLength: c.maxLength,
placeholder: c.placeholder,
trueLabel: c.trueLabel,
falseLabel: c.falseLabel,
condition: c.condition,
sectionId: c.sectionId,
}))
}
// ---------------------------------------------------------------------------
// Single-category panel (used both standalone and inside tabs)
// ---------------------------------------------------------------------------
function SingleCriteriaPanel({
roundId,
category,
}: {
roundId: string
category?: 'STARTUP' | 'BUSINESS_CONCEPT'
}) {
const [pendingCriteria, setPendingCriteria] = useState<Criterion[] | null>(null)
const utils = trpc.useUtils()
const { data: form, isLoading } = trpc.evaluation.getForm.useQuery(
{ roundId, category: category ?? null },
{ refetchInterval: 30_000 },
)
const upsertMutation = trpc.evaluation.upsertForm.useMutation({
onSuccess: () => {
utils.evaluation.getForm.invalidate({ roundId, category: category ?? null })
toast.success('Evaluation criteria saved')
setPendingCriteria(null)
},
onError: (err) => toast.error(err.message),
})
const serverCriteria: Criterion[] = useMemo(
() => parseCriteria(form?.criteriaJson),
[form?.criteriaJson],
)
const handleChange = useCallback((criteria: Criterion[]) => {
setPendingCriteria(criteria)
}, [])
const handleSave = () => {
const criteria = pendingCriteria ?? serverCriteria
const validCriteria = criteria.filter((c) => c.label.trim())
if (validCriteria.length === 0) {
toast.error('Add at least one criterion')
return
}
upsertMutation.mutate({
roundId,
category: category ?? undefined,
criteria: buildUpsertPayload(validCriteria),
})
}
return (
<div>
<div className="flex items-center justify-end mb-3">
{form && (
<span className="text-xs text-muted-foreground mr-auto">
Version {form.version} &mdash;{' '}
{(form.criteriaJson as Criterion[]).filter((c) => (c.type || 'numeric') !== 'section_header').length} criteria
</span>
)}
{!form && !isLoading && (
<span className="text-xs text-muted-foreground mr-auto">
No criteria defined yet. Add numeric scores, yes/no questions, and text fields.
</span>
)}
{pendingCriteria && (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setPendingCriteria(null)}>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={upsertMutation.isPending}>
{upsertMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Save Criteria
</Button>
</div>
)}
</div>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
</div>
) : (
<EvaluationFormBuilder
initialCriteria={serverCriteria}
onChange={handleChange}
/>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Main export
// ---------------------------------------------------------------------------
export function EvaluationCriteriaEditor({ roundId, perCategoryCriteria }: EvaluationCriteriaEditorProps) {
if (!perCategoryCriteria) {
// Original single-form layout
return <SingleFormEditor roundId={roundId} />
}
// Per-category tabbed layout
return <TabbedCriteriaEditor roundId={roundId} />
}
// ---------------------------------------------------------------------------
// Single form editor — preserves original Card layout exactly
// ---------------------------------------------------------------------------
function SingleFormEditor({ roundId }: { roundId: string }) {
const [pendingCriteria, setPendingCriteria] = useState<Criterion[] | null>(null)
const utils = trpc.useUtils()
@@ -32,21 +182,10 @@ export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorPr
onError: (err) => toast.error(err.message),
})
// Convert server criteriaJson to Criterion[] format
const serverCriteria: Criterion[] = useMemo(() => {
if (!form?.criteriaJson) return []
return (form.criteriaJson as Criterion[]).map((c) => {
// Handle legacy numeric-only format: convert "scale" string like "1-10" back to minScore/maxScore
const type = c.type || 'numeric'
if (type === 'numeric' && typeof c.scale === 'string') {
const parts = (c.scale as string).split('-').map(Number)
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
return { ...c, type: 'numeric' as const, scale: parts[1], minScore: parts[0], maxScore: parts[1] } as unknown as Criterion
}
}
return { ...c, type } as Criterion
})
}, [form?.criteriaJson])
const serverCriteria: Criterion[] = useMemo(
() => parseCriteria(form?.criteriaJson),
[form?.criteriaJson],
)
const handleChange = useCallback((criteria: Criterion[]) => {
setPendingCriteria(criteria)
@@ -59,26 +198,9 @@ export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorPr
toast.error('Add at least one criterion')
return
}
// Map to upsertForm format
upsertMutation.mutate({
roundId,
criteria: validCriteria.map((c) => ({
id: c.id,
label: c.label,
description: c.description,
type: c.type || 'numeric',
weight: c.weight,
scale: typeof c.scale === 'number' ? c.scale : undefined,
minScore: (c as any).minScore,
maxScore: (c as any).maxScore,
required: c.required,
maxLength: c.maxLength,
placeholder: c.placeholder,
trueLabel: c.trueLabel,
falseLabel: c.falseLabel,
condition: c.condition,
sectionId: c.sectionId,
})),
criteria: buildUpsertPayload(validCriteria),
})
}
@@ -122,3 +244,59 @@ export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorPr
</Card>
)
}
// ---------------------------------------------------------------------------
// Tabbed criteria editor — separate forms per category
// ---------------------------------------------------------------------------
function TabbedCriteriaEditor({ roundId }: { roundId: string }) {
const { data: startupForm } = trpc.evaluation.getForm.useQuery(
{ roundId, category: 'STARTUP' },
{ refetchInterval: 30_000 },
)
const { data: conceptForm } = trpc.evaluation.getForm.useQuery(
{ roundId, category: 'BUSINESS_CONCEPT' },
{ refetchInterval: 30_000 },
)
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Evaluation Criteria</CardTitle>
<CardDescription>
Separate criteria for each project category. Configure each tab independently.
</CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="STARTUP">
<TabsList>
<TabsTrigger value="STARTUP" className="gap-2">
Startup Criteria
{!startupForm && (
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-300 text-[10px] px-1.5 py-0">
No form
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="BUSINESS_CONCEPT" className="gap-2">
Business Concept Criteria
{!conceptForm && (
<Badge variant="outline" className="bg-amber-50 text-amber-700 border-amber-300 text-[10px] px-1.5 py-0">
No form
</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="STARTUP" className="mt-4">
<SingleCriteriaPanel roundId={roundId} category="STARTUP" />
</TabsContent>
<TabsContent value="BUSINESS_CONCEPT" className="mt-4">
<SingleCriteriaPanel roundId={roundId} category="BUSINESS_CONCEPT" />
</TabsContent>
</Tabs>
</CardContent>
</Card>
)
}

View File

@@ -73,6 +73,18 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
</Select>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="perCategoryCriteria">Separate Criteria per Category</Label>
<p className="text-xs text-muted-foreground">Define different evaluation criteria for Startup and Business Concept projects</p>
</div>
<Switch
id="perCategoryCriteria"
checked={(config.perCategoryCriteria as boolean) ?? false}
onCheckedChange={(v) => update('perCategoryCriteria', v)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="anonymizationLevel">Anonymization Level</Label>
<p className="text-xs text-muted-foreground">How much of other jurors&apos; identities are revealed</p>

View File

@@ -70,8 +70,12 @@ function getMetric(round: PipelineRound): string {
return evalTotal > 0
? `${evalSubmitted}/${evalTotal} evaluated`
: `${assignmentCount} assignments`
case 'SUBMISSION':
return `${projectStates.COMPLETED} submitted`
case 'SUBMISSION': {
const active = projectStates.IN_PROGRESS + projectStates.COMPLETED
return active > 0
? `${projectStates.COMPLETED}/${projectStates.total} submitted${projectStates.IN_PROGRESS > 0 ? ` (${projectStates.IN_PROGRESS} in progress)` : ''}`
: `${projectStates.total} awaiting`
}
case 'MENTORING':
return `${projectStates.COMPLETED ?? 0} mentored`
case 'LIVE_FINAL': {
@@ -100,7 +104,8 @@ function getProgressPct(round: PipelineRound): number | null {
return round.evalTotal > 0 ? Math.round((round.evalSubmitted / round.evalTotal) * 100) : 0
case 'SUBMISSION': {
const total = round.projectStates.total
return total > 0 ? Math.round((round.projectStates.COMPLETED / total) * 100) : 0
const active = round.projectStates.IN_PROGRESS + round.projectStates.COMPLETED
return total > 0 ? Math.round((active / total) * 100) : 0
}
case 'MENTORING': {
const total = round.projectStates.total

View File

@@ -162,6 +162,7 @@ const ROLE_SWITCH_OPTIONS: Record<string, { label: string; path: string; icon: t
JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale },
MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake },
OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye },
AWARD_MASTER: { label: 'Award Master', path: '/award-master', icon: Trophy },
}
export function AdminSidebar({ user }: AdminSidebarProps) {

View File

@@ -0,0 +1,23 @@
'use client'
import { Home } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
interface AwardMasterNavProps {
user: RoleNavUser
}
export function AwardMasterNav({ user }: AwardMasterNavProps) {
const navigation: NavItem[] = [
{ name: 'Dashboard', href: '/award-master', icon: Home },
]
return (
<RoleNav
navigation={navigation}
roleName="Award Master"
user={user}
basePath="/award-master"
/>
)
}

View File

@@ -19,7 +19,7 @@ import {
import type { Route } from 'next'
import type { LucideIcon } from 'lucide-react'
import {
LogOut, Menu, Moon, Settings, Sun, User, X,
LogOut, Menu, Moon, Settings, Sun, User, X, Trophy,
LayoutDashboard, Scale, Handshake, Eye, ArrowRightLeft,
ExternalLink as ExternalLinkIcon, HelpCircle, Mail,
} from 'lucide-react'
@@ -61,6 +61,7 @@ const ROLE_SWITCH_OPTIONS: Record<string, { label: string; path: string; icon: t
JURY_MEMBER: { label: 'Jury View', path: '/jury', icon: Scale },
MENTOR: { label: 'Mentor View', path: '/mentor', icon: Handshake },
OBSERVER: { label: 'Observer View', path: '/observer', icon: Eye },
AWARD_MASTER: { label: 'Award Master', path: '/award-master', icon: Trophy },
}
function isNavItemActive(pathname: string, href: string, basePath: string): boolean {

View File

@@ -54,7 +54,7 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
const roundId = data?.assignments?.[0]?.roundId as string | undefined
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
{ roundId: roundId ?? '' },
{ roundId: roundId ?? '', category: data?.project?.competitionCategory },
{ enabled: !!roundId },
)

View File

@@ -10,6 +10,7 @@ const ROLE_DASHBOARDS: Record<string, string> = {
MENTOR: '/mentor',
OBSERVER: '/observer',
APPLICANT: '/applicant',
AWARD_MASTER: '/award-master',
}
export async function requireRole(...allowedRoles: UserRole[]) {

View File

@@ -1993,10 +1993,14 @@ Together for a healthier ocean.
export function getEmailPreviewHtml(subject: string, body: string): string {
const formattedBody = escapeHtml(body).replace(/\n/g, '<br>')
const content = `
${sectionTitle(subject)}
<div style="color: #1f2937; font-size: 15px; line-height: 1.7; margin: 20px 0;">
${sectionTitle('Hello [Name],')}
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">
${formattedBody}
</div>
${ctaButton('#', 'View Details')}
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
You received this email because of your notification preferences on the MOPC Portal.
</p>
`
return getEmailWrapper(content)
}

View File

@@ -9,6 +9,7 @@ import { generateSummary } from '@/server/services/ai-evaluation-summary'
import { quickRank as aiQuickRank } from '../services/ai-ranking'
import type { EvaluationConfig } from '@/types/competition-configs'
import type { PrismaClient } from '@prisma/client'
import { findActiveForm } from '@/server/utils/evaluation-form-lookup'
import { triggerInProgressOnActivity, checkEvaluationCompletionAndTransition } from '../services/round-engine'
/**
@@ -1298,11 +1299,9 @@ export const evaluationRouter = router({
* Get active evaluation form for a round (admin view with full details)
*/
getForm: adminProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ roundId: z.string(), category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).nullish() }))
.query(async ({ ctx, input }) => {
const form = await ctx.prisma.evaluationForm.findFirst({
where: { roundId: input.roundId, isActive: true },
})
const form = await findActiveForm(ctx.prisma, input.roundId, input.category)
if (!form) return null
@@ -1333,6 +1332,7 @@ export const evaluationRouter = router({
.input(
z.object({
roundId: z.string(),
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).nullish(),
criteria: z.array(
z.object({
id: z.string(),
@@ -1364,7 +1364,7 @@ export const evaluationRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, criteria } = input
const { roundId, category, criteria } = input
// Enforce max one advance criterion per form
const advanceCount = criteria.filter((c) => c.type === 'advance').length
@@ -1378,9 +1378,9 @@ export const evaluationRouter = router({
// Verify round exists
await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId } })
// Get current max version for this round
// Get current max version for this round + category
const latestForm = await ctx.prisma.evaluationForm.findFirst({
where: { roundId },
where: { roundId, category: category ?? null },
orderBy: { version: 'desc' },
select: { version: true },
})
@@ -1440,10 +1440,10 @@ export const evaluationRouter = router({
scalesJson[scale] = { min, max }
}
// Transaction: deactivate old → create new
// Transaction: deactivate old → create new (scoped to category)
const form = await ctx.prisma.$transaction(async (tx) => {
await tx.evaluationForm.updateMany({
where: { roundId, isActive: true },
where: { roundId, isActive: true, category: category ?? null },
data: { isActive: false },
})
@@ -1454,6 +1454,7 @@ export const evaluationRouter = router({
criteriaJson,
scalesJson,
isActive: true,
category: category ?? null,
},
})
})
@@ -1553,10 +1554,14 @@ export const evaluationRouter = router({
})
if (existing) return existing
// Get active evaluation form for this stage
const form = await ctx.prisma.evaluationForm.findFirst({
where: { roundId: input.roundId, isActive: true },
// Fetch project's competition category for category-aware form lookup
const project = await ctx.prisma.project.findUniqueOrThrow({
where: { id: assignment.projectId },
select: { competitionCategory: true },
})
// Get active evaluation form for this stage (category-aware)
const form = await findActiveForm(ctx.prisma, input.roundId, project.competitionCategory)
if (!form) {
throw new TRPCError({
code: 'BAD_REQUEST',
@@ -1577,11 +1582,9 @@ export const evaluationRouter = router({
* Get the active evaluation form for a stage
*/
getStageForm: protectedProcedure
.input(z.object({ roundId: z.string() }))
.input(z.object({ roundId: z.string(), category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).nullish() }))
.query(async ({ ctx, input }) => {
const form = await ctx.prisma.evaluationForm.findFirst({
where: { roundId: input.roundId, isActive: true },
})
const form = await findActiveForm(ctx.prisma, input.roundId, input.category)
if (!form) {
return null
@@ -1846,7 +1849,7 @@ export const evaluationRouter = router({
const assignments = await ctx.prisma.assignment.findMany({
where: { userId: input.userId },
include: {
project: { select: { id: true, title: true } },
project: { select: { id: true, title: true, competitionCategory: true } },
round: { select: { id: true, name: true, roundType: true, sortOrder: true } },
evaluation: {
select: {

View File

@@ -103,14 +103,23 @@ export const exportRouter = router({
projectScores: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Fetch evaluation form to get criteria labels
const evalForm = await ctx.prisma.evaluationForm.findFirst({
// Fetch all active evaluation forms for this round (shared + category-specific)
const activeForms = await ctx.prisma.evaluationForm.findMany({
where: { roundId: input.roundId, isActive: true },
select: { criteriaJson: true },
})
const criteria = (evalForm?.criteriaJson as Array<{
id: string; label: string; type?: string
}> | null) ?? []
// Merge criteria across all forms, deduplicating by criterion id
const seenCriterionIds = new Set<string>()
const criteria: Array<{ id: string; label: string; type?: string }> = []
for (const f of activeForms) {
const fc = (f.criteriaJson as Array<{ id: string; label: string; type?: string }> | null) ?? []
for (const c of fc) {
if (!seenCriterionIds.has(c.id)) {
seenCriterionIds.add(c.id)
criteria.push(c)
}
}
}
const numericCriteria = criteria.filter((c) => !c.type || c.type === 'numeric')
const projects = await ctx.prisma.project.findMany({
@@ -632,12 +641,24 @@ export const exportRouter = router({
// Criteria breakdown
if (includeSection('criteriaBreakdown')) {
const form = await ctx.prisma.evaluationForm.findFirst({
// Fetch all active forms (shared + category-specific) and merge criteria
const allForms = await ctx.prisma.evaluationForm.findMany({
where: { roundId: input.roundId, isActive: true },
})
if (form?.criteriaJson) {
const criteria = form.criteriaJson as Array<{ id: string; label: string }>
const seenIds = new Set<string>()
const allCriteria: Array<{ id: string; label: string }> = []
for (const f of allForms) {
const fc = (f.criteriaJson as Array<{ id: string; label: string }> | null) ?? []
for (const c of fc) {
if (!seenIds.has(c.id)) {
seenIds.add(c.id)
allCriteria.push(c)
}
}
}
if (allCriteria.length > 0) {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
@@ -646,7 +667,7 @@ export const exportRouter = router({
select: { criterionScoresJson: true },
})
result.criteriaBreakdown = criteria.map((c) => {
result.criteriaBreakdown = allCriteria.map((c) => {
const scores: number[] = []
evaluations.forEach((e) => {
const cs = e.criterionScoresJson as Record<string, number> | null

View File

@@ -22,7 +22,7 @@ export const fileRouter = router({
})
)
.query(async ({ ctx, input }) => {
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role)
if (!isAdminOrObserver) {
const file = await ctx.prisma.projectFile.findFirst({
@@ -307,7 +307,7 @@ export const fileRouter = router({
roundId: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role)
if (!isAdminOrObserver) {
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
@@ -376,7 +376,7 @@ export const fileRouter = router({
roundId: z.string(),
}))
.query(async ({ ctx, input }) => {
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role)
if (!isAdminOrObserver) {
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
@@ -496,7 +496,7 @@ export const fileRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role)
if (!isAdminOrObserver) {
// Check user has access to the project (assigned or team member)
@@ -680,7 +680,7 @@ export const fileRouter = router({
})
)
.query(async ({ ctx, input }) => {
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'].includes(ctx.user.role)
const isAdminOrObserver = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER', 'AWARD_MASTER'].includes(ctx.user.role)
if (!isAdminOrObserver) {
const [assignment, mentorAssignment, teamMembership] = await Promise.all([

View File

@@ -86,9 +86,61 @@ export const messageRouter = router({
if (!isScheduled && input.deliveryChannels.includes('EMAIL')) {
const users = await ctx.prisma.user.findMany({
where: { id: { in: recipientUserIds } },
select: { id: true, name: true, email: true, passwordHash: true, inviteToken: true },
select: {
id: true, name: true, email: true, passwordHash: true, inviteToken: true,
teamMemberships: {
select: { project: { select: { title: true } } },
take: 1,
},
},
})
// Fetch round & program context for template variable substitution
let roundName = ''
let programName = ''
let deadline = ''
if (effectiveRoundIds.length > 0) {
const rounds = await ctx.prisma.round.findMany({
where: { id: { in: effectiveRoundIds } },
select: {
name: true,
windowCloseAt: true,
competition: {
select: {
program: { select: { name: true } },
},
},
},
})
if (rounds.length > 0) {
roundName = rounds.map((r) => r.name).join(', ')
programName = rounds[0].competition?.program?.name ?? ''
// Use the earliest upcoming deadline across selected rounds
const deadlines = rounds
.map((r) => r.windowCloseAt)
.filter((d): d is Date => d !== null)
.sort((a, b) => a.getTime() - b.getTime())
if (deadlines.length > 0) {
deadline = deadlines[0].toLocaleDateString('en-GB', {
day: 'numeric', month: 'long', year: 'numeric',
})
}
}
}
/** Substitute template variables in a string for a specific user */
function substituteVariables(
text: string,
user: { name: string | null; teamMemberships: { project: { title: string } }[] }
): string {
return text
.replace(/\{\{userName\}\}/g, user.name || '')
.replace(/\{\{projectName\}\}/g, user.teamMemberships[0]?.project?.title || '')
.replace(/\{\{roundName\}\}/g, roundName)
.replace(/\{\{programName\}\}/g, programName)
.replace(/\{\{deadline\}\}/g, deadline)
}
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
function getLinkUrl(user: { id: string; passwordHash: string | null; inviteToken: string | null }): string | undefined {
@@ -117,8 +169,8 @@ export const messageRouter = router({
userId: user.id,
context: {
name: user.name || undefined,
title: input.subject,
message: input.body,
title: substituteVariables(input.subject, user),
message: substituteVariables(input.body, user),
linkUrl: getLinkUrl(user),
},
}))
@@ -651,13 +703,24 @@ export const messageRouter = router({
sendTest: adminProcedure
.input(z.object({ subject: z.string(), body: z.string() }))
.mutation(async ({ ctx, input }) => {
const userName = ctx.user.name || ''
/** Substitute template variables with admin name + placeholder values for test emails */
function substituteTestVariables(text: string): string {
return text
.replace(/\{\{userName\}\}/g, userName)
.replace(/\{\{projectName\}\}/g, '[Project Name]')
.replace(/\{\{roundName\}\}/g, '[Round Name]')
.replace(/\{\{programName\}\}/g, '[Program Name]')
.replace(/\{\{deadline\}\}/g, '[Deadline]')
}
await sendStyledNotificationEmail(
ctx.user.email,
ctx.user.name || '',
userName,
'MESSAGE',
{
title: input.subject,
message: input.body,
title: substituteTestVariables(input.subject),
message: substituteTestVariables(input.body),
linkUrl: '/admin/messages',
}
)

View File

@@ -443,17 +443,24 @@ export const rankingRouter = router({
roundEvaluationScores: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Find the boolean criterion ID from the EvaluationForm (not round configJson)
const evalForm = await ctx.prisma.evaluationForm.findFirst({
// Find the boolean criterion ID across all active forms (shared + category-specific)
const activeForms = await ctx.prisma.evaluationForm.findMany({
where: { roundId: input.roundId, isActive: true },
select: { criteriaJson: true },
})
const formCriteria = (evalForm?.criteriaJson as Array<{
id: string; label: string; type?: string
}> | null) ?? []
const boolCriterionId = formCriteria.find(
(c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
)?.id ?? null
let boolCriterionId: string | null = null
for (const f of activeForms) {
const fc = (f.criteriaJson as Array<{
id: string; label: string; type?: string
}> | null) ?? []
const found = fc.find(
(c) => c.type === 'boolean' && c.label?.toLowerCase().includes('move to the next stage'),
)
if (found) {
boolCriterionId = found.id
break
}
}
const assignments = await ctx.prisma.assignment.findMany({
where: {

View File

@@ -1,10 +1,11 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { router, protectedProcedure, adminProcedure, awardMasterProcedure } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url'
import { logAudit } from '../utils/audit'
import { processEligibilityJob } from '../services/award-eligibility-job'
import { resolveAwardWinner } from '../services/award-winner-resolver'
import { getAwardSelectionNotificationTemplate } from '@/lib/email'
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
import { sendBatchNotifications } from '../services/notification-sender'
@@ -652,6 +653,117 @@ export const specialAwardRouter = router({
}
}),
/**
* Enhanced award detail for Award Master — includes project scores and chair vote visibility
*/
getMyAwardDetailEnhanced: awardMasterProcedure
.input(z.object({ awardId: z.string() }))
.query(async ({ ctx, input }) => {
const juror = await ctx.prisma.awardJuror.findUnique({
where: {
awardId_userId: { awardId: input.awardId, userId: ctx.user.id },
},
})
if (!juror) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not assigned to this award' })
}
const [award, eligibleProjects, myVotes, allJurors] = await Promise.all([
ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
include: {
competition: { select: { id: true, name: true } },
},
}),
ctx.prisma.awardEligibility.findMany({
where: { awardId: input.awardId, eligible: true },
include: {
project: {
select: {
id: true, title: true, teamName: true, description: true,
competitionCategory: true, country: true, tags: true,
},
},
},
}),
ctx.prisma.awardVote.findMany({
where: { awardId: input.awardId, userId: ctx.user.id },
}),
ctx.prisma.awardJuror.findMany({
where: { awardId: input.awardId },
select: { userId: true, isChair: true, user: { select: { name: true } } },
}),
])
// Fetch evaluation scores for eligible projects
const projectIds = eligibleProjects.map((e) => e.project.id)
const projectScores: Record<string, { avg: number; count: number }> = {}
if (award.evaluationRoundId) {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: {
roundId: award.evaluationRoundId,
projectId: { in: projectIds },
},
},
select: {
globalScore: true,
assignment: { select: { projectId: true } },
},
})
const scoreMap = new Map<string, number[]>()
for (const ev of evaluations) {
if (ev.globalScore !== null) {
const pid = ev.assignment.projectId
if (!scoreMap.has(pid)) scoreMap.set(pid, [])
scoreMap.get(pid)!.push(ev.globalScore)
}
}
for (const [pid, scores] of scoreMap) {
projectScores[pid] = {
avg: scores.reduce((a, b) => a + b, 0) / scores.length,
count: scores.length,
}
}
}
// Chair sees other votes
const isSolo = allJurors.length === 1
const isChair = juror.isChair || isSolo
let otherVotes: Array<{ userId: string; userName: string | null; projectId: string; justification: string | null }> = []
if (isChair && !isSolo) {
const votes = await ctx.prisma.awardVote.findMany({
where: { awardId: input.awardId, userId: { not: ctx.user.id } },
select: {
userId: true, projectId: true, justification: true,
user: { select: { name: true } },
},
})
otherVotes = votes.map((v) => ({
userId: v.userId,
userName: v.user.name,
projectId: v.projectId,
justification: v.justification,
}))
}
return {
award,
projects: eligibleProjects.map((e) => ({
...e.project,
evaluationScore: projectScores[e.project.id] ?? null,
})),
myVotes,
isChair,
otherVotes,
totalJurors: allJurors.length,
jurors: allJurors.map((j) => ({ userId: j.userId, name: j.user.name, isChair: j.isChair })),
}
}),
// ─── Voting ─────────────────────────────────────────────────────────────
/**
@@ -731,6 +843,55 @@ export const specialAwardRouter = router({
return { submitted: input.votes.length }
}),
/**
* Submit award master vote with optional justification (PICK_WINNER only)
*/
submitAwardMasterVote: awardMasterProcedure
.input(z.object({
awardId: z.string(),
projectId: z.string(),
justification: z.string().max(2000).optional(),
}))
.mutation(async ({ ctx, input }) => {
const juror = await ctx.prisma.awardJuror.findUnique({
where: { awardId_userId: { awardId: input.awardId, userId: ctx.user.id } },
})
if (!juror) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Not assigned to this award' })
}
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
})
if (award.status !== 'VOTING_OPEN') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Voting is not open' })
}
await ctx.prisma.$transaction([
ctx.prisma.awardVote.deleteMany({
where: { awardId: input.awardId, userId: ctx.user.id },
}),
ctx.prisma.awardVote.create({
data: {
awardId: input.awardId,
userId: ctx.user.id,
projectId: input.projectId,
justification: input.justification || null,
},
}),
])
await logAudit({
userId: ctx.user.id,
action: 'CREATE',
entityType: 'AwardVote',
entityId: input.awardId,
detailsJson: { awardId: input.awardId, projectId: input.projectId, mode: 'AWARD_MASTER_PICK' },
})
return { submitted: true }
}),
// ─── Results ────────────────────────────────────────────────────────────
/**
@@ -842,6 +1003,106 @@ export const specialAwardRouter = router({
return award
}),
/**
* Chair confirms the winner — resolves tiebreaks, sets winner, closes the award
*/
confirmWinner: awardMasterProcedure
.input(z.object({ awardId: z.string() }))
.mutation(async ({ ctx, input }) => {
const allJurors = await ctx.prisma.awardJuror.findMany({
where: { awardId: input.awardId },
select: { userId: true, isChair: true },
})
const myJuror = allJurors.find((j) => j.userId === ctx.user.id)
if (!myJuror) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Not assigned to this award' })
}
const isSolo = allJurors.length === 1
if (!myJuror.isChair && !isSolo) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the chair can confirm the winner' })
}
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
})
if (award.status !== 'VOTING_OPEN') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Award must be in VOTING_OPEN status' })
}
const chairVote = await ctx.prisma.awardVote.findFirst({
where: { awardId: input.awardId, userId: ctx.user.id },
})
if (!chairVote) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'You must vote before confirming' })
}
const allVotes = await ctx.prisma.awardVote.findMany({
where: { awardId: input.awardId },
select: { projectId: true, userId: true },
})
const winnerId = resolveAwardWinner(allVotes, ctx.user.id)
await ctx.prisma.specialAward.update({
where: { id: input.awardId },
data: {
winnerProjectId: winnerId,
status: 'CLOSED',
winnerOverridden: false,
winnerOverriddenBy: null,
},
})
await logAudit({
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'SpecialAward',
entityId: input.awardId,
detailsJson: {
action: 'CONFIRM_WINNER',
winnerId,
totalVotes: allVotes.length,
confirmedBy: ctx.user.id,
},
})
return { winnerId, closed: true }
}),
/**
* Admin: set/unset chair status for an award juror (only one chair per award)
*/
setChair: adminProcedure
.input(z.object({
awardId: z.string(),
userId: z.string(),
isChair: z.boolean(),
}))
.mutation(async ({ ctx, input }) => {
if (input.isChair) {
await ctx.prisma.awardJuror.updateMany({
where: { awardId: input.awardId, isChair: true },
data: { isChair: false },
})
}
await ctx.prisma.awardJuror.update({
where: { awardId_userId: { awardId: input.awardId, userId: input.userId } },
data: { isChair: input.isChair },
})
await logAudit({
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'AwardJuror',
entityId: `${input.awardId}:${input.userId}`,
detailsJson: { action: 'SET_CHAIR', isChair: input.isChair },
})
return { success: true }
}),
// ─── Round-Scoped Eligibility & Shortlists ──────────────────────────────
/**

View File

@@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server'
import type { Prisma } from '@prisma/client'
import { UserRole } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
import { sendInvitationEmail, sendMagicLinkEmail, sendPasswordResetEmail } from '@/lib/email'
import { sendInvitationEmail, sendJuryInvitationEmail, sendMagicLinkEmail, sendPasswordResetEmail } from '@/lib/email'
import { hashPassword, validatePassword } from '@/lib/password'
import { attachAvatarUrls, getUserAvatarUrl } from '@/server/utils/avatar-url'
import { logAudit } from '@/server/utils/audit'
@@ -507,10 +507,18 @@ export const userRouter = router({
})
}
// Generate invite token upfront so the user can accept even if the
// subsequent invitation email fails to send. Re-sending from the
// members table will just overwrite the token.
const inviteToken = generateInviteToken()
const expiryHours = await getInviteExpiryHours(ctx.prisma)
const user = await ctx.prisma.user.create({
data: {
...input,
status: 'INVITED',
inviteToken,
inviteTokenExpiresAt: new Date(Date.now() + expiryHours * 60 * 60 * 1000),
},
})
@@ -979,7 +987,13 @@ export const userRouter = router({
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
// Use jury-specific template for jury members
if (user.role === 'JURY_MEMBER') {
await sendJuryInvitationEmail(user.email, user.name, inviteUrl, 'the evaluation round')
} else {
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
}
await ctx.prisma.notificationLog.create({
data: {
@@ -1121,8 +1135,23 @@ export const userRouter = router({
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
// Send invitation email
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
// Send invitation email — use jury-specific template for jury members
if (user.role === 'JURY_MEMBER') {
// Try to resolve a round name for the jury invitation email
let roundName = 'the evaluation round'
if (input.juryGroupId) {
const juryGroup = await ctx.prisma.juryGroup.findUnique({
where: { id: input.juryGroupId },
select: { rounds: { select: { name: true }, take: 1, orderBy: { sortOrder: 'asc' } } },
})
if (juryGroup?.rounds[0]?.name) {
roundName = juryGroup.rounds[0].name
}
}
await sendJuryInvitationEmail(user.email, user.name, inviteUrl, roundName)
} else {
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
}
// Log notification
await ctx.prisma.notificationLog.create({
@@ -1187,7 +1216,13 @@ export const userRouter = router({
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
// Use jury-specific template for jury members
if (user.role === 'JURY_MEMBER') {
await sendJuryInvitationEmail(user.email, user.name, inviteUrl, 'the evaluation round')
} else {
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
}
await ctx.prisma.notificationLog.create({
data: {

View File

@@ -16,6 +16,7 @@ import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
import { classifyAIError, createParseError, logAIError } from './ai-errors'
import { sanitizeText } from './anonymization'
import type { PrismaClient, Prisma } from '@prisma/client'
import { findActiveForm } from '@/server/utils/evaluation-form-lookup'
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -290,6 +291,7 @@ export async function generateSummary({
select: {
id: true,
title: true,
competitionCategory: true,
},
})
@@ -329,11 +331,8 @@ export async function generateSummary({
})
}
// Get evaluation form criteria for this round
const form = await prisma.evaluationForm.findFirst({
where: { roundId, isActive: true },
select: { criteriaJson: true },
})
// Get evaluation form criteria for this round (category-aware)
const form = await findActiveForm(prisma, roundId, project.competitionCategory)
const criteria: CriterionDef[] = form?.criteriaJson
? (form.criteriaJson as unknown as CriterionDef[])

View File

@@ -18,6 +18,7 @@
import { getOpenAI, getConfiguredModel, buildCompletionParams } from '@/lib/openai'
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
import { classifyAIError, logAIError } from './ai-errors'
import { findActiveForm } from '@/server/utils/evaluation-form-lookup'
import { sanitizeUserInput } from '@/server/services/ai-prompt-guard'
import { TRPCError } from '@trpc/server'
import type { CompetitionCategory, PrismaClient } from '@prisma/client'
@@ -625,16 +626,13 @@ async function fetchCategoryProjects(
roundId: string,
prisma: PrismaClient,
): Promise<CategoryProjectData> {
// Fetch the round config and evaluation form in parallel
// Fetch the round config and evaluation form in parallel (category-aware)
const [round, evalForm] = await Promise.all([
prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { configJson: true },
}),
prisma.evaluationForm.findFirst({
where: { roundId, isActive: true },
select: { criteriaJson: true },
}),
findActiveForm(prisma, roundId, category),
])
const roundConfig = round.configJson as Record<string, unknown> | null

View File

@@ -0,0 +1,37 @@
/**
* Resolve the winner of a PICK_WINNER award given all votes and the chair's userId.
* Logic: count votes per project. If one project has the most, it wins.
* If tied, the chair's pick wins among the tied projects.
*/
export function resolveAwardWinner(
votes: Array<{ userId: string; projectId: string }>,
chairUserId: string
): string {
if (votes.length === 0) {
throw new Error('Cannot resolve winner with no votes')
}
// Tally votes per project
const tally = new Map<string, number>()
for (const v of votes) {
tally.set(v.projectId, (tally.get(v.projectId) || 0) + 1)
}
const maxVotes = Math.max(...tally.values())
const topProjects = [...tally.entries()]
.filter(([, count]) => count === maxVotes)
.map(([pid]) => pid)
if (topProjects.length === 1) {
return topProjects[0]
}
// Tie: chair's pick wins if among tied projects
const chairVote = votes.find((v) => v.userId === chairUserId)
if (chairVote && topProjects.includes(chairVote.projectId)) {
return chairVote.projectId
}
// Chair voted for a non-tied project — pick alphabetically for stability
return topProjects.sort()[0]
}

View File

@@ -826,76 +826,36 @@ export async function checkRequirementsAndTransition(
prisma: PrismaClient,
): Promise<{ transitioned: boolean; newState?: string }> {
try {
// Get all required FileRequirements for this round (legacy model)
// Get all required FileRequirements for this round
// Note: only FileRequirement (admin-managed via UI) is checked.
// SubmissionFileRequirement (on SubmissionWindow) has no admin UI and is not checked.
const requirements = await prisma.fileRequirement.findMany({
where: { roundId, isRequired: true },
select: { id: true },
})
// Also check SubmissionFileRequirement via the round's submissionWindow
const round = await prisma.round.findUnique({
where: { id: roundId },
select: { submissionWindowId: true },
})
let submissionRequirements: Array<{ id: string }> = []
if (round?.submissionWindowId) {
submissionRequirements = await prisma.submissionFileRequirement.findMany({
where: { submissionWindowId: round.submissionWindowId, required: true },
select: { id: true },
})
}
// If the round has no file requirements at all, nothing to check
if (requirements.length === 0 && submissionRequirements.length === 0) {
// If the round has no file requirements, nothing to check
if (requirements.length === 0) {
return { transitioned: false }
}
// Check which legacy requirements this project has satisfied
let legacyAllMet = true
if (requirements.length > 0) {
const fulfilledFiles = await prisma.projectFile.findMany({
where: {
projectId,
roundId,
requirementId: { in: requirements.map((r: { id: string }) => r.id) },
},
select: { requirementId: true },
})
// Check which requirements this project has satisfied
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)
)
const fulfilledIds = new Set(
fulfilledFiles
.map((f: { requirementId: string | null }) => f.requirementId)
.filter(Boolean)
)
legacyAllMet = requirements.every((r: { id: string }) => fulfilledIds.has(r.id))
}
// Check which SubmissionFileRequirements this project has satisfied
let submissionAllMet = true
if (submissionRequirements.length > 0) {
const fulfilledSubmissionFiles = await prisma.projectFile.findMany({
where: {
projectId,
submissionFileRequirementId: { in: submissionRequirements.map((r: { id: string }) => r.id) },
},
select: { submissionFileRequirementId: true },
})
const fulfilledSubIds = new Set(
fulfilledSubmissionFiles
.map((f: { submissionFileRequirementId: string | null }) => f.submissionFileRequirementId)
.filter(Boolean)
)
submissionAllMet = submissionRequirements.every((r: { id: string }) => fulfilledSubIds.has(r.id))
}
// All requirements from both models must be met
const allMet = legacyAllMet && submissionAllMet
if (!allMet) {
if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) {
return { transitioned: false }
}
@@ -919,7 +879,7 @@ export async function checkRequirementsAndTransition(
const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
if (result.success) {
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to COMPLETED in round ${roundId} (all ${requirements.length + submissionRequirements.length} requirements met)`)
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to COMPLETED in round ${roundId} (all ${requirements.length} requirements met)`)
return { transitioned: true, newState: 'COMPLETED' }
}
@@ -944,32 +904,20 @@ export async function batchCheckRequirementsAndTransition(
if (projectIds.length === 0) return { transitionedCount: 0, projectIds: [] }
// Pre-load all requirements for this round in batch (avoids per-project queries)
const [requirements, round] = await Promise.all([
prisma.fileRequirement.findMany({
where: { roundId, isRequired: true },
select: { id: true },
}),
prisma.round.findUnique({
where: { id: roundId },
select: { submissionWindowId: true },
}),
])
// Note: only FileRequirement (admin-managed via UI) is checked.
// SubmissionFileRequirement (on SubmissionWindow) has no admin UI and is not checked.
const requirements = await prisma.fileRequirement.findMany({
where: { roundId, isRequired: true },
select: { id: true },
})
let submissionRequirements: Array<{ id: string }> = []
if (round?.submissionWindowId) {
submissionRequirements = await prisma.submissionFileRequirement.findMany({
where: { submissionWindowId: round.submissionWindowId, required: true },
select: { id: true },
})
}
// If no requirements at all, nothing to check
if (requirements.length === 0 && submissionRequirements.length === 0) {
// If no requirements, nothing to check
if (requirements.length === 0) {
return { transitionedCount: 0, projectIds: [] }
}
// Pre-load all project files and current states in batch
type FileRow = { projectId: string; requirementId: string | null; submissionFileRequirementId: string | null }
type FileRow = { projectId: string; requirementId: string | null }
type StateRow = { projectId: string; state: string }
const [allFiles, allStates] = await Promise.all([
@@ -978,7 +926,7 @@ export async function batchCheckRequirementsAndTransition(
projectId: { in: projectIds },
roundId,
},
select: { projectId: true, requirementId: true, submissionFileRequirementId: true },
select: { projectId: true, requirementId: true },
}) as Promise<FileRow[]>,
prisma.projectRoundState.findMany({
where: { roundId, projectId: { in: projectIds } },
@@ -1004,18 +952,8 @@ export async function batchCheckRequirementsAndTransition(
if (!currentState || !eligibleStates.includes(currentState)) continue
const files = filesByProject.get(projectId) ?? []
// Check legacy requirements
if (requirements.length > 0) {
const fulfilledIds = new Set(files.map((f) => f.requirementId).filter(Boolean))
if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) continue
}
// Check submission requirements
if (submissionRequirements.length > 0) {
const fulfilledSubIds = new Set(files.map((f) => f.submissionFileRequirementId).filter(Boolean))
if (!submissionRequirements.every((r: { id: string }) => fulfilledSubIds.has(r.id))) continue
}
const fulfilledIds = new Set(files.map((f) => f.requirementId).filter(Boolean))
if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) continue
toTransition.push(projectId)
}

View File

@@ -0,0 +1,26 @@
import type { PrismaClient, CompetitionCategory, EvaluationForm } from '@prisma/client'
/**
* Find the active EvaluationForm for a round, with category-aware resolution.
*
* Resolution order:
* 1. If `category` is provided, try the category-specific active form first.
* 2. Fall back to the shared form (category = null).
* 3. If no `category` provided, return the shared form directly.
*/
export async function findActiveForm(
prisma: PrismaClient | Pick<PrismaClient, 'evaluationForm'>,
roundId: string,
category?: CompetitionCategory | null,
): Promise<EvaluationForm | null> {
if (category) {
const specific = await prisma.evaluationForm.findFirst({
where: { roundId, isActive: true, category },
})
if (specific) return specific
}
// Fallback to shared form (category = null)
return prisma.evaluationForm.findFirst({
where: { roundId, isActive: true, category: null },
})
}

View File

@@ -93,6 +93,7 @@ export const EvaluationConfigSchema = z.object({
requiredReviewsPerProject: z.number().int().positive().default(3),
scoringMode: z.enum(['criteria', 'global', 'binary']).default('criteria'),
perCategoryCriteria: z.boolean().default(false),
requireFeedback: z.boolean().default(true),
feedbackMinLength: z.number().int().nonnegative().default(0),
requireAllCriteriaScored: z.boolean().default(true),

View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest'
import { resolveAwardWinner } from '@/server/services/award-winner-resolver'
describe('resolveAwardWinner', () => {
it('returns the sole voted project when all agree', () => {
const votes = [
{ userId: 'u1', projectId: 'p1' },
{ userId: 'u2', projectId: 'p1' },
]
expect(resolveAwardWinner(votes, 'u1')).toBe('p1')
})
it('returns the majority winner when no tie', () => {
const votes = [
{ userId: 'u1', projectId: 'p1' },
{ userId: 'u2', projectId: 'p2' },
{ userId: 'u3', projectId: 'p1' },
]
expect(resolveAwardWinner(votes, 'u1')).toBe('p1')
})
it('uses chair vote as tiebreaker', () => {
const votes = [
{ userId: 'chair', projectId: 'p2' },
{ userId: 'u2', projectId: 'p1' },
]
expect(resolveAwardWinner(votes, 'chair')).toBe('p2')
})
it('returns chair pick when tied and chair voted for one of the tied projects', () => {
const votes = [
{ userId: 'chair', projectId: 'p3' },
{ userId: 'u2', projectId: 'p1' },
{ userId: 'u3', projectId: 'p2' },
]
expect(resolveAwardWinner(votes, 'chair')).toBe('p3')
})
it('returns sole vote for solo sponsor', () => {
const votes = [{ userId: 'sponsor', projectId: 'p1' }]
expect(resolveAwardWinner(votes, 'sponsor')).toBe('p1')
})
it('throws if no votes', () => {
expect(() => resolveAwardWinner([], 'chair')).toThrow()
})
})