feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s

- processRoundClose EVALUATION uses ranking scores + advanceMode config
  (threshold vs count) to auto-set proposedOutcome instead of defaulting all to PASSED
- Advancement emails generate invite tokens for passwordless users with
  "Create Your Account" CTA; rejection emails have no link
- Finalization UI shows account stats (invite vs dashboard link counts)
- Fixed getFinalizationSummary ranking query (was using non-existent rankingsJson)
- New award pool notification system: getAwardSelectionNotificationTemplate email,
  notifyEligibleProjects mutation with invite token generation,
  "Notify Pool" button on award detail page with custom message dialog

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 19:14:41 +01:00
parent 7735f3ecdf
commit cfee3bc8a9
48 changed files with 5294 additions and 676 deletions

3
.gitignore vendored
View File

@@ -58,3 +58,6 @@ build-output.txt
# Misc
*.log
.vercel
# Private keys and secrets
private/

View File

@@ -8,7 +8,7 @@ services:
image: postgres:16-alpine
container_name: mopc-postgres-dev
ports:
- "5432:5432"
- "5433:5432"
environment:
- POSTGRES_USER=${POSTGRES_USER:-mopc}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-devpassword}

View File

@@ -37,5 +37,26 @@ fi
echo "==> Syncing notification email settings..."
npx tsx prisma/seed-notification-settings.ts || echo "WARNING: Notification settings sync failed."
# Sync team lead links only if there are unlinked submitters
UNLINKED_COUNT=$(node -e "
const { PrismaClient } = require('@prisma/client');
const p = new PrismaClient();
p.\$queryRaw\`
SELECT COUNT(*)::int AS c FROM \"Project\" p
WHERE p.\"submittedByUserId\" IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM \"TeamMember\" tm
WHERE tm.\"projectId\" = p.id AND tm.\"userId\" = p.\"submittedByUserId\"
)
\`.then(r => { console.log(r[0].c); p.\$disconnect(); }).catch(() => { console.log('0'); p.\$disconnect(); });
" 2>/dev/null || echo "0")
if [ "$UNLINKED_COUNT" != "0" ]; then
echo "==> Syncing ${UNLINKED_COUNT} unlinked team lead links..."
npx tsx prisma/seed-team-leads.ts || echo "WARNING: Team lead sync failed."
else
echo "==> Team lead links already synced, skipping."
fi
echo "==> Starting application..."
exec node server.js

View File

@@ -2193,6 +2193,11 @@ model Round {
submissionWindowId String?
specialAwardId String?
// Finalization
gracePeriodEndsAt DateTime?
finalizedAt DateTime?
finalizedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2237,13 +2242,14 @@ model Round {
}
model ProjectRoundState {
id String @id @default(cuid())
projectId String
roundId String
state ProjectRoundStateValue @default(PENDING)
enteredAt DateTime @default(now())
exitedAt DateTime?
metadataJson Json? @db.JsonB
id String @id @default(cuid())
projectId String
roundId String
state ProjectRoundStateValue @default(PENDING)
proposedOutcome ProjectRoundStateValue?
enteredAt DateTime @default(now())
exitedAt DateTime?
metadataJson Json? @db.JsonB
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -559,7 +559,7 @@ async function main() {
})
// Create project
await prisma.project.create({
const createdProject = await prisma.project.create({
data: {
programId: program.id,
title: projectName || `Project by ${name}`,
@@ -584,13 +584,24 @@ async function main() {
},
})
// Link submitter as team lead
await prisma.teamMember.upsert({
where: { projectId_userId: { projectId: createdProject.id, userId: user.id } },
update: {},
create: {
projectId: createdProject.id,
userId: user.id,
role: 'LEAD',
},
})
projectCount++
if (projectCount % 50 === 0) {
console.log(` ... ${projectCount} projects created`)
}
}
console.log(` ✓ Created ${projectCount} projects`)
console.log(` ✓ Created ${projectCount} projects (with team lead links)`)
if (skippedNoEmail > 0) {
console.log(` ⚠ Skipped ${skippedNoEmail} rows with no valid email`)
}
@@ -864,6 +875,24 @@ async function main() {
}
console.log(`${rounds.length - 1} advancement rules created`)
// --- Assign all projects to intake round (COMPLETED, since intake is closed) ---
const intakeRound = rounds[0]
const allProjects = await prisma.project.findMany({
where: { programId: program.id },
select: { id: true },
})
if (allProjects.length > 0) {
await prisma.projectRoundState.createMany({
data: allProjects.map((p) => ({
projectId: p.id,
roundId: intakeRound.id,
state: 'COMPLETED' as const,
})),
skipDuplicates: true,
})
console.log(`${allProjects.length} projects assigned to intake round (COMPLETED)`)
}
// --- Round-Submission Visibility (which rounds can see which submission windows) ---
// R2 and R3 can see R1 docs, R5 can see R4 docs
const visibilityLinks = [

View File

@@ -53,6 +53,7 @@ import {
} from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Progress } from '@/components/ui/progress'
import { UserAvatar } from '@/components/shared/user-avatar'
import { AnimatedCard } from '@/components/shared/animated-container'
@@ -91,6 +92,7 @@ import {
AlertCircle,
Layers,
Info,
Mail,
} from 'lucide-react'
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
@@ -155,6 +157,8 @@ export default function AwardDetailPage({
const [activeTab, setActiveTab] = useState('eligibility')
const [addRoundOpen, setAddRoundOpen] = useState(false)
const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string })
const [notifyDialogOpen, setNotifyDialogOpen] = useState(false)
const [notifyCustomMessage, setNotifyCustomMessage] = useState('')
// Pagination for eligibility list
const [eligibilityPage, setEligibilityPage] = useState(1)
@@ -283,6 +287,19 @@ export default function AwardDetailPage({
onError: (err) => toast.error(err.message),
})
const { data: notifyStats } = trpc.specialAward.getNotificationStats.useQuery(
{ awardId },
{ enabled: notifyDialogOpen }
)
const notifyEligible = trpc.specialAward.notifyEligibleProjects.useMutation({
onSuccess: (result) => {
toast.success(`Notified ${result.notified} projects (${result.emailsSent} emails sent${result.emailsFailed ? `, ${result.emailsFailed} failed` : ''})`)
setNotifyDialogOpen(false)
setNotifyCustomMessage('')
},
onError: (err) => toast.error(err.message),
})
const handleStatusChange = async (
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
) => {
@@ -468,13 +485,72 @@ export default function AwardDetailPage({
</Button>
)}
{award.status === 'NOMINATIONS_OPEN' && (
<Button
onClick={() => handleStatusChange('VOTING_OPEN')}
disabled={updateStatus.isPending}
>
<Play className="mr-2 h-4 w-4" />
Open Voting
</Button>
<>
<Dialog open={notifyDialogOpen} onOpenChange={setNotifyDialogOpen}>
<DialogTrigger asChild>
<Button variant="outline" disabled={award.eligibleCount === 0}>
<Mail className="mr-2 h-4 w-4" />
Notify Pool
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Notify Eligible Projects</DialogTitle>
<DialogDescription>
Send &quot;Selected for {award.name}&quot; emails to all {award.eligibleCount} eligible projects.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{notifyStats && (
<div className="flex flex-wrap gap-2">
{notifyStats.needsInvite > 0 && (
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700">
{notifyStats.needsInvite} will receive Create Account link
</Badge>
)}
{notifyStats.hasAccount > 0 && (
<Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700">
{notifyStats.hasAccount} will receive Dashboard link
</Badge>
)}
</div>
)}
<div className="space-y-2">
<Label>Custom message (optional)</Label>
<Textarea
placeholder="Add a personal message to include in the email..."
value={notifyCustomMessage}
onChange={(e) => setNotifyCustomMessage(e.target.value)}
rows={4}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setNotifyDialogOpen(false)}>Cancel</Button>
<Button
onClick={() => notifyEligible.mutate({
awardId,
customMessage: notifyCustomMessage.trim() || undefined,
})}
disabled={notifyEligible.isPending}
>
{notifyEligible.isPending ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Sending...</>
) : (
<><Mail className="mr-2 h-4 w-4" />Send {award.eligibleCount} Emails</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Button
onClick={() => handleStatusChange('VOTING_OPEN')}
disabled={updateStatus.isPending}
>
<Play className="mr-2 h-4 w-4" />
Open Voting
</Button>
</>
)}
{award.status === 'VOTING_OPEN' && (
<Button

View File

@@ -64,12 +64,13 @@ import {
import { toast } from 'sonner'
import { formatDate } from '@/lib/utils'
type RecipientType = 'ALL' | 'ROLE' | 'ROUND_JURY' | 'PROGRAM_TEAM' | 'USER'
type RecipientType = 'ALL' | 'ROLE' | 'ROUND_JURY' | 'ROUND_APPLICANTS' | 'PROGRAM_TEAM' | 'USER'
const RECIPIENT_TYPE_OPTIONS: { value: RecipientType; label: string }[] = [
{ value: 'ALL', label: 'All Users' },
{ value: 'ROLE', label: 'By Role' },
{ value: 'ROUND_JURY', label: 'Round Jury' },
{ value: 'ROUND_APPLICANTS', label: 'Round Applicants' },
{ value: 'PROGRAM_TEAM', label: 'Program Team' },
{ value: 'USER', label: 'Specific User' },
]
@@ -110,6 +111,16 @@ export default function MessagesPage() {
{ refetchInterval: 30_000 }
)
const emailPreview = trpc.message.previewEmail.useQuery(
{ subject, body },
{ enabled: showPreview && subject.length > 0 && body.length > 0 }
)
const sendTestMutation = trpc.message.sendTest.useMutation({
onSuccess: (data) => toast.success(`Test email sent to ${data.to}`),
onError: (e) => toast.error(e.message),
})
const sendMutation = trpc.message.send.useMutation({
onSuccess: (data) => {
const count = (data as Record<string, unknown>)?.recipientCount || ''
@@ -183,6 +194,13 @@ export default function MessagesPage() {
? `Jury of ${stage.program ? `${stage.program.name} - ` : ''}${stage.name}`
: 'Stage Jury'
}
case 'ROUND_APPLICANTS': {
if (!roundId) return 'Round Applicants (none selected)'
const appRound = rounds?.find((r) => r.id === roundId)
return appRound
? `Applicants in ${appRound.program ? `${appRound.program.name} - ` : ''}${appRound.name}`
: 'Round Applicants'
}
case 'PROGRAM_TEAM': {
if (!selectedProgramId) return 'Program Team (none selected)'
const program = (programs as Array<{ id: string; name: string }> | undefined)?.find(
@@ -218,7 +236,7 @@ export default function MessagesPage() {
toast.error('Please select a role')
return
}
if (recipientType === 'ROUND_JURY' && !roundId) {
if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && !roundId) {
toast.error('Please select a round')
return
}
@@ -333,7 +351,7 @@ export default function MessagesPage() {
</div>
)}
{recipientType === 'ROUND_JURY' && (
{(recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && (
<div className="space-y-2">
<Label>Select Round</Label>
<Select value={roundId} onValueChange={setRoundId}>
@@ -670,9 +688,20 @@ export default function MessagesPage() {
<p className="text-sm font-medium mt-1">{subject}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Message</p>
<div className="mt-1 rounded-lg border bg-muted/30 p-4">
<p className="text-sm whitespace-pre-wrap">{body}</p>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Email Preview</p>
<div className="mt-1 rounded-lg border overflow-hidden bg-gray-50">
{emailPreview.data?.html ? (
<iframe
srcDoc={emailPreview.data.html}
sandbox="allow-same-origin"
className="w-full h-[500px] border-0"
title="Email Preview"
/>
) : (
<div className="p-4">
<p className="text-sm whitespace-pre-wrap">{body}</p>
</div>
)}
</div>
</div>
<div>
@@ -699,7 +728,21 @@ export default function MessagesPage() {
</div>
)}
</div>
<DialogFooter>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => sendTestMutation.mutate({ subject, body })}
disabled={sendTestMutation.isPending}
className="sm:mr-auto"
>
{sendTestMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
Send Test to Me
</Button>
<Button variant="outline" onClick={() => setShowPreview(false)}>
Edit
</Button>

View File

@@ -90,7 +90,7 @@ const updateProjectSchema = z.object({
'SEMIFINALIST',
'FINALIST',
'REJECTED',
]),
]).optional(),
tags: z.array(z.string()),
competitionCategory: z.string().optional(),
oceanIssue: z.string().optional(),
@@ -186,7 +186,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
title: '',
teamName: '',
description: '',
status: 'SUBMITTED',
status: undefined,
tags: [],
competitionCategory: '',
oceanIssue: '',
@@ -221,7 +221,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
const tags = form.watch('tags')
const selectedStatus = form.watch('status')
const previousStatus = (project?.status ?? 'SUBMITTED') as UpdateProjectForm['status']
const statusTriggersNotifications = ['SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(selectedStatus)
const statusTriggersNotifications = !!selectedStatus && ['SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(selectedStatus)
const requiresStatusNotificationConfirmation = Boolean(
project && selectedStatus !== previousStatus && statusTriggersNotifications
)
@@ -439,7 +439,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
>
<FormControl>
<SelectTrigger>
<SelectValue />
<SelectValue placeholder="Select status..." />
</SelectTrigger>
</FormControl>
<SelectContent>

View File

@@ -296,8 +296,8 @@ export default function ProjectsPage() {
const [selectedProgramForTagging, setSelectedProgramForTagging] = useState<string>('')
const [activeTaggingJobId, setActiveTaggingJobId] = useState<string | null>(null)
// Fetch programs and rounds for the AI tagging dialog
const { data: programs } = trpc.program.list.useQuery()
// Fetch programs and rounds for the AI tagging dialog + assign-to-round
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
// Start tagging job mutation
const startTaggingJob = trpc.tag.startTaggingJob.useMutation({

View File

@@ -13,7 +13,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import {
DropdownMenu,
@@ -77,6 +76,7 @@ import {
Trash2,
ArrowRight,
RotateCcw,
ListChecks,
} from 'lucide-react'
import {
Tooltip,
@@ -116,7 +116,11 @@ import { AIRecommendationsDisplay } from '@/components/admin/round/ai-recommenda
import { EvaluationCriteriaEditor } from '@/components/admin/round/evaluation-criteria-editor'
import { COIReviewSection } from '@/components/admin/assignment/coi-review-section'
import { ConfigSectionHeader } from '@/components/admin/rounds/config/config-section-header'
import { NotifyAdvancedButton } from '@/components/admin/round/notify-advanced-button'
import { NotifyRejectedButton } from '@/components/admin/round/notify-rejected-button'
import { BulkInviteButton } from '@/components/admin/round/bulk-invite-button'
import { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card'
import { FinalizationTab } from '@/components/admin/round/finalization-tab'
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -265,6 +269,20 @@ export default function RoundDetailPage() {
}
}, [juryWorkload])
// Auto-select finalization tab when round is closed and not yet finalized
const finalizationAutoSelected = useRef(false)
useEffect(() => {
if (
round &&
!finalizationAutoSelected.current &&
round.status === 'ROUND_CLOSED' &&
!round.finalizedAt
) {
finalizationAutoSelected.current = true
setActiveTab('finalization')
}
}, [round])
// ── Mutations ──────────────────────────────────────────────────────────
const updateMutation = trpc.round.update.useMutation({
onSuccess: () => {
@@ -291,12 +309,12 @@ export default function RoundDetailPage() {
const closeMutation = trpc.roundEngine.close.useMutation({
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)
}
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
toast.success('Round closed — use the Finalization tab to review and advance projects')
setCloseAndAdvance(false)
// Auto-switch to finalization tab
setActiveTab('finalization')
},
onError: (err) => {
setCloseAndAdvance(false)
@@ -308,6 +326,7 @@ export default function RoundDetailPage() {
onSuccess: (data) => {
utils.round.getById.invalidate({ id: roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
const msg = data.pausedRounds?.length
? `Round reopened. Paused: ${data.pausedRounds.join(', ')}`
: 'Round reopened'
@@ -319,6 +338,8 @@ export default function RoundDetailPage() {
const archiveMutation = trpc.roundEngine.archive.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
toast.success('Round archived')
},
onError: (err) => toast.error(err.message),
@@ -495,6 +516,7 @@ export default function RoundDetailPage() {
const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
const hasAwards = roundAwards.length > 0
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
const showFinalization = ['ROUND_CLOSED', 'ROUND_ARCHIVED'].includes(round?.status ?? '')
const poolLink = `/admin/projects?hasAssign=false&round=${roundId}` as Route
@@ -846,6 +868,7 @@ export default function RoundDetailPage() {
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments & Jury', icon: ClipboardList }] : []),
...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []),
...(hasJury && !isEvaluation ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
...(showFinalization ? [{ value: 'finalization', label: 'Finalization', icon: ListChecks }] : []),
{ value: 'config', label: 'Config', icon: Settings },
...(hasAwards ? [{ value: 'awards', label: 'Awards', icon: Trophy }] : []),
].map((tab) => (
@@ -1166,49 +1189,54 @@ export default function RoundDetailPage() {
</div>
</button>
{/* Advance projects (always visible when projects exist) */}
{/* Advance projects — closed rounds go to Finalization tab, active rounds use old dialog */}
{projectCount > 0 && (
<button
onClick={() => (isSimpleAdvance || passedCount > 0)
? setAdvanceDialogOpen(true)
: toast.info('Mark projects as "Passed" first in the Projects tab')}
onClick={() => {
if (showFinalization) {
setActiveTab('finalization')
} else if (isSimpleAdvance || passedCount > 0) {
setAdvanceDialogOpen(true)
} else {
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)
(showFinalization || isSimpleAdvance || passedCount > 0)
? 'border-l-4 border-l-emerald-500 bg-emerald-50/30'
: 'border-dashed opacity-60',
)}
>
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', (isSimpleAdvance || passedCount > 0) ? 'text-emerald-600' : 'text-muted-foreground')} />
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', (showFinalization || 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">
{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'}
{showFinalization
? 'Review and confirm advancement in the Finalization tab'
: 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]">{isSimpleAdvance ? projectCount : passedCount}</Badge>
</button>
)}
{/* Close & Advance (active rounds with passed projects) */}
{status === 'ROUND_ACTIVE' && passedCount > 0 && (
{/* Close & Finalize (active rounds — closes round and opens finalization tab) */}
{status === 'ROUND_ACTIVE' && projectCount > 0 && (
<button
onClick={() => {
setCloseAndAdvance(true)
closeMutation.mutate({ roundId })
}}
onClick={() => 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-sm font-medium">Close & Finalize</p>
<p className="text-xs text-muted-foreground mt-0.5">
Close this round and advance {passedCount} passed project(s) to the next round
Close this round and review outcomes in the Finalization tab
</p>
</div>
</button>
@@ -1289,12 +1317,24 @@ export default function RoundDetailPage() {
</div>
</div>
)}
{/* Notifications Group */}
{projectCount > 0 && (
<div>
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground mb-2">Notifications</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<NotifyAdvancedButton roundId={roundId} />
<NotifyRejectedButton roundId={roundId} />
<BulkInviteButton roundId={roundId} />
</div>
</div>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Advance Projects Dialog */}
<AdvanceProjectsDialog
{/* Advance Projects Dialog — only for active rounds; closed rounds use Finalization tab */}
{!showFinalization && <AdvanceProjectsDialog
open={advanceDialogOpen}
onOpenChange={setAdvanceDialogOpen}
roundId={roundId}
@@ -1309,7 +1349,7 @@ export default function RoundDetailPage() {
roundType: r.roundType,
}))}
currentSortOrder={round?.sortOrder}
/>
/>}
{/* AI Shortlist Confirmation Dialog */}
<AlertDialog open={shortlistDialogOpen} onOpenChange={setShortlistDialogOpen}>
@@ -1435,10 +1475,17 @@ export default function RoundDetailPage() {
{/* ═══════════ PROJECTS TAB ═══════════ */}
<TabsContent value="projects" className="space-y-4">
<ProjectStatesTable competitionId={competitionId} roundId={roundId} onAssignProjects={() => {
setActiveTab('assignments')
setTimeout(() => setPreviewSheetOpen(true), 100)
}} />
<ProjectStatesTable
competitionId={competitionId}
roundId={roundId}
roundStatus={round?.status}
competitionRounds={competition?.rounds}
currentSortOrder={round?.sortOrder}
onAssignProjects={() => {
setActiveTab('assignments')
setTimeout(() => setPreviewSheetOpen(true), 100)
}}
/>
</TabsContent>
{/* ═══════════ FILTERING TAB ═══════════ */}
@@ -2059,6 +2106,13 @@ export default function RoundDetailPage() {
</TabsContent>
)}
{/* ═══════════ FINALIZATION TAB ═══════════ */}
{showFinalization && (
<TabsContent value="finalization" className="space-y-4">
<FinalizationTab roundId={roundId} roundStatus={round.status} />
</TabsContent>
)}
{/* ═══════════ CONFIG TAB ═══════════ */}
<TabsContent value="config" className="space-y-6">
{/* Round Dates */}
@@ -2123,42 +2177,6 @@ export default function RoundDetailPage() {
/>
</CardHeader>
<CardContent className="space-y-0 pt-0">
<div className="flex items-center justify-between p-4 rounded-md">
<div className="space-y-0.5">
<Label htmlFor="notify-on-entry" className="text-sm font-medium">
Notify on round entry
</Label>
<p className="text-xs text-muted-foreground">
Send an automated email to project applicants when their project enters this round
</p>
</div>
<Switch
id="notify-on-entry"
checked={!!config.notifyOnEntry}
onCheckedChange={(checked) => {
handleConfigChange({ ...config, notifyOnEntry: checked })
}}
/>
</div>
<div className="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div className="space-y-0.5">
<Label htmlFor="notify-on-advance" className="text-sm font-medium">
Notify on advance
</Label>
<p className="text-xs text-muted-foreground">
Send an email to project applicants when their project advances from this round to the next
</p>
</div>
<Switch
id="notify-on-advance"
checked={!!config.notifyOnAdvance}
onCheckedChange={(checked) => {
handleConfigChange({ ...config, notifyOnAdvance: checked })
}}
/>
</div>
{(isEvaluation || isFiltering) && (
<div className="border-t mt-2 pt-4 px-4 pb-2 bg-[#053d57]/[0.03] rounded-b-lg -mx-6 -mb-6 p-6">
<Label className="text-sm font-medium">Advancement Targets</Label>

View File

@@ -13,14 +13,14 @@ import {
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { StatusTracker } from '@/components/shared/status-tracker'
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
import { WithdrawButton } from '@/components/applicant/withdraw-button'
import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
FileText,
Calendar,
Clock,
CheckCircle,
Users,
Crown,
@@ -43,7 +43,7 @@ const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destru
}
export default function ApplicantDashboardPage() {
const { status: sessionStatus } = useSession()
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const { data, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
@@ -112,7 +112,6 @@ export default function ApplicantDashboardPage() {
}
const { project, timeline, currentStatus, openRounds, hasPassedIntake } = data
const isDraft = !project.submittedAt
const programYear = project.program?.year
const programName = project.program?.name
const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0
@@ -121,32 +120,34 @@ export default function ApplicantDashboardPage() {
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
{currentStatus && (
<Badge variant={statusColors[currentStatus] || 'secondary'}>
{currentStatus.replace('_', ' ')}
</Badge>
<div className="flex items-center gap-4">
{/* Project logo */}
<div className="shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden">
{data.logoUrl ? (
<img src={data.logoUrl} alt={project.title} className="h-full w-full object-cover" />
) : (
<FileText className="h-7 w-7 text-muted-foreground/60" />
)}
</div>
<p className="text-muted-foreground">
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
</p>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
{currentStatus && (
<Badge variant={statusColors[currentStatus] || 'secondary'}>
{currentStatus.replace('_', ' ')}
</Badge>
)}
</div>
<p className="text-muted-foreground">
{programYear ? `${programYear} Edition` : ''}{programName ? ` - ${programName}` : ''}
</p>
</div>
</div>
{project.isTeamLead && currentStatus !== 'REJECTED' && (currentStatus as string) !== 'WINNER' && (
<WithdrawButton projectId={project.id} />
)}
</div>
{/* Draft warning */}
{isDraft && (
<Alert>
<Clock className="h-4 w-4" />
<AlertTitle>Draft Submission</AlertTitle>
<AlertDescription>
This submission has not been submitted yet. You can continue editing and submit when ready.
</AlertDescription>
</Alert>
)}
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
@@ -205,16 +206,11 @@ export default function ApplicantDashboardPage() {
<Calendar className="h-4 w-4" />
Created {new Date(project.createdAt).toLocaleDateString()}
</div>
{project.submittedAt ? (
{project.submittedAt && (
<div className="flex items-center gap-1">
<CheckCircle className="h-4 w-4 text-green-500" />
Submitted {new Date(project.submittedAt).toLocaleDateString()}
</div>
) : (
<div className="flex items-center gap-1">
<Clock className="h-4 w-4 text-orange-500" />
Draft
</div>
)}
<div className="flex items-center gap-1">
<FileText className="h-4 w-4" />
@@ -310,23 +306,25 @@ export default function ApplicantDashboardPage() {
<AnimatedCard index={3}>
<Card>
<CardHeader>
<CardTitle>
{hasPassedIntake ? 'Competition Progress' : 'Status Timeline'}
</CardTitle>
<CardTitle>Status Timeline</CardTitle>
</CardHeader>
<CardContent>
{hasPassedIntake ? (
<CompetitionTimelineSidebar />
) : (
<StatusTracker
timeline={timeline}
currentStatus={currentStatus || 'SUBMITTED'}
/>
)}
<CompetitionTimelineSidebar />
</CardContent>
</Card>
</AnimatedCard>
{/* Mentoring Request Card — show when there's an active MENTORING round */}
{project.isTeamLead && openRounds.filter((r) => r.roundType === 'MENTORING').map((mentoringRound) => (
<AnimatedCard key={mentoringRound.id} index={4}>
<MentoringRequestCard
projectId={project.id}
roundId={mentoringRound.id}
roundName={mentoringRound.name}
/>
</AnimatedCard>
))}
{/* Jury Feedback Card */}
{totalEvaluations > 0 && (
<AnimatedCard index={4}>

View File

@@ -48,7 +48,10 @@ import {
} from '@/components/ui/alert-dialog'
import { CountrySelect } from '@/components/ui/country-select'
import { Checkbox as CheckboxPrimitive } from '@/components/ui/checkbox'
import { ProjectLogoUpload } from '@/components/shared/project-logo-upload'
import { UserAvatar } from '@/components/shared/user-avatar'
import {
FolderOpen,
Users,
UserPlus,
Crown,
@@ -59,7 +62,14 @@ import {
CheckCircle,
Clock,
FileText,
ImageIcon,
MapPin,
Waves,
GraduationCap,
Heart,
Calendar,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
const inviteSchema = z.object({
name: z.string().min(1, 'Name is required'),
@@ -86,7 +96,21 @@ const statusLabels: Record<string, { label: string; icon: React.ComponentType<{
SUSPENDED: { label: 'Suspended', icon: AlertCircle },
}
export default function ApplicantTeamPage() {
const OCEAN_ISSUE_LABELS: Record<string, string> = {
POLLUTION_REDUCTION: 'Pollution Reduction',
CLIMATE_MITIGATION: 'Climate Mitigation',
TECHNOLOGY_INNOVATION: 'Technology Innovation',
SUSTAINABLE_SHIPPING: 'Sustainable Shipping',
BLUE_CARBON: 'Blue Carbon',
HABITAT_RESTORATION: 'Habitat Restoration',
COMMUNITY_CAPACITY: 'Community Capacity',
SUSTAINABLE_FISHING: 'Sustainable Fishing',
CONSUMER_AWARENESS: 'Consumer Awareness',
OCEAN_ACIDIFICATION: 'Ocean Acidification',
OTHER: 'Other',
}
export default function ApplicantProjectPage() {
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const [isInviteOpen, setIsInviteOpen] = useState(false)
@@ -96,13 +120,20 @@ export default function ApplicantTeamPage() {
{ enabled: isAuthenticated }
)
const projectId = dashboardData?.project?.id
const project = dashboardData?.project
const projectId = project?.id
const isIntakeOpen = dashboardData?.isIntakeOpen ?? false
const { data: teamData, isLoading: teamLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
{ projectId: projectId! },
{ enabled: !!projectId }
)
const { data: logoUrl, refetch: refetchLogo } = trpc.applicant.getProjectLogoUrl.useQuery(
{ projectId: projectId! },
{ enabled: !!projectId }
)
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
onSuccess: (result) => {
if (result.requiresAccountSetup) {
@@ -180,18 +211,18 @@ export default function ApplicantTeamPage() {
)
}
if (!projectId) {
if (!projectId || !project) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Team</h1>
<h1 className="text-2xl font-semibold tracking-tight">Project</h1>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project</h2>
<p className="text-muted-foreground text-center">
Submit a project first to manage your team.
Submit a project first to view details.
</p>
</CardContent>
</Card>
@@ -210,159 +241,297 @@ export default function ApplicantTeamPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{/* Project logo */}
<div className="shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden">
{logoUrl ? (
<img src={logoUrl} alt={project.title} className="h-full w-full object-cover" />
) : (
<FolderOpen className="h-7 w-7 text-muted-foreground/60" />
)}
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Users className="h-6 w-6" />
Team Members
<h1 className="text-2xl font-semibold tracking-tight">
{project.title}
</h1>
<p className="text-muted-foreground">
Manage your project team
{project.teamName ? `Team: ${project.teamName}` : 'Project details and team management'}
</p>
</div>
{isTeamLead && (
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus className="mr-2 h-4 w-4" />
Invite Member
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>
Send an invitation to join your project team. They will receive an email
with instructions to create their account.
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
placeholder="Jane Doe"
{...form.register('name')}
/>
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="jane@example.com"
{...form.register('email')}
/>
{form.formState.errors.email && (
<p className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={form.watch('role')}
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Team Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title (optional)</Label>
<Input
id="title"
placeholder="CTO, Designer..."
{...form.register('title')}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Nationality</Label>
<CountrySelect
value={form.watch('nationality') || ''}
onChange={(v) => form.setValue('nationality', v)}
placeholder="Select nationality"
/>
</div>
<div className="space-y-2">
<Label>Country of Residence</Label>
<CountrySelect
value={form.watch('country') || ''}
onChange={(v) => form.setValue('country', v)}
placeholder="Select country"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="institution">Institution (optional)</Label>
<Input
id="institution"
placeholder="e.g., Ocean Research Institute"
{...form.register('institution')}
/>
</div>
<div className="flex items-center gap-2">
<CheckboxPrimitive
id="sendInvite"
checked={form.watch('sendInvite')}
onCheckedChange={(checked) => form.setValue('sendInvite', !!checked)}
/>
<Label htmlFor="sendInvite" className="text-sm font-normal cursor-pointer">
Send platform invite email
</Label>
</div>
<div className="rounded-lg bg-muted/50 border p-3 text-sm">
<p className="font-medium mb-1">What invited members can do:</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Upload documents for submission rounds</li>
<li>View project status and competition progress</li>
<li>Receive email notifications about round updates</li>
</ul>
<p className="mt-2 text-muted-foreground">Only the Team Lead can invite or remove members.</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsInviteOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={inviteMutation.isPending}>
{inviteMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Send Invitation
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
{/* Project Details Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Project Information
</CardTitle>
{isIntakeOpen && (
<Badge variant="outline" className="text-amber-600 border-amber-200 bg-amber-50">
Editable during intake
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Category & Ocean Issue badges */}
<div className="flex flex-wrap gap-2">
{project.competitionCategory && (
<Badge variant="outline" className="gap-1">
<GraduationCap className="h-3 w-3" />
{project.competitionCategory === 'STARTUP' ? 'Start-up' : 'Business Concept'}
</Badge>
)}
{project.oceanIssue && (
<Badge variant="outline" className="gap-1">
<Waves className="h-3 w-3" />
{OCEAN_ISSUE_LABELS[project.oceanIssue] || project.oceanIssue.replace(/_/g, ' ')}
</Badge>
)}
{project.wantsMentorship && (
<Badge variant="outline" className="gap-1 text-pink-600 border-pink-200 bg-pink-50">
<Heart className="h-3 w-3" />
Wants Mentorship
</Badge>
)}
</div>
{/* Description */}
{project.description && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-1">Description</p>
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
</div>
)}
{/* Location, Institution, Founded */}
<div className="grid gap-4 sm:grid-cols-2">
{(project.country || project.geographicZone) && (
<div className="flex items-start gap-2">
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Location</p>
<p className="text-sm">{project.geographicZone || project.country}</p>
</div>
</div>
)}
{project.institution && (
<div className="flex items-start gap-2">
<GraduationCap className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Institution</p>
<p className="text-sm">{project.institution}</p>
</div>
</div>
)}
{project.foundedAt && (
<div className="flex items-start gap-2">
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Founded</p>
<p className="text-sm">{formatDateOnly(project.foundedAt)}</p>
</div>
</div>
)}
</div>
{/* Mentor info */}
{project.mentorAssignment?.mentor && (
<div className="rounded-lg border p-3 bg-muted/50">
<p className="text-sm font-medium mb-1">Assigned Mentor</p>
<p className="text-sm text-muted-foreground">
{project.mentorAssignment.mentor.name} ({project.mentorAssignment.mentor.email})
</p>
</div>
)}
{/* Tags */}
{project.tags && project.tags.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-1">Tags</p>
<div className="flex flex-wrap gap-1">
{project.tags.map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Project Logo */}
{isTeamLead && projectId && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ImageIcon className="h-5 w-5" />
Project Logo
</CardTitle>
<CardDescription>
Click the image to upload or change your project logo.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<ProjectLogoUpload
projectId={projectId}
currentLogoUrl={logoUrl}
onUploadComplete={() => refetchLogo()}
/>
</CardContent>
</Card>
)}
{/* Team Members List */}
<Card>
<CardHeader>
<CardTitle>Team ({teamData?.teamMembers.length || 0} members)</CardTitle>
<CardDescription>
Everyone on this list can view and collaborate on this project.
</CardDescription>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Team ({teamData?.teamMembers.length || 0} members)
</CardTitle>
<CardDescription>
Everyone on this list can view and collaborate on this project.
</CardDescription>
</div>
{isTeamLead && (
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogTrigger asChild>
<Button size="sm">
<UserPlus className="mr-2 h-4 w-4" />
Invite
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite Team Member</DialogTitle>
<DialogDescription>
Send an invitation to join your project team. They will receive an email
with instructions to create their account.
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onInvite)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
placeholder="Jane Doe"
{...form.register('name')}
/>
{form.formState.errors.name && (
<p className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
placeholder="jane@example.com"
{...form.register('email')}
/>
{form.formState.errors.email && (
<p className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={form.watch('role')}
onValueChange={(value) => form.setValue('role', value as 'MEMBER' | 'ADVISOR')}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Team Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title (optional)</Label>
<Input
id="title"
placeholder="CTO, Designer..."
{...form.register('title')}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Nationality</Label>
<CountrySelect
value={form.watch('nationality') || ''}
onChange={(v) => form.setValue('nationality', v)}
placeholder="Select nationality"
/>
</div>
<div className="space-y-2">
<Label>Country of Residence</Label>
<CountrySelect
value={form.watch('country') || ''}
onChange={(v) => form.setValue('country', v)}
placeholder="Select country"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="institution">Institution (optional)</Label>
<Input
id="institution"
placeholder="e.g., Ocean Research Institute"
{...form.register('institution')}
/>
</div>
<div className="flex items-center gap-2">
<CheckboxPrimitive
id="sendInvite"
checked={form.watch('sendInvite')}
onCheckedChange={(checked) => form.setValue('sendInvite', !!checked)}
/>
<Label htmlFor="sendInvite" className="text-sm font-normal cursor-pointer">
Send platform invite email
</Label>
</div>
<div className="rounded-lg bg-muted/50 border p-3 text-sm">
<p className="font-medium mb-1">What invited members can do:</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Upload documents for submission rounds</li>
<li>View project status and competition progress</li>
<li>Receive email notifications about round updates</li>
</ul>
<p className="mt-2 text-muted-foreground">Only the Team Lead can invite or remove members.</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsInviteOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={inviteMutation.isPending}>
{inviteMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Send Invitation
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{teamData?.teamMembers.map((member) => {
@@ -374,13 +543,16 @@ export default function ApplicantTeamPage() {
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
{member.role === 'LEAD' ? (
<Crown className="h-5 w-5 text-yellow-500" />
) : (
<span className="text-sm font-medium">
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
<div className="relative">
<UserAvatar
user={member.user}
avatarUrl={teamData?.avatarUrls?.[member.userId] || null}
size="md"
/>
{member.role === 'LEAD' && (
<div className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-yellow-100 ring-2 ring-white">
<Crown className="h-2.5 w-2.5 text-yellow-600" />
</div>
)}
</div>
<div>
@@ -455,25 +627,6 @@ export default function ApplicantTeamPage() {
)}
</CardContent>
</Card>
{/* Team Documents - visible via applicant documents page */}
{/* Info Card */}
<Card className="bg-muted/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="text-sm text-muted-foreground">
<p className="font-medium text-foreground">About Team Access</p>
<p className="mt-1">
All team members can view project details and status updates.
Only the team lead can invite or remove team members.
Invited members will receive an email to set up their account.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -12,6 +12,7 @@ import {
CardTitle,
} from '@/components/ui/card'
import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock } from 'lucide-react'
import Image from 'next/image'
import { trpc } from '@/lib/trpc/client'
import { AnimatedCard } from '@/components/shared/animated-container'
@@ -179,13 +180,14 @@ function AcceptInviteContent() {
// Valid invitation - show welcome
const user = data?.user
const team = data?.team
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-emerald-50">
<CheckCircle2 className="h-6 w-6 text-green-600" />
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-white shadow-sm border">
<Image src="/images/MOPC-blue-small.png" alt="MOPC" width={32} height={32} className="object-contain" />
</div>
<CardTitle className="text-xl">
{user?.name ? `Welcome, ${user.name}!` : 'Welcome!'}
@@ -196,6 +198,14 @@ function AcceptInviteContent() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{team?.projectTitle && (
<div className="rounded-md border border-blue-200 bg-blue-50 p-3 text-center">
<p className="text-sm text-blue-700">
You&apos;ve been invited to join the team for
</p>
<p className="font-semibold text-blue-900">&ldquo;{team.projectTitle}&rdquo;</p>
</div>
)}
{user?.email && (
<div className="rounded-md bg-muted/50 p-3 text-center">
<p className="text-sm text-muted-foreground">Signing in as</p>

View File

@@ -2,6 +2,7 @@
import { useState, useMemo, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -39,8 +40,17 @@ import {
Building2,
Flag,
ImageIcon,
Compass,
LayoutDashboard,
Upload,
ClipboardList,
Users,
Trophy,
BookOpen,
GraduationCap,
} from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
import { UserAvatar } from '@/components/shared/user-avatar'
type Step =
| 'name'
@@ -51,6 +61,7 @@ type Step =
| 'bio'
| 'logo'
| 'preferences'
| 'guide'
| 'complete'
type ApplicantWizardProps = {
@@ -136,7 +147,7 @@ export function ApplicantOnboardingWizard({
if (onboardingCtx?.projectId) {
base.push('logo')
}
base.push('preferences', 'complete')
base.push('preferences', 'guide', 'complete')
return base
}, [onboardingCtx?.projectId])
@@ -191,6 +202,7 @@ export function ApplicantOnboardingWizard({
bio: 'About',
logo: 'Logo',
preferences: 'Settings',
guide: 'Guide',
complete: 'Done',
}
@@ -203,11 +215,11 @@ export function ApplicantOnboardingWizard({
{/* Progress indicator */}
{step !== 'complete' && (
<div className="px-6 pt-6">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
{steps.slice(0, -1).map((s, i) => (
<div key={s} className="flex items-center flex-1">
<div key={s} className="flex-1 flex flex-col items-center gap-1">
<div
className={`h-2 flex-1 rounded-full transition-colors ${
className={`h-2 w-full rounded-full transition-colors ${
i < currentIndex
? 'bg-primary'
: i === currentIndex
@@ -215,15 +227,9 @@ export function ApplicantOnboardingWizard({
: 'bg-muted'
}`}
/>
</div>
))}
</div>
<div className="flex items-center gap-2 mt-1">
{steps.slice(0, -1).map((s, i) => (
<div key={s} className="flex-1 text-center">
<span
className={cn(
'text-[10px]',
'text-[10px] leading-none',
i <= currentIndex ? 'text-primary font-medium' : 'text-muted-foreground'
)}
>
@@ -291,7 +297,16 @@ export function ApplicantOnboardingWizard({
}}
currentAvatarUrl={avatarUrl}
onUploadComplete={() => refetchUser()}
/>
>
<div className="cursor-pointer">
<UserAvatar
user={{ name: userData?.name, email: userData?.email }}
avatarUrl={avatarUrl}
size="2xl"
showEditOverlay
/>
</div>
</AvatarUpload>
</div>
<p className="text-sm text-muted-foreground text-center">
Click the avatar to upload a new photo.
@@ -555,6 +570,83 @@ export function ApplicantOnboardingWizard({
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button onClick={goNext} className="flex-1">
Continue
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</>
)}
{/* Step: Portal Guide */}
{step === 'guide' && (
<>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Compass className="h-5 w-5 text-primary" />
Your Applicant Portal
</CardTitle>
<CardDescription>
Here&apos;s what you can do through the MOPC Applicant Portal.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
{[
{
icon: LayoutDashboard,
title: 'Dashboard',
desc: 'Overview of your project status, team, and upcoming deadlines.',
},
{
icon: Upload,
title: 'Documents',
desc: 'Upload required files for each round and track submission progress.',
},
{
icon: ClipboardList,
title: 'Evaluations',
desc: 'View anonymized jury feedback and scores for your project.',
},
{
icon: Users,
title: 'Team',
desc: 'Manage your team members, invite collaborators, and update your project logo.',
},
{
icon: Trophy,
title: 'Competition',
desc: 'Track your progress through competition rounds and milestones.',
},
{
icon: GraduationCap,
title: 'Mentorship',
desc: 'Connect with your assigned mentor for guidance and support.',
},
{
icon: BookOpen,
title: 'Resources',
desc: 'Access helpful materials, guides, and competition resources.',
},
].map(({ icon: Icon, title, desc }) => (
<div key={title} className="flex items-start gap-3 rounded-lg border p-3">
<div className="rounded-md bg-primary/10 p-2 shrink-0">
<Icon className="h-4 w-4 text-primary" />
</div>
<div>
<p className="font-medium text-sm">{title}</p>
<p className="text-xs text-muted-foreground">{desc}</p>
</div>
</div>
))}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={goBack} className="flex-1">
<ArrowLeft className="mr-2 h-4 w-4" />
@@ -580,8 +672,8 @@ export function ApplicantOnboardingWizard({
{/* Step: Complete */}
{step === 'complete' && (
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="rounded-2xl bg-emerald-50 p-4 mb-4 animate-in zoom-in-50 duration-500">
<CheckCircle className="h-12 w-12 text-green-600" />
<div className="mb-4 animate-in zoom-in-50 duration-500">
<Image src="/images/MOPC-blue-small.png" alt="MOPC Logo" width={64} height={64} className="h-16 w-16" />
</div>
<h2 className="text-xl font-semibold mb-2 animate-in fade-in slide-in-from-bottom-2 duration-500 delay-200">
Welcome, {name}!

View File

@@ -43,7 +43,7 @@ export default function SetPasswordPage() {
} else if (session?.user?.role === 'SUPER_ADMIN' || session?.user?.role === 'PROGRAM_ADMIN') {
router.push('/admin')
} else if (session?.user?.role === 'APPLICANT') {
router.push('/applicant')
router.push('/onboarding')
} else {
router.push('/')
}
@@ -148,7 +148,7 @@ export default function SetPasswordPage() {
</CardHeader>
<CardContent className="text-center">
<p className="text-sm text-muted-foreground">
Redirecting you to the dashboard...
Redirecting you to onboarding...
</p>
</CardContent>
</Card>

View File

@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import { processRoundClose } from '@/server/services/round-finalization'
export async function GET(request: NextRequest): Promise<NextResponse> {
const cronSecret = request.headers.get('x-cron-secret')
if (!cronSecret || cronSecret !== process.env.CRON_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const now = new Date()
// Find rounds with expired grace periods that haven't been finalized
const expiredRounds = await prisma.round.findMany({
where: {
status: 'ROUND_CLOSED',
gracePeriodEndsAt: { lt: now },
finalizedAt: null,
},
select: { id: true, name: true },
})
const results: Array<{ roundId: string; roundName: string; processed: number }> = []
for (const round of expiredRounds) {
try {
const result = await processRoundClose(round.id, 'system-cron', prisma)
results.push({ roundId: round.id, roundName: round.name, processed: result.processed })
} catch (err) {
console.error(`[Cron] processRoundClose failed for round ${round.id}:`, err)
}
}
return NextResponse.json({
ok: true,
processedRounds: results.length,
results,
timestamp: now.toISOString(),
})
} catch (error) {
console.error('Cron grace period processing failed:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

View File

@@ -0,0 +1,82 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { UserPlus, Loader2 } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
interface BulkInviteButtonProps {
roundId: string
}
export function BulkInviteButton({ roundId }: BulkInviteButtonProps) {
const preview = trpc.round.getBulkInvitePreview.useQuery({ roundId })
const inviteMutation = trpc.round.bulkInviteTeamMembers.useMutation({
onSuccess: (data) => {
toast.success(
`Invited ${data.invited} team member${data.invited !== 1 ? 's' : ''}${data.skipped ? ` (${data.skipped} already active/invited)` : ''}`
)
void preview.refetch()
},
onError: (err) => toast.error(err.message),
})
const uninvited = preview.data?.uninvitedCount ?? 0
if (uninvited === 0 && !preview.isLoading) return null
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<button className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-blue-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left">
<UserPlus className="h-5 w-5 text-blue-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Invite Team Members</p>
<p className="text-xs text-muted-foreground mt-0.5">
{preview.isLoading
? 'Checking...'
: `${uninvited} team member${uninvited !== 1 ? 's' : ''} need invitations`}
</p>
</div>
</button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Invite team members?</AlertDialogTitle>
<AlertDialogDescription>
This will send invitation emails to {uninvited} team member
{uninvited !== 1 ? 's' : ''} who haven&apos;t been invited yet.
{preview.data?.alreadyInvitedCount
? ` (${preview.data.alreadyInvitedCount} already invited)`
: ''}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => inviteMutation.mutate({ roundId })}
disabled={inviteMutation.isPending}
>
{inviteMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending...
</>
) : (
`Send ${uninvited} Invitation${uninvited !== 1 ? 's' : ''}`
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,130 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Loader2, Mail, RefreshCw } from 'lucide-react'
interface EmailPreviewDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
recipientCount: number
previewHtml: string | undefined
isPreviewLoading: boolean
onSend: (customMessage?: string) => void
isSending: boolean
showCustomMessage?: boolean
onRefreshPreview?: (customMessage?: string) => void
}
export function EmailPreviewDialog({
open,
onOpenChange,
title,
description,
recipientCount,
previewHtml,
isPreviewLoading,
onSend,
isSending,
showCustomMessage = true,
onRefreshPreview,
}: EmailPreviewDialogProps) {
const [customMessage, setCustomMessage] = useState('')
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
{title}
</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden grid grid-cols-1 md:grid-cols-2 gap-4 min-h-0">
{/* Left: Custom message */}
{showCustomMessage && (
<div className="space-y-3">
<Label>Custom Message (optional)</Label>
<Textarea
placeholder="Add a personal message to include in the email..."
value={customMessage}
onChange={(e) => setCustomMessage(e.target.value)}
className="min-h-[200px] resize-none"
/>
{onRefreshPreview && (
<Button
variant="outline"
size="sm"
onClick={() => onRefreshPreview(customMessage || undefined)}
disabled={isPreviewLoading}
>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Refresh Preview
</Button>
)}
</div>
)}
{/* Right: Email preview */}
<div className="border rounded-lg overflow-hidden bg-gray-50">
<div className="px-3 py-1.5 bg-gray-100 border-b text-xs text-muted-foreground font-medium">
Email Preview
</div>
{isPreviewLoading ? (
<div className="flex items-center justify-center h-[300px]">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : previewHtml ? (
<iframe
srcDoc={previewHtml}
sandbox="allow-same-origin"
className="w-full h-[400px] border-0"
title="Email Preview"
/>
) : (
<div className="flex items-center justify-center h-[300px] text-sm text-muted-foreground">
No preview available
</div>
)}
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSending}>
Cancel
</Button>
<Button
onClick={() => onSend(customMessage || undefined)}
disabled={isSending || recipientCount === 0}
>
{isSending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending...
</>
) : (
<>
<Mail className="h-4 w-4 mr-2" />
Send to {recipientCount} recipient{recipientCount !== 1 ? 's' : ''}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,665 @@
'use client'
import { useState, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import {
Clock,
CheckCircle2,
AlertTriangle,
ArrowRight,
Loader2,
Search,
ChevronDown,
ChevronRight,
Mail,
Send,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { projectStateConfig } from '@/lib/round-config'
// ── Types ──────────────────────────────────────────────────────────────────
interface FinalizationTabProps {
roundId: string
roundStatus: string
}
type ProposedOutcome = 'PASSED' | 'REJECTED'
const stateColors: Record<string, string> = Object.fromEntries(
Object.entries(projectStateConfig).map(([k, v]) => [k, v.bg])
)
const stateLabelColors: Record<string, string> = {
PENDING: 'bg-gray-100 text-gray-700',
IN_PROGRESS: 'bg-blue-100 text-blue-700',
COMPLETED: 'bg-indigo-100 text-indigo-700',
PASSED: 'bg-green-100 text-green-700',
REJECTED: 'bg-red-100 text-red-700',
WITHDRAWN: 'bg-yellow-100 text-yellow-700',
}
// ── Main Component ─────────────────────────────────────────────────────────
export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps) {
const utils = trpc.useUtils()
const { data: summary, isLoading } = trpc.roundEngine.getFinalizationSummary.useQuery(
{ roundId },
)
const [search, setSearch] = useState('')
const [filterOutcome, setFilterOutcome] = useState<'all' | 'PASSED' | 'REJECTED' | 'none'>('all')
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [emailSectionOpen, setEmailSectionOpen] = useState(false)
const [advancementMessage, setAdvancementMessage] = useState('')
const [rejectionMessage, setRejectionMessage] = useState('')
// Mutations
const updateOutcome = trpc.roundEngine.updateProposedOutcome.useMutation({
onSuccess: () => utils.roundEngine.getFinalizationSummary.invalidate({ roundId }),
})
const batchUpdate = trpc.roundEngine.batchUpdateProposedOutcomes.useMutation({
onSuccess: () => {
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
setSelectedIds(new Set())
toast.success('Proposed outcomes updated')
},
})
const confirmMutation = trpc.roundEngine.confirmFinalization.useMutation({
onSuccess: (data) => {
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
toast.success(
`Finalized: ${data.advanced} advanced, ${data.rejected} rejected, ${data.emailsSent} emails sent`,
)
},
onError: (err) => toast.error(err.message),
})
const endGraceMutation = trpc.roundEngine.endGracePeriod.useMutation({
onSuccess: () => {
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
toast.success('Grace period ended, projects processed')
},
onError: (err) => toast.error(err.message),
})
const processProjectsMutation = trpc.roundEngine.processRoundProjects.useMutation({
onSuccess: (data) => {
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
toast.success(`Processed ${data.processed} projects — review proposed outcomes below`)
},
onError: (err) => toast.error(err.message),
})
// Filtered projects
const filteredProjects = useMemo(() => {
if (!summary) return []
return summary.projects.filter((p) => {
const matchesSearch =
!search ||
p.title.toLowerCase().includes(search.toLowerCase()) ||
p.teamName?.toLowerCase().includes(search.toLowerCase()) ||
p.country?.toLowerCase().includes(search.toLowerCase())
const matchesFilter =
filterOutcome === 'all' ||
(filterOutcome === 'none' && !p.proposedOutcome) ||
p.proposedOutcome === filterOutcome
return matchesSearch && matchesFilter
})
}, [summary, search, filterOutcome])
// Counts
const passedCount = summary?.projects.filter((p) => p.proposedOutcome === 'PASSED').length ?? 0
const rejectedCount = summary?.projects.filter((p) => p.proposedOutcome === 'REJECTED').length ?? 0
const undecidedCount = summary?.projects.filter((p) => !p.proposedOutcome).length ?? 0
// Select all toggle
const allSelected = filteredProjects.length > 0 && filteredProjects.every((p) => selectedIds.has(p.id))
const toggleSelectAll = () => {
if (allSelected) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(filteredProjects.map((p) => p.id)))
}
}
// Bulk set outcome
const handleBulkSetOutcome = (outcome: ProposedOutcome) => {
const outcomes: Record<string, ProposedOutcome> = {}
for (const id of selectedIds) {
outcomes[id] = outcome
}
batchUpdate.mutate({ roundId, outcomes })
}
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-96 w-full" />
</div>
)
}
if (!summary) return null
return (
<div className="space-y-6">
{/* Grace Period Banner */}
{summary.isGracePeriodActive && (
<Card className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20">
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-amber-600" />
<div>
<p className="font-medium text-amber-800 dark:text-amber-200">Grace Period Active</p>
<p className="text-sm text-amber-600 dark:text-amber-400">
Applicants can still submit until{' '}
{summary.gracePeriodEndsAt
? new Date(summary.gracePeriodEndsAt).toLocaleString()
: 'the grace period ends'}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="border-amber-300 hover:bg-amber-100"
onClick={() => endGraceMutation.mutate({ roundId })}
disabled={endGraceMutation.isPending}
>
{endGraceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
End Grace Period
</Button>
</CardContent>
</Card>
)}
{/* Finalized Banner */}
{summary.isFinalized && (
<Card className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20">
<CardContent className="flex items-center gap-3 py-4">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<div>
<p className="font-medium text-green-800 dark:text-green-200">Round Finalized</p>
<p className="text-sm text-green-600 dark:text-green-400">
Finalized on{' '}
{summary.finalizedAt
? new Date(summary.finalizedAt).toLocaleString()
: 'unknown date'}
</p>
</div>
</CardContent>
</Card>
)}
{/* Needs Processing Banner */}
{!summary.isFinalized && !summary.isGracePeriodActive && summary.projects.length > 0 && summary.projects.every((p) => !p.proposedOutcome) && (
<Card className="border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20">
<CardContent className="flex items-center justify-between py-4">
<div className="flex items-center gap-3">
<AlertTriangle className="h-5 w-5 text-blue-600" />
<div>
<p className="font-medium text-blue-800 dark:text-blue-200">Projects Need Processing</p>
<p className="text-sm text-blue-600 dark:text-blue-400">
{summary.projects.length} project{summary.projects.length !== 1 ? 's' : ''} in this round have no proposed outcome.
Click &quot;Process&quot; to auto-assign outcomes based on round type and project activity.
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="border-blue-300 hover:bg-blue-100"
onClick={() => processProjectsMutation.mutate({ roundId })}
disabled={processProjectsMutation.isPending}
>
{processProjectsMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Process Projects
</Button>
</CardContent>
</Card>
)}
{/* Summary Stats Bar */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{([
['Pending', summary.stats.pending, 'bg-gray-100 text-gray-700'],
['In Progress', summary.stats.inProgress, 'bg-blue-100 text-blue-700'],
['Completed', summary.stats.completed, 'bg-indigo-100 text-indigo-700'],
['Passed', summary.stats.passed, 'bg-green-100 text-green-700'],
['Rejected', summary.stats.rejected, 'bg-red-100 text-red-700'],
['Withdrawn', summary.stats.withdrawn, 'bg-yellow-100 text-yellow-700'],
] as const).map(([label, count, cls]) => (
<div key={label} className="rounded-lg border p-3 text-center">
<div className="text-2xl font-bold">{count}</div>
<div className={cn('text-xs font-medium mt-1 inline-flex px-2 py-0.5 rounded-full', cls)}>{label}</div>
</div>
))}
</div>
{/* Category Target Progress */}
{(summary.categoryTargets.startupTarget != null || summary.categoryTargets.conceptTarget != null) && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Advancement Targets</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{summary.categoryTargets.startupTarget != null && (
<div>
<div className="flex justify-between text-sm mb-1">
<span>Startup</span>
<span className="font-medium">
{summary.categoryTargets.startupProposed} / {summary.categoryTargets.startupTarget}
</span>
</div>
<div className="h-2.5 rounded-full bg-muted overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
summary.categoryTargets.startupProposed > summary.categoryTargets.startupTarget
? 'bg-amber-500'
: 'bg-green-500',
)}
style={{
width: `${Math.min(100, (summary.categoryTargets.startupProposed / summary.categoryTargets.startupTarget) * 100)}%`,
}}
/>
</div>
</div>
)}
{summary.categoryTargets.conceptTarget != null && (
<div>
<div className="flex justify-between text-sm mb-1">
<span>Business Concept</span>
<span className="font-medium">
{summary.categoryTargets.conceptProposed} / {summary.categoryTargets.conceptTarget}
</span>
</div>
<div className="h-2.5 rounded-full bg-muted overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
summary.categoryTargets.conceptProposed > summary.categoryTargets.conceptTarget
? 'bg-amber-500'
: 'bg-green-500',
)}
style={{
width: `${Math.min(100, (summary.categoryTargets.conceptProposed / summary.categoryTargets.conceptTarget) * 100)}%`,
}}
/>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* Proposed Outcomes Table */}
<Card>
<CardHeader className="pb-3">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<CardTitle className="text-base">Proposed Outcomes</CardTitle>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
{passedCount} advancing
</Badge>
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">
{rejectedCount} rejected
</Badge>
{undecidedCount > 0 && (
<Badge variant="outline">{undecidedCount} undecided</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Search + Filter */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select value={filterOutcome} onValueChange={(v) => setFilterOutcome(v as typeof filterOutcome)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by outcome" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All projects</SelectItem>
<SelectItem value="PASSED">Proposed: Pass</SelectItem>
<SelectItem value="REJECTED">Proposed: Reject</SelectItem>
<SelectItem value="none">Undecided</SelectItem>
</SelectContent>
</Select>
</div>
{/* Bulk actions */}
{selectedIds.size > 0 && !summary.isFinalized && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 border">
<span className="text-sm font-medium">{selectedIds.size} selected</span>
<Button
variant="outline"
size="sm"
onClick={() => handleBulkSetOutcome('PASSED')}
disabled={batchUpdate.isPending}
>
Set as Pass
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleBulkSetOutcome('REJECTED')}
disabled={batchUpdate.isPending}
className="text-destructive"
>
Set as Reject
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelectedIds(new Set())}>
Clear
</Button>
</div>
)}
{/* Table */}
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
{!summary.isFinalized && (
<th className="w-10 px-3 py-2.5">
<Checkbox
checked={allSelected}
onCheckedChange={toggleSelectAll}
aria-label="Select all"
/>
</th>
)}
<th className="text-left px-3 py-2.5 font-medium">Project</th>
<th className="text-left px-3 py-2.5 font-medium hidden sm:table-cell">Category</th>
<th className="text-left px-3 py-2.5 font-medium hidden md:table-cell">Country</th>
<th className="text-center px-3 py-2.5 font-medium">Current State</th>
{summary.roundType === 'EVALUATION' && (
<th className="text-center px-3 py-2.5 font-medium hidden lg:table-cell">Score / Rank</th>
)}
<th className="text-center px-3 py-2.5 font-medium w-[160px]">Proposed Outcome</th>
</tr>
</thead>
<tbody>
{filteredProjects.map((project) => (
<tr key={project.id} className="border-b last:border-0 hover:bg-muted/30">
{!summary.isFinalized && (
<td className="px-3 py-2.5">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={(checked) => {
const next = new Set(selectedIds)
if (checked) next.add(project.id)
else next.delete(project.id)
setSelectedIds(next)
}}
aria-label={`Select ${project.title}`}
/>
</td>
)}
<td className="px-3 py-2.5">
<div className="font-medium truncate max-w-[200px]">{project.title}</div>
{project.teamName && (
<div className="text-xs text-muted-foreground truncate">{project.teamName}</div>
)}
</td>
<td className="px-3 py-2.5 hidden sm:table-cell text-muted-foreground">
{project.category === 'STARTUP' ? 'Startup' : project.category === 'BUSINESS_CONCEPT' ? 'Concept' : project.category ?? '-'}
</td>
<td className="px-3 py-2.5 hidden md:table-cell text-muted-foreground">
{project.country ?? '-'}
</td>
<td className="px-3 py-2.5 text-center">
<Badge
variant="secondary"
className={cn('text-xs', stateLabelColors[project.currentState] ?? '')}
>
{project.currentState.replace('_', ' ')}
</Badge>
</td>
{summary.roundType === 'EVALUATION' && (
<td className="px-3 py-2.5 text-center hidden lg:table-cell text-muted-foreground">
{project.evaluationScore != null
? `${project.evaluationScore.toFixed(1)} (#${project.rankPosition ?? '-'})`
: '-'}
</td>
)}
<td className="px-3 py-2.5 text-center">
{summary.isFinalized ? (
<Badge
variant="secondary"
className={cn(
'text-xs',
project.proposedOutcome === 'PASSED' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700',
)}
>
{project.proposedOutcome === 'PASSED' ? 'Advanced' : 'Rejected'}
</Badge>
) : (
<Select
value={project.proposedOutcome ?? 'undecided'}
onValueChange={(v) => {
if (v === 'undecided') return
updateOutcome.mutate({
roundId,
projectId: project.id,
proposedOutcome: v as 'PASSED' | 'REJECTED',
})
}}
>
<SelectTrigger
className={cn(
'h-8 w-[130px] text-xs mx-auto',
project.proposedOutcome === 'PASSED' && 'border-green-300 bg-green-50 text-green-700',
project.proposedOutcome === 'REJECTED' && 'border-red-300 bg-red-50 text-red-700',
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="undecided" disabled>Undecided</SelectItem>
<SelectItem value="PASSED">Pass</SelectItem>
<SelectItem value="REJECTED">Reject</SelectItem>
</SelectContent>
</Select>
)}
</td>
</tr>
))}
{filteredProjects.length === 0 && (
<tr>
<td
colSpan={summary.isFinalized ? 6 : 7}
className="px-3 py-8 text-center text-muted-foreground"
>
No projects match your search/filter
</td>
</tr>
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Email Customization + Confirm */}
{!summary.isFinalized && !summary.isGracePeriodActive && (
<Card>
<CardHeader className="pb-3">
<button
className="flex items-center gap-2 text-left w-full"
onClick={() => setEmailSectionOpen(!emailSectionOpen)}
>
{emailSectionOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<Mail className="h-4 w-4" />
<CardTitle className="text-base">Email Customization</CardTitle>
<span className="text-xs text-muted-foreground ml-2">(optional)</span>
</button>
</CardHeader>
{emailSectionOpen && (
<CardContent className="space-y-4">
{/* Account stats */}
{(summary.accountStats.needsInvite > 0 || summary.accountStats.hasAccount > 0) && (
<div className="flex items-center gap-4 p-3 rounded-lg bg-muted/50 border text-sm">
<Mail className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex flex-wrap gap-x-4 gap-y-1">
{summary.accountStats.needsInvite > 0 && (
<span>
<strong>{summary.accountStats.needsInvite}</strong> project{summary.accountStats.needsInvite !== 1 ? 's' : ''} will receive a{' '}
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200 text-xs">Create Account</Badge>{' '}
invite link
</span>
)}
{summary.accountStats.hasAccount > 0 && (
<span>
<strong>{summary.accountStats.hasAccount}</strong> project{summary.accountStats.hasAccount !== 1 ? 's' : ''} will receive a{' '}
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 text-xs">View Dashboard</Badge>{' '}
link
</span>
)}
</div>
</div>
)}
<div>
<label className="text-sm font-medium mb-1.5 block">Advancement Message</label>
<Textarea
placeholder="Custom message for projects that are advancing (added to the standard email template)..."
value={advancementMessage}
onChange={(e) => setAdvancementMessage(e.target.value)}
rows={3}
/>
</div>
<div>
<label className="text-sm font-medium mb-1.5 block">Rejection Message</label>
<Textarea
placeholder="Custom message for projects that are not advancing (added to the standard email template)..."
value={rejectionMessage}
onChange={(e) => setRejectionMessage(e.target.value)}
rows={3}
/>
</div>
</CardContent>
)}
<CardContent className="pt-0">
<div className="flex items-center justify-between border-t pt-4">
<div className="text-sm text-muted-foreground">
{summary.nextRound ? (
<span>
<strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} will advance to{' '}
<strong>{summary.nextRound.name}</strong>
</span>
) : (
<span>
<strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} will be marked as passed
</span>
)}
{' | '}
<strong>{rejectedCount}</strong> rejected
{undecidedCount > 0 && (
<span className="text-amber-600"> | {undecidedCount} undecided (will not be processed)</span>
)}
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
disabled={passedCount + rejectedCount === 0 || confirmMutation.isPending}
>
{confirmMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
<Send className="h-4 w-4 mr-1.5" />
Finalize Round
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Finalization</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>This will:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Mark <strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} as <strong>PASSED</strong></li>
<li>Mark <strong>{rejectedCount}</strong> project{rejectedCount !== 1 ? 's' : ''} as <strong>REJECTED</strong></li>
{summary.nextRound && (
<li>Advance passed projects to <strong>{summary.nextRound.name}</strong></li>
)}
<li>Send email notifications to all affected teams</li>
</ul>
{undecidedCount > 0 && (
<p className="text-amber-600 flex items-center gap-1.5 mt-2">
<AlertTriangle className="h-4 w-4" />
{undecidedCount} project{undecidedCount !== 1 ? 's' : ''} with no proposed outcome will not be affected.
</p>
)}
<p className="font-medium mt-3">This action cannot be undone.</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
confirmMutation.mutate({
roundId,
targetRoundId: summary.nextRound?.id,
advancementMessage: advancementMessage || undefined,
rejectionMessage: rejectionMessage || undefined,
})
}
>
Yes, Finalize Round
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,62 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Trophy } from 'lucide-react'
import { EmailPreviewDialog } from './email-preview-dialog'
interface NotifyAdvancedButtonProps {
roundId: string
targetRoundId?: string
}
export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedButtonProps) {
const [open, setOpen] = useState(false)
const [customMessage, setCustomMessage] = useState<string | undefined>()
const preview = trpc.round.previewAdvancementEmail.useQuery(
{ roundId, targetRoundId, customMessage },
{ enabled: open }
)
const sendMutation = trpc.round.sendAdvancementNotifications.useMutation({
onSuccess: (data) => {
toast.success(
`Sent ${data.sent} notification${data.sent !== 1 ? 's' : ''}${data.failed ? ` (${data.failed} failed)` : ''}`
)
setOpen(false)
},
onError: (err) => toast.error(err.message),
})
return (
<>
<button
onClick={() => setOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-emerald-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<Trophy className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Notify Advanced Teams</p>
<p className="text-xs text-muted-foreground mt-0.5">
Send advancement emails to passed projects
</p>
</div>
</button>
<EmailPreviewDialog
open={open}
onOpenChange={setOpen}
title="Notify Advanced Teams"
description="Send advancement notification emails to project team members"
recipientCount={preview.data?.recipientCount ?? 0}
previewHtml={preview.data?.html}
isPreviewLoading={preview.isLoading}
onSend={(msg) => sendMutation.mutate({ roundId, targetRoundId, customMessage: msg })}
isSending={sendMutation.isPending}
onRefreshPreview={(msg) => setCustomMessage(msg)}
/>
</>
)
}

View File

@@ -0,0 +1,61 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { XCircle } from 'lucide-react'
import { EmailPreviewDialog } from './email-preview-dialog'
interface NotifyRejectedButtonProps {
roundId: string
}
export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
const [open, setOpen] = useState(false)
const [customMessage, setCustomMessage] = useState<string | undefined>()
const preview = trpc.round.previewRejectionEmail.useQuery(
{ roundId, customMessage },
{ enabled: open }
)
const sendMutation = trpc.round.sendRejectionNotifications.useMutation({
onSuccess: (data) => {
toast.success(
`Sent ${data.sent} notification${data.sent !== 1 ? 's' : ''}${data.failed ? ` (${data.failed} failed)` : ''}`
)
setOpen(false)
},
onError: (err) => toast.error(err.message),
})
return (
<>
<button
onClick={() => setOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-red-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Notify Non-Advanced</p>
<p className="text-xs text-muted-foreground mt-0.5">
Send rejection emails to non-advanced projects
</p>
</div>
</button>
<EmailPreviewDialog
open={open}
onOpenChange={setOpen}
title="Notify Non-Advanced Teams"
description="Send rejection notification emails to project team members"
recipientCount={preview.data?.recipientCount ?? 0}
previewHtml={preview.data?.html}
isPreviewLoading={preview.isLoading}
onSend={(msg) => sendMutation.mutate({ roundId, customMessage: msg })}
isSending={sendMutation.isPending}
onRefreshPreview={(msg) => setCustomMessage(msg)}
/>
</>
)
}

View File

@@ -76,13 +76,23 @@ const stateConfig: Record<ProjectState, { label: string; color: string; icon: Re
WITHDRAWN: { label: 'Withdrawn', color: 'bg-orange-100 text-orange-700 border-orange-200', icon: LogOut },
}
type CompetitionRound = {
id: string
name: string
sortOrder: number
_count: { projectRoundStates: number }
}
type ProjectStatesTableProps = {
competitionId: string
roundId: string
roundStatus?: string
competitionRounds?: CompetitionRound[]
currentSortOrder?: number
onAssignProjects?: (projectIds: string[]) => void
}
export function ProjectStatesTable({ competitionId, roundId, onAssignProjects }: ProjectStatesTableProps) {
export function ProjectStatesTable({ competitionId, roundId, roundStatus, competitionRounds, currentSortOrder, onAssignProjects }: ProjectStatesTableProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [stateFilter, setStateFilter] = useState<string>('ALL')
const [searchQuery, setSearchQuery] = useState('')
@@ -226,32 +236,65 @@ export function ProjectStatesTable({ competitionId, roundId, onAssignProjects }:
)
}
const hasEarlierRounds = competitionRounds && currentSortOrder != null &&
competitionRounds.some((r) => r.sortOrder < currentSortOrder && r._count.projectRoundStates > 0)
if (!projectStates || projectStates.length === 0) {
return (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center justify-center text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<Layers className="h-8 w-8 text-muted-foreground" />
<>
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center justify-center text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<Layers className="h-8 w-8 text-muted-foreground" />
</div>
<p className="text-sm font-medium">No Projects in This Round</p>
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
Assign projects from the Project Pool or import from an earlier round to get started.
</p>
<div className="flex items-center gap-2 mt-4">
<Link href={poolLink}>
<Button size="sm" variant="outline">
<Plus className="h-4 w-4 mr-1.5" />
Go to Project Pool
</Button>
</Link>
{hasEarlierRounds && (
<Button size="sm" onClick={() => { setAddProjectOpen(true) }}>
<ArrowRight className="h-4 w-4 mr-1.5" />
Import from Earlier Round
</Button>
)}
</div>
</div>
<p className="text-sm font-medium">No Projects in This Round</p>
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
Assign projects from the Project Pool to this round to get started.
</p>
<Link href={poolLink}>
<Button size="sm" className="mt-4">
<Plus className="h-4 w-4 mr-1.5" />
Go to Project Pool
</Button>
</Link>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
<AddProjectDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
roundId={roundId}
competitionId={competitionId}
competitionRounds={competitionRounds}
currentSortOrder={currentSortOrder}
defaultTab={hasEarlierRounds ? 'round' : 'create'}
onAssigned={() => {
utils.roundEngine.getProjectStates.invalidate({ roundId })
}}
/>
</>
)
}
return (
<div className="space-y-4">
{/* Finalization hint for closed rounds */}
{(roundStatus === 'ROUND_CLOSED' || roundStatus === 'ROUND_ARCHIVED') && (
<div className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20 px-4 py-3 text-sm">
<span className="text-blue-700 dark:text-blue-300">
This round is closed. Use the <strong>Finalization</strong> tab to review proposed outcomes and confirm advancement.
</span>
</div>
)}
{/* Top bar: search + filters + add buttons */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3 flex-1 min-w-0">
@@ -496,12 +539,14 @@ export function ProjectStatesTable({ competitionId, roundId, onAssignProjects }:
}}
/>
{/* Add Project Dialog (Create New + From Pool) */}
{/* Add Project Dialog (Create New + From Pool + From Round) */}
<AddProjectDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
roundId={roundId}
competitionId={competitionId}
competitionRounds={competitionRounds}
currentSortOrder={currentSortOrder}
onAssigned={() => {
utils.roundEngine.getProjectStates.invalidate({ roundId })
}}
@@ -744,15 +789,21 @@ function AddProjectDialog({
onOpenChange,
roundId,
competitionId,
competitionRounds,
currentSortOrder,
defaultTab,
onAssigned,
}: {
open: boolean
onOpenChange: (open: boolean) => void
roundId: string
competitionId: string
competitionRounds?: CompetitionRound[]
currentSortOrder?: number
defaultTab?: 'create' | 'pool' | 'round'
onAssigned: () => void
}) {
const [activeTab, setActiveTab] = useState<'create' | 'pool'>('create')
const [activeTab, setActiveTab] = useState<'create' | 'pool' | 'round'>(defaultTab ?? 'create')
// ── Create New tab state ──
const [title, setTitle] = useState('')
@@ -765,6 +816,12 @@ function AddProjectDialog({
const [poolSearch, setPoolSearch] = useState('')
const [selectedPoolIds, setSelectedPoolIds] = useState<Set<string>>(new Set())
// ── From Round tab state ──
const [sourceRoundId, setSourceRoundId] = useState('')
const [roundStateFilter, setRoundStateFilter] = useState<string[]>([])
const [roundSearch, setRoundSearch] = useState('')
const [selectedRoundIds, setSelectedRoundIds] = useState<Set<string>>(new Set())
const utils = trpc.useUtils()
// Get the competition to find programId (for pool search)
@@ -774,6 +831,34 @@ function AddProjectDialog({
)
const programId = (competition as any)?.programId || ''
// Earlier rounds available for import
const earlierRounds = useMemo(() => {
if (!competitionRounds || currentSortOrder == null) return []
return competitionRounds
.filter((r) => r.sortOrder < currentSortOrder && r._count.projectRoundStates > 0)
}, [competitionRounds, currentSortOrder])
// From Round query
const { data: roundProjects, isLoading: roundLoading } = trpc.projectPool.getProjectsInRound.useQuery(
{
roundId: sourceRoundId,
states: roundStateFilter.length > 0 ? roundStateFilter : undefined,
search: roundSearch.trim() || undefined,
},
{ enabled: open && activeTab === 'round' && !!sourceRoundId },
)
// Import mutation
const importMutation = trpc.projectPool.importFromRound.useMutation({
onSuccess: (data) => {
toast.success(`${data.imported} project(s) imported${data.skipped > 0 ? `, ${data.skipped} already in round` : ''}`)
utils.roundEngine.getProjectStates.invalidate({ roundId })
onAssigned()
resetAndClose()
},
onError: (err) => toast.error(err.message),
})
// Pool query
const { data: poolResults, isLoading: poolLoading } = trpc.projectPool.listUnassigned.useQuery(
{
@@ -815,6 +900,10 @@ function AddProjectDialog({
setCategory('')
setPoolSearch('')
setSelectedPoolIds(new Set())
setSourceRoundId('')
setRoundStateFilter([])
setRoundSearch('')
setSelectedRoundIds(new Set())
onOpenChange(false)
}
@@ -838,6 +927,24 @@ function AddProjectDialog({
})
}
const handleImportFromRound = () => {
if (selectedRoundIds.size === 0 || !sourceRoundId) return
importMutation.mutate({
sourceRoundId,
targetRoundId: roundId,
projectIds: Array.from(selectedRoundIds),
})
}
const toggleRoundProject = (id: string) => {
setSelectedRoundIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const togglePoolProject = (id: string) => {
setSelectedPoolIds(prev => {
const next = new Set(prev)
@@ -847,7 +954,7 @@ function AddProjectDialog({
})
}
const isMutating = createMutation.isPending || assignMutation.isPending
const isMutating = createMutation.isPending || assignMutation.isPending || importMutation.isPending
return (
<Dialog open={open} onOpenChange={(isOpen) => {
@@ -862,10 +969,13 @@ function AddProjectDialog({
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'create' | 'pool')}>
<TabsList className="grid w-full grid-cols-2">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'create' | 'pool' | 'round')}>
<TabsList className={`grid w-full ${earlierRounds.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}>
<TabsTrigger value="create">Create New</TabsTrigger>
<TabsTrigger value="pool">From Pool</TabsTrigger>
{earlierRounds.length > 0 && (
<TabsTrigger value="round">From Round</TabsTrigger>
)}
</TabsList>
{/* ── Create New Tab ── */}
@@ -1012,6 +1122,158 @@ function AddProjectDialog({
</Button>
</DialogFooter>
</TabsContent>
{/* ── From Round Tab ── */}
{earlierRounds.length > 0 && (
<TabsContent value="round" className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Source Round</Label>
<Select value={sourceRoundId} onValueChange={(v) => {
setSourceRoundId(v)
setSelectedRoundIds(new Set())
}}>
<SelectTrigger>
<SelectValue placeholder="Select a round..." />
</SelectTrigger>
<SelectContent>
{earlierRounds.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name} ({r._count.projectRoundStates} projects)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{sourceRoundId && (
<>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search by title or team..."
value={roundSearch}
onChange={(e) => setRoundSearch(e.target.value)}
className="pl-8"
/>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{['PASSED', 'COMPLETED', 'PENDING', 'IN_PROGRESS', 'REJECTED'].map((state) => {
const isActive = roundStateFilter.includes(state)
return (
<button
key={state}
onClick={() => {
setRoundStateFilter(prev =>
isActive ? prev.filter(s => s !== state) : [...prev, state]
)
setSelectedRoundIds(new Set())
}}
className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
isActive
? 'bg-foreground text-background border-foreground'
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
}`}
>
{state.charAt(0) + state.slice(1).toLowerCase().replace('_', ' ')}
</button>
)
})}
</div>
<ScrollArea className="h-[280px] rounded-md border">
<div className="p-2 space-y-0.5">
{roundLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!roundLoading && roundProjects?.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
{roundSearch.trim() ? `No projects found matching "${roundSearch}"` : 'No projects in this round'}
</p>
)}
{roundProjects && roundProjects.length > 0 && (
<label
className="flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer hover:bg-muted/50 border-b mb-1"
>
<Checkbox
checked={roundProjects.length > 0 && roundProjects.every(p => selectedRoundIds.has(p.id))}
onCheckedChange={() => {
const allIds = roundProjects.map(p => p.id)
const allSelected = allIds.every(id => selectedRoundIds.has(id))
if (allSelected) {
setSelectedRoundIds(new Set())
} else {
setSelectedRoundIds(new Set(allIds))
}
}}
/>
<span className="text-xs font-medium text-muted-foreground">
Select all ({roundProjects.length})
</span>
</label>
)}
{roundProjects?.map((project) => {
const isSelected = selectedRoundIds.has(project.id)
return (
<label
key={project.id}
className={`flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors ${
isSelected ? 'bg-accent' : 'hover:bg-muted/50'
}`}
>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleRoundProject(project.id)}
/>
<div className="flex flex-1 items-center justify-between min-w-0">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{project.title}</p>
<p className="text-xs text-muted-foreground truncate">
{project.teamName}
{project.country && <> &middot; {project.country}</>}
</p>
</div>
<div className="flex items-center gap-1.5 ml-2 shrink-0">
<Badge variant="outline" className="text-[10px]">
{project.state.charAt(0) + project.state.slice(1).toLowerCase().replace('_', ' ')}
</Badge>
{project.competitionCategory && (
<Badge variant="outline" className="text-[10px]">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Concept'}
</Badge>
)}
</div>
</div>
</label>
)
})}
</div>
</ScrollArea>
</>
)}
<DialogFooter>
<Button variant="outline" onClick={resetAndClose}>Cancel</Button>
<Button
onClick={handleImportFromRound}
disabled={selectedRoundIds.size === 0 || isMutating}
>
{importMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
{selectedRoundIds.size <= 1
? 'Import to Round'
: `Import ${selectedRoundIds.size} Projects`
}
</Button>
</DialogFooter>
</TabsContent>
)}
</Tabs>
</DialogContent>
</Dialog>

View File

@@ -4,7 +4,8 @@ import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { CheckCircle2, Circle, Clock, XCircle, Trophy } from 'lucide-react'
import { CheckCircle2, Circle, Clock, XCircle, Trophy, Check } from 'lucide-react'
import { cn } from '@/lib/utils'
const roundStatusDisplay: Record<string, { label: string; variant: 'default' | 'secondary' }> = {
ROUND_DRAFT: { label: 'Upcoming', variant: 'secondary' },
@@ -166,7 +167,7 @@ export function ApplicantCompetitionTimeline() {
/**
* Compact sidebar variant for the dashboard.
* Shows dots + labels, no date details.
* Animated timeline with connector indicators between dots.
*/
export function CompetitionTimelineSidebar() {
const { data, isLoading } = trpc.applicant.getMyCompetitionTimeline.useQuery()
@@ -185,54 +186,123 @@ export function CompetitionTimelineSidebar() {
return <p className="text-sm text-muted-foreground">No rounds available</p>
}
// Find the index where elimination happened (first REJECTED entry)
const eliminationIndex = data.entries.findIndex((e) => e.projectState === 'REJECTED')
return (
<div className="space-y-0">
{data.entries.map((entry, index) => {
const isCompleted = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
const isActive = entry.status === 'ROUND_ACTIVE'
const isRejected = entry.projectState === 'REJECTED'
const isGrandFinale = entry.roundType === 'GRAND_FINALE'
const isLast = index === data.entries.length - 1
<div className="relative">
<div className="space-y-0">
{data.entries.map((entry, index) => {
const isCompleted = entry.status === 'ROUND_CLOSED' || entry.status === 'ROUND_ARCHIVED'
const isActive = entry.status === 'ROUND_ACTIVE'
const isRejected = entry.projectState === 'REJECTED'
const isGrandFinale = entry.roundType === 'GRAND_FINALE'
const isPassed = entry.projectState === 'PASSED' || entry.projectState === 'COMPLETED'
const isLast = index === data.entries.length - 1
// Is this entry after the elimination point?
const isAfterElimination = eliminationIndex >= 0 && index > eliminationIndex
let dotColor = 'border-2 border-muted bg-background'
if (isRejected) dotColor = 'bg-destructive'
else if (isGrandFinale && isCompleted) dotColor = 'bg-yellow-500'
else if (isCompleted) dotColor = 'bg-primary'
else if (isActive) dotColor = 'bg-primary ring-2 ring-primary/30'
// Is this the current round the project is in (regardless of round status)?
const isCurrent = !!entry.projectState && entry.projectState !== 'PASSED' && entry.projectState !== 'COMPLETED' && entry.projectState !== 'REJECTED'
return (
<div key={entry.id} className="relative flex gap-3">
{/* Connecting line */}
{!isLast && (
<div className="absolute left-[7px] top-[20px] h-full w-0.5 bg-muted" />
)}
// Determine connector segment color (no icons, just colored lines)
let connectorColor = 'bg-border'
if ((isPassed || isCompleted) && !isAfterElimination) connectorColor = 'bg-emerald-400'
else if (isRejected) connectorColor = 'bg-destructive/30'
{/* Dot */}
<div className={`relative z-10 mt-1.5 h-4 w-4 rounded-full shrink-0 ${dotColor}`} />
// Dot inner content
let dotInner: React.ReactNode = null
let dotClasses = 'border-2 border-muted-foreground/20 bg-background'
{/* Label */}
<div className="flex-1 pb-4">
<p
className={`text-sm font-medium ${
isRejected
? 'text-destructive'
: isCompleted || isActive
? 'text-foreground'
: 'text-muted-foreground'
}`}
>
{entry.label}
</p>
{isRejected && (
<p className="text-xs text-destructive">Not Selected</p>
)}
{isActive && (
<p className="text-xs text-primary">In Progress</p>
if (isAfterElimination) {
dotClasses = 'border-2 border-muted/60 bg-muted/30'
} else if (isRejected) {
dotClasses = 'bg-destructive border-2 border-destructive'
dotInner = <XCircle className="h-3.5 w-3.5 text-white" />
} else if (isGrandFinale && (isCompleted || isPassed)) {
dotClasses = 'bg-yellow-500 border-2 border-yellow-500'
dotInner = <Trophy className="h-3.5 w-3.5 text-white" />
} else if (isCompleted || isPassed) {
dotClasses = 'bg-emerald-500 border-2 border-emerald-500'
dotInner = <Check className="h-3.5 w-3.5 text-white" />
} else if (isCurrent) {
dotClasses = 'bg-amber-400 border-2 border-amber-400'
dotInner = <span className="h-2.5 w-2.5 rounded-full bg-white animate-ping" style={{ animationDuration: '2s' }} />
}
// Status sub-label
let statusLabel: string | null = null
let statusColor = 'text-muted-foreground'
if (isRejected) {
statusLabel = 'Eliminated'
statusColor = 'text-destructive'
} else if (isAfterElimination) {
statusLabel = null
} else if (isPassed) {
statusLabel = 'Advanced'
statusColor = 'text-emerald-600'
} else if (isCurrent) {
statusLabel = 'You are here'
statusColor = 'text-amber-600'
}
return (
<div
key={entry.id}
className="animate-in fade-in slide-in-from-left-2 fill-mode-both"
style={{ animationDelay: `${index * 100}ms`, animationDuration: '400ms' }}
>
{/* Row: dot + label */}
<div className={cn(
'group relative flex items-center gap-3.5 rounded-lg px-1.5 py-1.5 -mx-1.5 transition-colors duration-200 hover:bg-muted/40',
isCurrent && 'bg-amber-50/60 hover:bg-amber-50/80',
)}>
{/* Dot */}
<div
className={cn(
'relative z-10 h-7 w-7 rounded-full shrink-0 flex items-center justify-center shadow-sm transition-all duration-300',
isCurrent && 'ring-4 ring-amber-400/25',
isPassed && !isRejected && !isAfterElimination && 'ring-[3px] ring-emerald-500/15',
dotClasses,
)}
>
{dotInner}
</div>
{/* Label */}
<div className="flex-1 min-w-0">
<p
className={cn('text-sm leading-5 font-medium transition-colors duration-200', {
'text-destructive line-through decoration-destructive/40': isRejected,
'text-muted-foreground/40': isAfterElimination,
'text-foreground font-semibold': isCurrent,
'text-foreground': !isCurrent && !isRejected && !isAfterElimination && (isCompleted || isActive || isPassed),
'text-muted-foreground': !isRejected && !isAfterElimination && !isCompleted && !isActive && !isPassed,
})}
>
{entry.label}
</p>
{statusLabel && (
<p className={cn('text-xs mt-0.5 font-semibold tracking-wide uppercase', statusColor)}>{statusLabel}</p>
)}
</div>
</div>
{/* Connector line between dots */}
{!isLast && (
<div className="flex items-center ml-[13px] h-5">
<div
className={cn(
'w-[2px] h-full rounded-full transition-all duration-500',
connectorColor,
)}
/>
</div>
)}
</div>
</div>
)
})}
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,133 @@
'use client'
import { useState, useEffect } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { MessageSquare, Clock, CheckCircle, XCircle } from 'lucide-react'
import { toast } from 'sonner'
interface MentoringRequestCardProps {
projectId: string
roundId: string
roundName: string
}
export function MentoringRequestCard({ projectId, roundId, roundName }: MentoringRequestCardProps) {
const [timeLeft, setTimeLeft] = useState('')
const { data: status, isLoading } = trpc.applicant.getMentoringRequestStatus.useQuery(
{ projectId, roundId },
{ refetchInterval: 60_000 },
)
const utils = trpc.useUtils()
const requestMutation = trpc.applicant.requestMentoring.useMutation({
onSuccess: (data) => {
toast.success(data.requesting ? 'Mentoring requested' : 'Mentoring request cancelled')
utils.applicant.getMentoringRequestStatus.invalidate({ projectId, roundId })
utils.applicant.getMyDashboard.invalidate()
},
onError: (error) => toast.error(error.message),
})
// Countdown timer
useEffect(() => {
if (!status?.deadline) return
const update = () => {
const now = new Date()
const deadline = new Date(status.deadline!)
const diff = deadline.getTime() - now.getTime()
if (diff <= 0) {
setTimeLeft('Expired')
return
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
if (days > 0) setTimeLeft(`${days}d ${hours}h remaining`)
else if (hours > 0) setTimeLeft(`${hours}h ${minutes}m remaining`)
else setTimeLeft(`${minutes}m remaining`)
}
update()
const interval = setInterval(update, 60_000)
return () => clearInterval(interval)
}, [status?.deadline])
if (isLoading || !status?.available) return null
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<MessageSquare className="h-5 w-5" />
Mentoring {roundName}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{status.requested ? (
<div className="flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-green-500 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Mentoring requested</p>
<p className="text-xs text-muted-foreground">
{status.requestedAt
? `Requested on ${new Date(status.requestedAt).toLocaleDateString()}`
: 'Awaiting mentor assignment'}
</p>
</div>
</div>
) : (
<div className="flex items-start gap-3">
<XCircle className="h-5 w-5 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Not requesting mentoring</p>
<p className="text-xs text-muted-foreground">
You will advance automatically without a mentoring period.
</p>
</div>
</div>
)}
{/* Deadline info */}
{status.deadline && (
<div className="flex items-center gap-2 text-sm">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Request window:</span>
{status.canStillRequest ? (
<Badge variant="outline" className="text-amber-600 border-amber-300">
{timeLeft}
</Badge>
) : (
<Badge variant="secondary">Closed</Badge>
)}
</div>
)}
{/* Action button */}
{status.canStillRequest && (
<Button
variant={status.requested ? 'outline' : 'default'}
size="sm"
className="w-full"
onClick={() => requestMutation.mutate({ projectId, roundId, requesting: !status.requested })}
disabled={requestMutation.isPending}
>
{requestMutation.isPending
? 'Updating...'
: status.requested
? 'Cancel Mentoring Request'
: 'Request Mentoring'}
</Button>
)}
{!status.canStillRequest && (
<p className="text-xs text-muted-foreground text-center">
The mentoring request window has closed.
</p>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,70 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { LogOut } from 'lucide-react'
import { toast } from 'sonner'
interface WithdrawButtonProps {
projectId: string
}
export function WithdrawButton({ projectId }: WithdrawButtonProps) {
const [open, setOpen] = useState(false)
const utils = trpc.useUtils()
const withdraw = trpc.applicant.withdrawFromCompetition.useMutation({
onSuccess: (data) => {
toast.success(`Withdrawn from ${data.roundName}`)
utils.applicant.getMyDashboard.invalidate()
utils.applicant.getMyCompetitionTimeline.invalidate()
setOpen(false)
},
onError: (error) => {
toast.error(error.message)
},
})
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="text-destructive hover:text-destructive">
<LogOut className="h-4 w-4 mr-1.5" />
Withdraw
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Withdraw from Competition?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to withdraw your project from the current round?
This action is immediate and cannot be undone by you.
An administrator would need to re-include your project.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => withdraw.mutate({ projectId })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={withdraw.isPending}
>
{withdraw.isPending ? 'Withdrawing...' : 'Yes, Withdraw'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { Home, Users, FileText, MessageSquare, Trophy, Star, BookOpen } from 'lucide-react'
import { Home, FolderOpen, FileText, MessageSquare, Trophy, Star, BookOpen } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
@@ -15,7 +15,7 @@ export function ApplicantNav({ user }: ApplicantNavProps) {
const navigation: NavItem[] = [
{ name: 'Dashboard', href: '/applicant', icon: Home },
{ name: 'Team', href: '/applicant/team', icon: Users },
{ name: 'Project', href: '/applicant/team', icon: FolderOpen },
{ name: 'Competition', href: '/applicant/competition', icon: Trophy },
{ name: 'Documents', href: '/applicant/documents', icon: FileText },
...(flags?.hasEvaluationRounds

View File

@@ -144,9 +144,9 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem disabled>
<User className="mr-2 h-4 w-4" />
<DropdownMenuContent align="end" className="min-w-48">
<DropdownMenuItem disabled className="text-xs">
<User className="mr-2 h-4 w-4 shrink-0" />
{user.email}
</DropdownMenuItem>
<DropdownMenuSeparator />

View File

@@ -40,7 +40,7 @@ const OFFICE_MIME_TYPES = [
const OFFICE_EXTENSIONS = ['.pptx', '.ppt', '.docx', '.doc']
function isOfficeFile(mimeType: string, fileName: string): boolean {
export function isOfficeFile(mimeType: string, fileName: string): boolean {
if (OFFICE_MIME_TYPES.includes(mimeType)) return true
const ext = fileName.toLowerCase().slice(fileName.lastIndexOf('.'))
return OFFICE_EXTENSIONS.includes(ext)
@@ -633,7 +633,7 @@ function FileDownloadButton({ file, className, label }: { file: ProjectFile; cla
)
}
function FilePreview({ file, url }: { file: ProjectFile; url: string }) {
export function FilePreview({ file, url }: { file: { mimeType: string; fileName: string }; url: string }) {
if (file.mimeType.startsWith('video/')) {
return (
<video

View File

@@ -252,6 +252,14 @@ export function NotificationBell() {
}
)
// Mark all notifications as read when popover opens
useEffect(() => {
if (open && isAuthenticated && unreadCount > 0) {
markAllAsReadMutation.mutate()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const markAsReadMutation = trpc.notification.markAsRead.useMutation({
onSuccess: () => refetch(),
})

View File

@@ -16,7 +16,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { Upload, Loader2, ZoomIn, ImageIcon } from 'lucide-react'
import { Upload, Loader2, Trash2, ZoomIn, ImageIcon } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
@@ -24,6 +24,7 @@ type ProjectLogoUploadProps = {
projectId: string
currentLogoUrl?: string | null
onUploadComplete?: () => void
children?: React.ReactNode
}
const MAX_SIZE_MB = 5
@@ -72,6 +73,7 @@ export function ProjectLogoUpload({
projectId,
currentLogoUrl,
onUploadComplete,
children,
}: ProjectLogoUploadProps) {
const [open, setOpen] = useState(false)
const [imageSrc, setImageSrc] = useState<string | null>(null)
@@ -79,10 +81,12 @@ export function ProjectLogoUpload({
const [zoom, setZoom] = useState(1)
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.applicant.getProjectLogoUploadUrl.useMutation()
const confirmUpload = trpc.applicant.confirmProjectLogo.useMutation()
const deleteLogo = trpc.applicant.deleteProjectLogo.useMutation()
const onCropComplete = useCallback((_croppedArea: Area, croppedPixels: Area) => {
setCroppedAreaPixels(croppedPixels)
@@ -148,6 +152,21 @@ export function ProjectLogoUpload({
}
}
const handleDelete = async () => {
setIsDeleting(true)
try {
await deleteLogo.mutateAsync({ projectId })
toast.success('Logo removed')
setOpen(false)
onUploadComplete?.()
} catch (error) {
console.error('Delete error:', error)
toast.error('Failed to remove logo')
} finally {
setIsDeleting(false)
}
}
const resetState = () => {
setImageSrc(null)
setCrop({ x: 0, y: 0 })
@@ -164,43 +183,48 @@ export function ProjectLogoUpload({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button
type="button"
className="relative mx-auto flex h-24 w-24 items-center justify-center rounded-xl border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 transition-colors cursor-pointer overflow-hidden bg-muted"
>
{currentLogoUrl ? (
<img src={currentLogoUrl} alt="Project logo" className="h-full w-full object-cover" />
) : (
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
)}
</button>
{children || (
<button
type="button"
className="relative mx-auto flex h-24 w-24 items-center justify-center rounded-xl border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 transition-colors cursor-pointer overflow-hidden bg-muted"
>
{currentLogoUrl ? (
<img src={currentLogoUrl} alt="Project logo" className="h-full w-full object-cover" />
) : (
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
)}
</button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Project Logo</DialogTitle>
<DialogDescription>
{imageSrc
? 'Drag to reposition and use the slider to zoom.'
: 'Upload a logo for your project. Allowed formats: JPEG, PNG, GIF, WebP.'}
? 'Drag to reposition and use the slider to zoom. The logo will be cropped to a square.'
: `Upload a logo for your project. Allowed formats: JPEG, PNG, GIF, WebP. Max size: ${MAX_SIZE_MB}MB.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{imageSrc ? (
<>
{/* Cropper */}
<div className="relative w-full h-64 bg-muted rounded-lg overflow-hidden">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
showGrid={false}
cropShape="rect"
showGrid
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
/>
</div>
{/* Zoom slider */}
<div className="flex items-center gap-3 px-1">
<ZoomIn className="h-4 w-4 text-muted-foreground shrink-0" />
<Slider
@@ -213,6 +237,7 @@ export function ProjectLogoUpload({
/>
</div>
{/* Change image button */}
<Button
variant="ghost"
size="sm"
@@ -227,6 +252,7 @@ export function ProjectLogoUpload({
</>
) : (
<>
{/* Current logo preview */}
{currentLogoUrl && (
<div className="flex justify-center">
<img
@@ -237,6 +263,7 @@ export function ProjectLogoUpload({
</div>
)}
{/* File input */}
<div className="space-y-2">
<Label htmlFor="logo">Select image</Label>
<Input
@@ -253,6 +280,22 @@ export function ProjectLogoUpload({
</div>
<DialogFooter className="flex-col gap-2 sm:flex-row">
{currentLogoUrl && !imageSrc && (
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
className="w-full sm:w-auto"
>
{isDeleting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Remove
</Button>
)}
<div className="flex gap-2 w-full sm:w-auto">
<Button variant="outline" onClick={handleCancel} className="flex-1">
Cancel

View File

@@ -13,17 +13,27 @@ import {
Loader2,
Trash2,
RefreshCw,
Eye,
Download,
FileText,
Languages,
Play,
X,
} from 'lucide-react'
import { cn, formatFileSize } from '@/lib/utils'
import { toast } from 'sonner'
import { FilePreview, isOfficeFile } from '@/components/shared/file-viewer'
function getMimeLabel(mime: string): string {
if (mime === 'application/pdf') return 'PDF'
if (mime.startsWith('image/')) return 'Images'
if (mime === 'video/mp4') return 'MP4'
if (mime === 'video/quicktime') return 'MOV'
if (mime === 'video/webm') return 'WebM'
if (mime.startsWith('video/')) return 'Video'
if (mime.includes('wordprocessingml')) return 'Word'
if (mime.includes('wordprocessingml') || mime === 'application/msword') return 'Word'
if (mime.includes('spreadsheetml')) return 'Excel'
if (mime.includes('presentationml')) return 'PowerPoint'
if (mime.includes('presentationml') || mime === 'application/vnd.ms-powerpoint') return 'PowerPoint'
if (mime.endsWith('/*')) return mime.replace('/*', '')
return mime
}
@@ -44,6 +54,11 @@ interface UploadedFile {
size: number
createdAt: string | Date
requirementId?: string | null
bucket?: string
objectKey?: string
pageCount?: number | null
detectedLang?: string | null
analyzedAt?: string | Date | null
}
interface RequirementUploadSlotProps {
@@ -55,6 +70,36 @@ interface RequirementUploadSlotProps {
disabled?: boolean
}
function ViewFileButton({ bucket, objectKey }: { bucket: string; objectKey: string }) {
const { data } = trpc.file.getDownloadUrl.useQuery(
{ bucket, objectKey, forDownload: false },
{ staleTime: 10 * 60 * 1000 }
)
const href = typeof data === 'string' ? data : data?.url
return (
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs gap-1" asChild disabled={!href}>
<a href={href || '#'} target="_blank" rel="noopener noreferrer">
<Eye className="h-3 w-3" /> View
</a>
</Button>
)
}
function DownloadFileButton({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) {
const { data } = trpc.file.getDownloadUrl.useQuery(
{ bucket, objectKey, forDownload: true, fileName },
{ staleTime: 10 * 60 * 1000 }
)
const href = typeof data === 'string' ? data : data?.url
return (
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs gap-1" asChild disabled={!href}>
<a href={href || '#'} download={fileName}>
<Download className="h-3 w-3" /> Download
</a>
</Button>
)
}
export function RequirementUploadSlot({
requirement,
existingFile,
@@ -66,6 +111,7 @@ export function RequirementUploadSlot({
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [deleting, setDeleting] = useState(false)
const [showPreview, setShowPreview] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const getUploadUrl = trpc.applicant.getUploadUrl.useMutation()
@@ -181,6 +227,20 @@ export function RequirementUploadSlot({
}
}, [existingFile, deleteFile, onFileChange])
// Fetch preview URL only when preview is toggled on
const { data: previewUrlData, isLoading: isLoadingPreview } = trpc.file.getDownloadUrl.useQuery(
{ bucket: existingFile?.bucket || '', objectKey: existingFile?.objectKey || '', forDownload: false },
{ enabled: showPreview && !!existingFile?.bucket && !!existingFile?.objectKey, staleTime: 10 * 60 * 1000 }
)
const previewUrl = typeof previewUrlData === 'string' ? previewUrlData : previewUrlData?.url
const canPreview = existingFile
? existingFile.mimeType.startsWith('video/') ||
existingFile.mimeType === 'application/pdf' ||
existingFile.mimeType.startsWith('image/') ||
isOfficeFile(existingFile.mimeType, existingFile.fileName)
: false
const isFulfilled = !!existingFile
const statusColor = isFulfilled
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950'
@@ -222,9 +282,9 @@ export function RequirementUploadSlot({
)}
<div className="flex flex-wrap gap-1 ml-6 mb-2">
{requirement.acceptedMimeTypes.map((mime) => (
<Badge key={mime} variant="outline" className="text-xs">
{getMimeLabel(mime)}
{[...new Set(requirement.acceptedMimeTypes.map(getMimeLabel))].map((label) => (
<Badge key={label} variant="outline" className="text-xs">
{label}
</Badge>
))}
{requirement.maxSizeMB && (
@@ -235,10 +295,65 @@ export function RequirementUploadSlot({
</div>
{existingFile && (
<div className="ml-6 flex items-center gap-2 text-xs text-muted-foreground">
<FileIcon className="h-3 w-3" />
<span className="truncate">{existingFile.fileName}</span>
<span>({formatFileSize(existingFile.size)})</span>
<div className="ml-6 space-y-1.5">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileIcon className="h-3 w-3" />
<span className="truncate">{existingFile.fileName}</span>
<span>({formatFileSize(existingFile.size)})</span>
{existingFile.pageCount != null && (
<span className="flex items-center gap-0.5">
<FileText className="h-3 w-3" />
{existingFile.pageCount} page{existingFile.pageCount !== 1 ? 's' : ''}
</span>
)}
{existingFile.detectedLang && existingFile.detectedLang !== 'und' && (
<span className="flex items-center gap-0.5">
<Languages className="h-3 w-3" />
{existingFile.detectedLang.toUpperCase()}
</span>
)}
</div>
{existingFile.bucket && existingFile.objectKey && (
<div className="flex items-center gap-1.5">
{canPreview && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs gap-1"
onClick={() => setShowPreview(!showPreview)}
>
{showPreview ? (
<><X className="h-3 w-3" /> Close Preview</>
) : (
<><Play className="h-3 w-3" /> Preview</>
)}
</Button>
)}
<ViewFileButton bucket={existingFile.bucket} objectKey={existingFile.objectKey} />
<DownloadFileButton bucket={existingFile.bucket} objectKey={existingFile.objectKey} fileName={existingFile.fileName} />
</div>
)}
</div>
)}
{/* Inline preview panel */}
{showPreview && existingFile && (
<div className="ml-6 mt-2 rounded-lg border bg-muted/50 overflow-hidden">
{isLoadingPreview ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : previewUrl ? (
<FilePreview
file={{ mimeType: existingFile.mimeType, fileName: existingFile.fileName }}
url={previewUrl}
/>
) : (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<AlertCircle className="mr-2 h-4 w-4" />
Failed to load preview
</div>
)}
</div>
)}
@@ -349,6 +464,11 @@ export function RequirementUploadList({ projectId, roundId, disabled }: Requirem
size: existing.size,
createdAt: existing.createdAt,
requirementId: (existing as { requirementId?: string | null }).requirementId,
bucket: (existing as { bucket?: string }).bucket,
objectKey: (existing as { objectKey?: string }).objectKey,
pageCount: (existing as { pageCount?: number | null }).pageCount,
detectedLang: (existing as { detectedLang?: string | null }).detectedLang,
analyzedAt: (existing as { analyzedAt?: string | null }).analyzedAt,
}
: null
}

View File

@@ -11,7 +11,7 @@ type UserAvatarProps = {
email?: string | null
profileImageKey?: string | null
}
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
className?: string
showEditOverlay?: boolean
avatarUrl?: string | null
@@ -23,6 +23,7 @@ const sizeClasses = {
md: 'h-10 w-10',
lg: 'h-12 w-12',
xl: 'h-16 w-16',
'2xl': 'h-24 w-24',
}
const textSizeClasses = {
@@ -31,6 +32,7 @@ const textSizeClasses = {
md: 'text-sm',
lg: 'text-base',
xl: 'text-lg',
'2xl': 'text-2xl',
}
const iconSizeClasses = {
@@ -39,6 +41,7 @@ const iconSizeClasses = {
md: 'h-4 w-4',
lg: 'h-5 w-5',
xl: 'h-6 w-6',
'2xl': 'h-8 w-8',
}
export function UserAvatar({

View File

@@ -1678,6 +1678,222 @@ Together for a healthier ocean.
}
}
/**
* Generate "Project Advanced" notification email template
*/
export function getAdvancementNotificationTemplate(
name: string,
projectName: string,
fromRoundName: string,
toRoundName: string,
customMessage?: string,
accountUrl?: string,
): EmailTemplate {
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
const celebrationBanner = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background: linear-gradient(135deg, #059669 0%, #0d9488 100%); border-radius: 12px; padding: 24px; text-align: center;">
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Great News</p>
<h2 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: 700;">Your project has advanced!</h2>
</td>
</tr>
</table>
`
const escapedMessage = customMessage
? customMessage
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
: null
const content = `
${sectionTitle(greeting)}
${celebrationBanner}
${infoBox(`<strong>"${projectName}"</strong>`, 'success')}
${infoBox(`Advanced from <strong>${fromRoundName}</strong> to <strong>${toRoundName}</strong>`, 'info')}
${
escapedMessage
? `<div style="background-color: #f5f5f5; border-radius: 8px; padding: 20px; margin: 20px 0; color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7;">${escapedMessage}</div>`
: paragraph('Our team will be in touch with more details about the next phase.')
}
${accountUrl
? ctaButton(accountUrl, 'Create Your Account')
: ctaButton('/applicant', 'View Your Dashboard')}
`
return {
subject: `Your project has advanced: "${projectName}"`,
html: getEmailWrapper(content),
text: `
${greeting}
Your project has advanced!
Project: ${projectName}
Advanced from: ${fromRoundName}
To: ${toRoundName}
${customMessage || 'Our team will be in touch with more details about the next phase.'}
${accountUrl
? `Create your account: ${getBaseUrl()}${accountUrl}`
: `Visit your dashboard: ${getBaseUrl()}/applicant`}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Project Not Advanced" (rejection) notification email template
*/
export function getRejectionNotificationTemplate(
name: string,
projectName: string,
roundName: string,
customMessage?: string
): EmailTemplate {
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
const escapedMessage = customMessage
? customMessage
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
: null
const content = `
${sectionTitle(greeting)}
${paragraph(`Thank you for your participation in <strong>${roundName}</strong> with your project <strong>"${projectName}"</strong>.`)}
${infoBox('After careful review by our jury, we regret to inform you that your project was not selected to advance at this stage.', 'info')}
${
escapedMessage
? `<div style="background-color: #f5f5f5; border-radius: 8px; padding: 20px; margin: 20px 0; color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7;">${escapedMessage}</div>`
: ''
}
${paragraph('We encourage you to continue developing your ocean protection initiative and to apply again in future editions. Your commitment to protecting our oceans is valuable and appreciated.')}
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 14px; text-align: center;">
Thank you for being part of the Monaco Ocean Protection Challenge community.
</p>
`
return {
subject: `Update on your application: "${projectName}"`,
html: getEmailWrapper(content),
text: `
${greeting}
Thank you for your participation in ${roundName} with your project "${projectName}".
After careful review by our jury, we regret to inform you that your project was not selected to advance at this stage.
${customMessage || ''}
We encourage you to continue developing your ocean protection initiative and to apply again in future editions.
Thank you for being part of the Monaco Ocean Protection Challenge community.
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate "Selected for Special Award" notification email template
*/
export function getAwardSelectionNotificationTemplate(
name: string,
projectName: string,
awardName: string,
customMessage?: string,
accountUrl?: string,
): EmailTemplate {
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
const celebrationBanner = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
<td style="background: linear-gradient(135deg, #d97706 0%, #f59e0b 100%); border-radius: 12px; padding: 24px; text-align: center;">
<p style="color: #ffffff; margin: 0 0 8px 0; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 2px;">Congratulations</p>
<h2 style="color: #ffffff; margin: 0; font-size: 24px; font-weight: 700;">Your project has been selected!</h2>
</td>
</tr>
</table>
`
const escapedMessage = customMessage
? customMessage
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
: null
const content = `
${sectionTitle(greeting)}
${celebrationBanner}
${infoBox(`<strong>"${projectName}"</strong> has been selected for the <strong>${awardName}</strong>`, 'success')}
${
escapedMessage
? `<div style="background-color: #f5f5f5; border-radius: 8px; padding: 20px; margin: 20px 0; color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7;">${escapedMessage}</div>`
: paragraph('Our team will be in touch with more details about this award and next steps.')
}
${accountUrl
? ctaButton(accountUrl, 'Create Your Account')
: ctaButton('/applicant', 'View Your Dashboard')}
`
return {
subject: `Your project has been selected for ${awardName}: "${projectName}"`,
html: getEmailWrapper(content),
text: `
${greeting}
Your project has been selected!
Project: ${projectName}
Award: ${awardName}
${customMessage || 'Our team will be in touch with more details about this award and next steps.'}
${accountUrl
? `Create your account: ${getBaseUrl()}${accountUrl}`
: `Visit your dashboard: ${getBaseUrl()}/applicant`}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Generate a preview HTML wrapper for admin email previews
*/
export function getEmailPreviewHtml(subject: string, body: string): string {
const formattedBody = body
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
const content = `
${sectionTitle(subject)}
<div style="color: #1f2937; font-size: 15px; line-height: 1.7; margin: 20px 0;">
${formattedBody}
</div>
`
return getEmailWrapper(content)
}
/**
* Template registry mapping notification types to template generators
*/
@@ -1828,6 +2044,32 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
(ctx.metadata?.awardName as string) || 'Award'
),
ADVANCEMENT_NOTIFICATION: (ctx) =>
getAdvancementNotificationTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Your Project',
(ctx.metadata?.fromRoundName as string) || 'previous round',
(ctx.metadata?.toRoundName as string) || 'next round',
ctx.metadata?.customMessage as string | undefined,
ctx.metadata?.accountUrl as string | undefined,
),
REJECTION_NOTIFICATION: (ctx) =>
getRejectionNotificationTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Your Project',
(ctx.metadata?.roundName as string) || 'this round',
ctx.metadata?.customMessage as string | undefined
),
AWARD_SELECTION_NOTIFICATION: (ctx) =>
getAwardSelectionNotificationTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Your Project',
(ctx.metadata?.awardName as string) || 'Special Award',
ctx.metadata?.customMessage as string | undefined,
ctx.metadata?.accountUrl as string | undefined,
),
// Admin templates
NEW_APPLICATION: (ctx) =>
getNewApplicationTemplate(

View File

@@ -3,13 +3,13 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { getPresignedUrl, generateObjectKey } from '@/lib/minio'
import { generateLogoKey, type StorageProviderType } from '@/lib/storage'
import { getImageUploadUrl, confirmImageUpload, getImageUrl, type ImageUploadConfig } from '@/server/utils/image-upload'
import { generateLogoKey, createStorageProvider, type StorageProviderType } from '@/lib/storage'
import { getImageUploadUrl, confirmImageUpload, getImageUrl, deleteImage, type ImageUploadConfig } from '@/server/utils/image-upload'
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'
import { EvaluationConfigSchema } from '@/types/competition-configs'
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
import type { Prisma } from '@prisma/client'
// Bucket for applicant submissions
@@ -415,14 +415,17 @@ 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-transition: mark as IN_PROGRESS on file activity, then check completion
if (roundId) {
await triggerInProgressOnActivity(projectId, roundId, ctx.user.id, ctx.prisma)
if (requirementId) {
await checkRequirementsAndTransition(
projectId,
roundId,
ctx.user.id,
ctx.prisma,
)
}
}
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
@@ -724,6 +727,8 @@ export const applicantRouter = router({
email: true,
status: true,
lastLoginAt: true,
profileImageKey: true,
profileImageProvider: true,
},
},
},
@@ -742,9 +747,20 @@ export const applicantRouter = router({
})
}
// Generate presigned avatar URLs for team members with profile images
const avatarUrls: Record<string, string> = {}
for (const member of project.teamMembers) {
if (member.user.profileImageKey) {
const providerType = (member.user.profileImageProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType)
avatarUrls[member.userId] = await provider.getDownloadUrl(member.user.profileImageKey)
}
}
return {
teamMembers: project.teamMembers,
submittedBy: project.submittedBy,
avatarUrls,
}
}),
@@ -1296,6 +1312,7 @@ export const applicantRouter = router({
id: true,
name: true,
slug: true,
roundType: true,
windowCloseAt: true,
},
})
@@ -1311,6 +1328,24 @@ export const applicantRouter = router({
select: { id: true },
})
// Check if there is an active intake round (applicants can edit project details during intake)
const activeIntakeRound = await ctx.prisma.round.findFirst({
where: {
competition: { programId: project.programId },
roundType: 'INTAKE',
status: 'ROUND_ACTIVE',
},
select: { id: true },
})
// Generate presigned logo URL if the project has a logo
let logoUrl: string | null = null
if (project.logoKey) {
const providerType = (project.logoProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType)
logoUrl = await provider.getDownloadUrl(project.logoKey)
}
return {
project: {
...project,
@@ -1321,6 +1356,8 @@ export const applicantRouter = router({
timeline,
currentStatus,
hasPassedIntake: !!passedIntake,
isIntakeOpen: !!activeIntakeRound,
logoUrl,
}
}),
@@ -1430,7 +1467,7 @@ export const applicantRouter = router({
type TimelineEntry = {
id: string
label: string
roundType: 'EVALUATION' | 'GRAND_FINALE'
roundType: string
status: string
windowOpenAt: Date | null
windowCloseAt: Date | null
@@ -1440,25 +1477,32 @@ export const applicantRouter = router({
const entries: TimelineEntry[] = []
// Build lookup for filtering rounds and their next evaluation round
// Build lookup for filtering rounds
const filteringRounds = rounds.filter((r) => r.roundType === 'FILTERING')
const evalRounds = rounds.filter((r) => r.roundType === 'EVALUATION')
const liveFinalRounds = rounds.filter((r) => r.roundType === 'LIVE_FINAL')
const deliberationRounds = rounds.filter((r) => r.roundType === 'DELIBERATION')
// Process EVALUATION rounds
for (const evalRound of evalRounds) {
const actualState = stateMap.get(evalRound.id) ?? null
// Process visible rounds: hide FILTERING, LIVE_FINAL, DELIBERATION always.
// Also hide MENTORING unless the project is actually participating in it.
const visibleRounds = rounds.filter(
(r) => {
if (r.roundType === 'FILTERING' || r.roundType === 'LIVE_FINAL' || r.roundType === 'DELIBERATION') return false
if (r.roundType === 'MENTORING' && !stateMap.has(r.id)) return false
return true
}
)
// Check if a FILTERING round before this eval round rejected the project
for (const round of visibleRounds) {
const actualState = stateMap.get(round.id) ?? null
// Check if a FILTERING round before this round rejected the project
let projectState = actualState
let isSynthesizedRejection = false
// Find FILTERING rounds that come before this eval round in sortOrder
const evalSortOrder = rounds.findIndex((r) => r.id === evalRound.id)
const roundSortOrder = rounds.findIndex((r) => r.id === round.id)
const precedingFilterRounds = filteringRounds.filter((fr) => {
const frIdx = rounds.findIndex((r) => r.id === fr.id)
return frIdx < evalSortOrder
return frIdx < roundSortOrder
})
for (const fr of precedingFilterRounds) {
@@ -1475,12 +1519,12 @@ export const applicantRouter = router({
}
entries.push({
id: evalRound.id,
label: evalRound.name,
roundType: 'EVALUATION',
status: evalRound.status,
windowOpenAt: evalRound.windowOpenAt,
windowCloseAt: evalRound.windowCloseAt,
id: round.id,
label: round.name,
roundType: round.roundType,
status: round.status,
windowOpenAt: round.windowOpenAt,
windowCloseAt: round.windowCloseAt,
projectState,
isSynthesizedRejection,
})
@@ -1913,6 +1957,59 @@ export const applicantRouter = router({
return { success: true }
}),
/**
* Delete project logo (applicant access).
*/
deleteProjectLogo: protectedProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ ctx, input }) => {
const isMember = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true },
})
if (!isMember) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' })
}
const logoConfig: ImageUploadConfig<{ logoKey: string | null; logoProvider: string | null }> = {
label: 'logo',
generateKey: generateLogoKey,
findCurrent: (prisma, entityId) =>
prisma.project.findUnique({
where: { id: entityId },
select: { logoKey: true, logoProvider: true },
}),
getImageKey: (record) => record.logoKey,
getProviderType: (record) =>
(record.logoProvider as StorageProviderType) || 's3',
setImage: (prisma, entityId, key, providerType) =>
prisma.project.update({
where: { id: entityId },
data: { logoKey: key, logoProvider: providerType },
}),
clearImage: (prisma, entityId) =>
prisma.project.update({
where: { id: entityId },
data: { logoKey: null, logoProvider: null },
}),
auditEntityType: 'Project',
auditFieldName: 'logoKey',
}
return deleteImage(ctx.prisma, logoConfig, input.projectId, {
userId: ctx.user.id,
ip: ctx.ip,
userAgent: ctx.userAgent,
})
}),
/**
* Get project logo URL (applicant access).
*/
@@ -1932,4 +2029,196 @@ export const applicantRouter = router({
return getImageUrl(ctx.prisma, logoConfig, input.projectId)
}),
/**
* Withdraw from competition. Only team lead can withdraw.
* Finds the current active (non-terminal) ProjectRoundState and transitions to WITHDRAWN.
*/
/**
* Get mentoring request status for a project in a MENTORING round
*/
getMentoringRequestStatus: protectedProcedure
.input(z.object({ projectId: z.string(), roundId: z.string() }))
.query(async ({ ctx, input }) => {
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
}
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { id: true, roundType: true, status: true, configJson: true, windowOpenAt: true },
})
if (!round || round.roundType !== 'MENTORING') {
return { available: false, requested: false, requestedAt: null, deadline: null, canStillRequest: false }
}
const config = MentoringConfigSchema.safeParse(round.configJson)
const deadlineDays = config.success ? config.data.mentoringRequestDeadlineDays : 14
const deadline = round.windowOpenAt
? new Date(new Date(round.windowOpenAt).getTime() + deadlineDays * 24 * 60 * 60 * 1000)
: null
const canStillRequest = round.status === 'ROUND_ACTIVE' && (!deadline || new Date() < deadline)
const prs = await ctx.prisma.projectRoundState.findUnique({
where: { projectId_roundId: { projectId: input.projectId, roundId: input.roundId } },
select: { metadataJson: true },
})
const metadata = (prs?.metadataJson as Record<string, unknown>) ?? {}
const requested = !!metadata.mentoringRequested
const requestedAt = metadata.mentoringRequestedAt ? new Date(metadata.mentoringRequestedAt as string) : null
return { available: true, requested, requestedAt, deadline, canStillRequest }
}),
/**
* Request or cancel mentoring for the current MENTORING round
*/
requestMentoring: protectedProcedure
.input(z.object({ projectId: z.string(), roundId: z.string(), requesting: z.boolean() }))
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can request mentoring' })
}
// Verify caller is team lead
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { id: true, submittedByUserId: true, title: true },
})
if (!project) throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
if (project.submittedByUserId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the team lead can request mentoring' })
}
// Verify round is MENTORING and ACTIVE
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { id: true, roundType: true, status: true, configJson: true, windowOpenAt: true },
})
if (!round || round.roundType !== 'MENTORING') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Not a mentoring round' })
}
if (round.status !== 'ROUND_ACTIVE') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Mentoring round is not active' })
}
// Check deadline
const config = MentoringConfigSchema.safeParse(round.configJson)
const deadlineDays = config.success ? config.data.mentoringRequestDeadlineDays : 14
if (round.windowOpenAt) {
const deadline = new Date(new Date(round.windowOpenAt).getTime() + deadlineDays * 24 * 60 * 60 * 1000)
if (new Date() > deadline) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Mentoring request window has closed' })
}
}
// Find PRS
const prs = await ctx.prisma.projectRoundState.findUnique({
where: { projectId_roundId: { projectId: input.projectId, roundId: input.roundId } },
})
if (!prs) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project is not assigned to this round' })
}
const existingMeta = (prs.metadataJson as Record<string, unknown>) ?? {}
// Update metadataJson with mentoring request info
await ctx.prisma.projectRoundState.update({
where: { id: prs.id },
data: {
metadataJson: {
...existingMeta,
mentoringRequested: input.requesting,
mentoringRequestedAt: input.requesting ? new Date().toISOString() : null,
},
},
})
// If requesting mentoring and currently PASSED (pass-through), transition to IN_PROGRESS
if (input.requesting && prs.state === 'PASSED') {
await transitionProject(
input.projectId, input.roundId,
'IN_PROGRESS' as Parameters<typeof transitionProject>[2],
ctx.user.id, ctx.prisma,
)
}
await logAudit({
prisma: ctx.prisma,
action: input.requesting ? 'MENTORING_REQUESTED' : 'MENTORING_CANCELLED',
entityType: 'Project',
entityId: input.projectId,
userId: ctx.user.id,
detailsJson: { roundId: input.roundId, projectTitle: project.title },
})
return { success: true, requesting: input.requesting }
}),
withdrawFromCompetition: protectedProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can withdraw' })
}
// Verify caller is team lead (submittedByUserId)
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { id: true, submittedByUserId: true, title: true },
})
if (!project) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
}
if (project.submittedByUserId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the team lead can withdraw from the competition' })
}
// Find the active (non-terminal) ProjectRoundState
const activePrs = await ctx.prisma.projectRoundState.findFirst({
where: {
projectId: input.projectId,
round: { status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] } },
},
include: { round: { select: { id: true, name: true } } },
orderBy: { round: { sortOrder: 'desc' } },
})
if (!activePrs || isTerminalState(activePrs.state as Parameters<typeof isTerminalState>[0])) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No active round participation to withdraw from' })
}
const result = await transitionProject(
input.projectId,
activePrs.roundId,
'WITHDRAWN' as Parameters<typeof transitionProject>[2],
ctx.user.id,
ctx.prisma,
)
if (!result.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: result.errors?.join('; ') ?? 'Failed to withdraw',
})
}
// Audit log
await logAudit({
prisma: ctx.prisma,
action: 'WITHDRAWAL',
entityType: 'Project',
entityId: input.projectId,
userId: ctx.user.id,
detailsJson: { roundId: activePrs.roundId, roundName: activePrs.round.name, projectTitle: project.title },
})
return { success: true, roundId: activePrs.roundId, roundName: activePrs.round.name }
}),
})

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 { triggerInProgressOnActivity, checkEvaluationCompletionAndTransition } from '../services/round-engine'
/**
* Auto-trigger AI ranking if all required assignments for the round are complete.
@@ -377,6 +378,12 @@ export const evaluationRouter = router({
// Auto-trigger ranking if all assignments complete (fire-and-forget, never awaited)
void triggerAutoRankIfComplete(evaluation.assignment.roundId, ctx.prisma, ctx.user.id)
// Auto-transition: mark project IN_PROGRESS and check if all evaluations are done
const projectId = evaluation.assignment.projectId
const roundIdForTransition = evaluation.assignment.roundId
await triggerInProgressOnActivity(projectId, roundIdForTransition, ctx.user.id, ctx.prisma)
await checkEvaluationCompletionAndTransition(projectId, roundIdForTransition, ctx.user.id, ctx.prisma)
// Audit log
await logAudit({
prisma: ctx.prisma,

View File

@@ -11,6 +11,7 @@ import {
notifyAdmins,
NotificationTypes,
} from '../services/in-app-notification'
import { triggerInProgressOnActivity } from '../services/round-engine'
/**
* Verify the current session user exists in the database.
@@ -204,6 +205,11 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
})
}
// Auto-transition: mark all projects as IN_PROGRESS when filtering starts
for (const pss of projectStates) {
await triggerInProgressOnActivity(pss.projectId, roundId, userId, prisma)
}
// Execute rules — upsert results per batch for streaming to the UI
const results = await executeFilteringRules(rules, projects, userId, roundId, onProgress, async (batchResults) => {
if (batchResults.length === 0) return

View File

@@ -21,6 +21,7 @@ import {
addFileComment as workspaceAddFileComment,
promoteFile as workspacePromoteFile,
} from '../services/mentor-workspace'
import { triggerInProgressOnActivity } from '../services/round-engine'
export const mentorRouter = router({
/**
@@ -211,6 +212,23 @@ export const mentorRouter = router({
},
})
// Auto-transition: mark project IN_PROGRESS in any active MENTORING round
try {
const mentoringPrs = await ctx.prisma.projectRoundState.findFirst({
where: {
projectId: input.projectId,
round: { roundType: 'MENTORING', status: { in: ['ROUND_ACTIVE', 'ROUND_CLOSED'] } },
state: 'PENDING',
},
select: { roundId: true },
})
if (mentoringPrs) {
await triggerInProgressOnActivity(input.projectId, mentoringPrs.roundId, ctx.user.id, ctx.prisma)
}
} catch (e) {
console.error('[Mentor] triggerInProgressOnActivity failed (non-fatal):', e)
}
return assignment
}),

View File

@@ -2,7 +2,7 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { sendStyledNotificationEmail } from '@/lib/email'
import { sendStyledNotificationEmail, getEmailPreviewHtml } from '@/lib/email'
export const messageRouter = router({
/**
@@ -12,7 +12,7 @@ export const messageRouter = router({
send: adminProcedure
.input(
z.object({
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'PROGRAM_TEAM', 'ALL']),
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
recipientFilter: z.any().optional(),
roundId: z.string().optional(),
subject: z.string().min(1).max(500),
@@ -371,6 +371,34 @@ export const messageRouter = router({
return template
}),
/**
* Preview styled email HTML for admin compose dialog.
*/
previewEmail: adminProcedure
.input(z.object({ subject: z.string(), body: z.string() }))
.query(({ input }) => {
return { html: getEmailPreviewHtml(input.subject, input.body) }
}),
/**
* Send a test email to the currently logged-in admin.
*/
sendTest: adminProcedure
.input(z.object({ subject: z.string(), body: z.string() }))
.mutation(async ({ ctx, input }) => {
await sendStyledNotificationEmail(
ctx.user.email,
ctx.user.name || '',
'MESSAGE',
{
title: input.subject,
message: input.body,
linkUrl: '/admin/messages',
}
)
return { sent: true, to: ctx.user.email }
}),
})
// =============================================================================
@@ -419,6 +447,35 @@ async function resolveRecipients(
return assignments.map((a) => a.userId)
}
case 'ROUND_APPLICANTS': {
const targetRoundId = roundId || (filter?.roundId as string)
if (!targetRoundId) return []
// Get all projects in this round
const projectStates = await prisma.projectRoundState.findMany({
where: { roundId: targetRoundId },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
if (projectIds.length === 0) return []
// Get team members + submittedByUserId
const [teamMembers, projects] = await Promise.all([
prisma.teamMember.findMany({
where: { projectId: { in: projectIds } },
select: { userId: true },
}),
prisma.project.findMany({
where: { id: { in: projectIds } },
select: { submittedByUserId: true },
}),
])
const userIds = new Set<string>()
for (const tm of teamMembers) userIds.add(tm.userId)
for (const p of projects) {
if (p.submittedByUserId) userIds.add(p.submittedByUserId)
}
return [...userIds]
}
case 'PROGRAM_TEAM': {
const programId = filter?.programId as string
if (!programId) return []

View File

@@ -2,72 +2,6 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import { sendAnnouncementEmail } from '@/lib/email'
import type { PrismaClient } from '@prisma/client'
/**
* Send round-entry notification emails to project team members.
* Fire-and-forget: errors are logged but never block the assignment.
*/
async function sendRoundEntryEmails(
prisma: PrismaClient,
projectIds: string[],
roundName: string,
) {
try {
// Fetch projects with team members' user emails + fallback submittedByEmail
const projects = await prisma.project.findMany({
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: {
user: { select: { email: true, name: true } },
},
},
},
})
const emailPromises: Promise<void>[] = []
for (const project of projects) {
// Collect unique emails for this project
const recipients = new Map<string, string | null>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, tm.user.name)
}
}
// Fallback: if no team members have emails, use submittedByEmail
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
}
for (const [email, name] of recipients) {
emailPromises.push(
sendAnnouncementEmail(
email,
name,
`Your project has entered: ${roundName}`,
`Your project "${project.title}" has been added to the round "${roundName}" in the Monaco Ocean Protection Challenge. You will receive further instructions as the round progresses.`,
'View Your Dashboard',
`${process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'}/dashboard`,
).catch((err) => {
console.error(`[round-entry-email] Failed to send to ${email}:`, err)
}),
)
}
}
await Promise.allSettled(emailPromises)
} catch (err) {
console.error('[round-entry-email] Failed to send round entry emails:', err)
}
}
/**
* Project Pool Router
@@ -199,7 +133,7 @@ export const projectPoolRouter = router({
assignToRound: adminProcedure
.input(
z.object({
projectIds: z.array(z.string()).min(1).max(200),
projectIds: z.array(z.string()).min(1).max(1000),
roundId: z.string(),
})
)
@@ -228,10 +162,10 @@ export const projectPoolRouter = router({
})
}
// Verify round exists and get config
// Verify round exists
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true, name: true, configJson: true },
select: { id: true, name: true },
})
// Step 2: Perform bulk assignment in a transaction
@@ -284,12 +218,6 @@ export const projectPoolRouter = router({
userAgent: ctx.userAgent,
})
// Send round-entry notification emails if enabled (fire-and-forget)
const config = (round.configJson as Record<string, unknown>) || {}
if (config.notifyOnEntry) {
void sendRoundEntryEmails(ctx.prisma as unknown as PrismaClient, projectIds, round.name)
}
return {
success: true,
assignedCount: result.count,
@@ -313,10 +241,10 @@ export const projectPoolRouter = router({
.mutation(async ({ ctx, input }) => {
const { programId, roundId, competitionCategory, unassignedOnly } = input
// Verify round exists and get config
const round = await ctx.prisma.round.findUniqueOrThrow({
// Verify round exists
await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { id: true, name: true, configJson: true },
select: { id: true },
})
// Find projects to assign
@@ -388,12 +316,213 @@ export const projectPoolRouter = router({
userAgent: ctx.userAgent,
})
// Send round-entry notification emails if enabled (fire-and-forget)
const config = (round.configJson as Record<string, unknown>) || {}
if (config.notifyOnEntry) {
void sendRoundEntryEmails(ctx.prisma as unknown as PrismaClient, projectIds, round.name)
}
return { success: true, assignedCount: result.count, roundId }
}),
/**
* List projects in a specific round (for import-from-round picker).
*/
getProjectsInRound: adminProcedure
.input(
z.object({
roundId: z.string(),
states: z.array(z.string()).optional(),
search: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const { roundId, states, search } = input
const where: Record<string, unknown> = { roundId }
if (states && states.length > 0) {
where.state = { in: states }
}
if (search?.trim()) {
where.project = {
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ teamName: { contains: search, mode: 'insensitive' } },
],
}
}
const projectStates = await ctx.prisma.projectRoundState.findMany({
where,
select: {
projectId: true,
state: true,
project: {
select: {
id: true,
title: true,
teamName: true,
competitionCategory: true,
country: true,
},
},
},
orderBy: { project: { title: 'asc' } },
})
return projectStates.map((ps) => ({
id: ps.project.id,
title: ps.project.title,
teamName: ps.project.teamName,
competitionCategory: ps.project.competitionCategory,
country: ps.project.country,
state: ps.state,
}))
}),
/**
* Import projects from an earlier round into a later round.
* Fills intermediate rounds with COMPLETED states to keep history clean.
*/
importFromRound: adminProcedure
.input(
z.object({
sourceRoundId: z.string(),
targetRoundId: z.string(),
projectIds: z.array(z.string()).min(1).max(1000),
})
)
.mutation(async ({ ctx, input }) => {
const { sourceRoundId, targetRoundId, projectIds } = input
// Validate both rounds exist and belong to the same competition
const [sourceRound, targetRound] = await Promise.all([
ctx.prisma.round.findUniqueOrThrow({
where: { id: sourceRoundId },
select: { id: true, name: true, competitionId: true, sortOrder: true },
}),
ctx.prisma.round.findUniqueOrThrow({
where: { id: targetRoundId },
select: { id: true, name: true, competitionId: true, sortOrder: true },
}),
])
if (sourceRound.competitionId !== targetRound.competitionId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Source and target rounds must belong to the same competition',
})
}
if (sourceRound.sortOrder >= targetRound.sortOrder) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Source round must come before target round',
})
}
// Validate all projectIds exist in the source round
const sourceStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId: sourceRoundId, projectId: { in: projectIds } },
select: { projectId: true, state: true },
})
if (sourceStates.length !== projectIds.length) {
const foundIds = new Set(sourceStates.map((s) => s.projectId))
const missing = projectIds.filter((id) => !foundIds.has(id))
throw new TRPCError({
code: 'BAD_REQUEST',
message: `${missing.length} project(s) not found in source round`,
})
}
// Find intermediate rounds
const intermediateRounds = await ctx.prisma.round.findMany({
where: {
competitionId: sourceRound.competitionId,
sortOrder: { gt: sourceRound.sortOrder, lt: targetRound.sortOrder },
},
select: { id: true },
orderBy: { sortOrder: 'asc' },
})
// Check which projects are already in the target round
const existingInTarget = await ctx.prisma.projectRoundState.findMany({
where: { roundId: targetRoundId, projectId: { in: projectIds } },
select: { projectId: true },
})
const alreadyInTarget = new Set(existingInTarget.map((e) => e.projectId))
const toImport = projectIds.filter((id) => !alreadyInTarget.has(id))
if (toImport.length === 0) {
return { imported: 0, skipped: projectIds.length }
}
await ctx.prisma.$transaction(async (tx) => {
// Update source round states to COMPLETED (if PASSED or PENDING)
await tx.projectRoundState.updateMany({
where: {
roundId: sourceRoundId,
projectId: { in: toImport },
state: { in: ['PASSED', 'PENDING', 'IN_PROGRESS'] },
},
data: { state: 'COMPLETED' },
})
// Create COMPLETED states for intermediate rounds
if (intermediateRounds.length > 0) {
const intermediateData = intermediateRounds.flatMap((round) =>
toImport.map((projectId) => ({
projectId,
roundId: round.id,
state: 'COMPLETED' as const,
}))
)
await tx.projectRoundState.createMany({
data: intermediateData,
skipDuplicates: true,
})
}
// Create PENDING states in the target round
await tx.projectRoundState.createMany({
data: toImport.map((projectId) => ({
projectId,
roundId: targetRoundId,
})),
skipDuplicates: true,
})
// Update project status to ASSIGNED
await tx.project.updateMany({
where: { id: { in: toImport } },
data: { status: 'ASSIGNED' },
})
// Create status history records
await tx.projectStatusHistory.createMany({
data: toImport.map((projectId) => ({
projectId,
status: 'ASSIGNED',
changedBy: ctx.user?.id,
})),
})
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user?.id,
action: 'IMPORT_FROM_ROUND',
entityType: 'Project',
detailsJson: {
sourceRoundId,
sourceRoundName: sourceRound.name,
targetRoundId,
targetRoundName: targetRound.name,
importedCount: toImport.length,
skippedCount: alreadyInTarget.size,
intermediateRounds: intermediateRounds.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { imported: toImport.length, skipped: alreadyInTarget.size }
}),
})

View File

@@ -6,7 +6,14 @@ import { logAudit } from '@/server/utils/audit'
import { validateRoundConfig, defaultRoundConfig } from '@/types/competition-configs'
import { generateShortlist } from '../services/ai-shortlist'
import { createBulkNotifications } from '../services/in-app-notification'
import { sendAnnouncementEmail } from '@/lib/email'
import {
getAdvancementNotificationTemplate,
getRejectionNotificationTemplate,
sendStyledNotificationEmail,
sendInvitationEmail,
getBaseUrl,
} from '@/lib/email'
import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite'
import {
openWindow,
closeWindow,
@@ -417,95 +424,6 @@ export const roundRouter = router({
userAgent: ctx.userAgent,
})
// Fix 5: notifyOnEntry — notify team members when projects enter target round
try {
const targetConfig = (targetRound.configJson as Record<string, unknown>) || {}
if (targetConfig.notifyOnEntry) {
const teamMembers = await ctx.prisma.teamMember.findMany({
where: { projectId: { in: idsToAdvance! } },
select: { userId: true },
})
const userIds = [...new Set(teamMembers.map((tm) => tm.userId))]
if (userIds.length > 0) {
void createBulkNotifications({
userIds,
type: 'round_entry',
title: `Projects entered: ${targetRound.name}`,
message: `Your project has been advanced to the round "${targetRound.name}".`,
linkUrl: '/dashboard',
linkLabel: 'View Dashboard',
icon: 'ArrowRight',
})
}
}
} catch (notifyErr) {
console.error('[advanceProjects] notifyOnEntry notification failed (non-fatal):', notifyErr)
}
// Fix 6: notifyOnAdvance — notify applicants from source round that projects advanced
try {
const sourceConfig = (currentRound.configJson as Record<string, unknown>) || {}
if (sourceConfig.notifyOnAdvance) {
const projects = await ctx.prisma.project.findMany({
where: { id: { in: idsToAdvance! } },
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: { user: { select: { id: true, email: true, name: true } } },
},
},
})
// Collect unique user IDs for in-app notifications
const applicantUserIds = new Set<string>()
for (const project of projects) {
for (const tm of project.teamMembers) {
applicantUserIds.add(tm.user.id)
}
}
if (applicantUserIds.size > 0) {
void createBulkNotifications({
userIds: [...applicantUserIds],
type: 'project_advanced',
title: 'Your project has advanced!',
message: `Congratulations! Your project has advanced from "${currentRound.name}" to "${targetRound.name}".`,
linkUrl: '/dashboard',
linkLabel: 'View Dashboard',
icon: 'Trophy',
priority: 'high',
})
}
// Send emails to team members (fire-and-forget)
for (const project of projects) {
const recipients = new Map<string, string | null>()
for (const tm of project.teamMembers) {
if (tm.user.email) recipients.set(tm.user.email, tm.user.name)
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
}
for (const [email, name] of recipients) {
void sendAnnouncementEmail(
email,
name,
`Your project has advanced to: ${targetRound.name}`,
`Congratulations! Your project "${project.title}" has advanced from "${currentRound.name}" to "${targetRound.name}" in the Monaco Ocean Protection Challenge.`,
'View Your Dashboard',
`${process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'}/dashboard`,
).catch((err) => {
console.error(`[advanceProjects] notifyOnAdvance email failed for ${email}:`, err)
})
}
}
}
} catch (notifyErr) {
console.error('[advanceProjects] notifyOnAdvance notification failed (non-fatal):', notifyErr)
}
return {
advancedCount: idsToAdvance!.length,
autoPassedCount,
@@ -883,4 +801,477 @@ export const roundRouter = router({
})
return round ?? null
}),
// =========================================================================
// Notification Procedures
// =========================================================================
previewAdvancementEmail: adminProcedure
.input(
z.object({
roundId: z.string(),
targetRoundId: z.string().optional(),
customMessage: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const { roundId, targetRoundId, customMessage } = input
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { name: true, competition: { select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } } } },
})
// Determine target round name
const rounds = currentRound.competition.rounds
const currentIdx = rounds.findIndex((r) => r.id === roundId)
const targetRound = targetRoundId
? rounds.find((r) => r.id === targetRoundId)
: rounds[currentIdx + 1]
const toRoundName = targetRound?.name ?? 'Next Round'
// Count recipients: team members of PASSED or COMPLETED projects in this round
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: { in: ['PASSED', 'COMPLETED'] } },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
let recipientCount = 0
if (projectIds.length > 0) {
const teamMembers = await ctx.prisma.teamMember.findMany({
where: { projectId: { in: projectIds } },
select: { user: { select: { email: true } } },
})
const emails = new Set(teamMembers.map((tm) => tm.user.email).filter(Boolean))
// Also count submittedByEmail for projects without team member emails
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: { submittedByEmail: true, teamMembers: { select: { user: { select: { email: true } } } } },
})
for (const p of projects) {
const hasTeamEmail = p.teamMembers.some((tm) => tm.user.email)
if (!hasTeamEmail && p.submittedByEmail) {
emails.add(p.submittedByEmail)
}
}
recipientCount = emails.size
}
// Build preview HTML
const template = getAdvancementNotificationTemplate(
'Team Member',
'Your Project',
currentRound.name,
toRoundName,
customMessage || undefined
)
return { html: template.html, subject: template.subject, recipientCount }
}),
sendAdvancementNotifications: adminProcedure
.input(
z.object({
roundId: z.string(),
targetRoundId: z.string().optional(),
customMessage: z.string().optional(),
projectIds: z.array(z.string()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, targetRoundId, customMessage } = input
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { name: true, competition: { select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } } } },
})
const rounds = currentRound.competition.rounds
const currentIdx = rounds.findIndex((r) => r.id === roundId)
const targetRound = targetRoundId
? rounds.find((r) => r.id === targetRoundId)
: rounds[currentIdx + 1]
const toRoundName = targetRound?.name ?? 'Next Round'
// Get target projects
let projectIds = input.projectIds
if (!projectIds) {
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: { in: ['PASSED', 'COMPLETED'] } },
select: { projectId: true },
})
projectIds = projectStates.map((ps) => ps.projectId)
}
if (projectIds.length === 0) {
return { sent: 0, failed: 0 }
}
// Fetch projects with team members
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: { user: { select: { id: true, email: true, name: true } } },
},
},
})
let sent = 0
let failed = 0
const allUserIds = new Set<string>()
for (const project of projects) {
const recipients = new Map<string, string | null>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, tm.user.name)
allUserIds.add(tm.user.id)
}
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
}
for (const [email, name] of recipients) {
try {
await sendStyledNotificationEmail(
email,
name || '',
'ADVANCEMENT_NOTIFICATION',
{
title: 'Your project has advanced!',
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
fromRoundName: currentRound.name,
toRoundName,
customMessage: customMessage || undefined,
},
}
)
sent++
} catch (err) {
console.error(`[sendAdvancementNotifications] Failed for ${email}:`, err)
failed++
}
}
}
// Create in-app notifications
if (allUserIds.size > 0) {
void createBulkNotifications({
userIds: [...allUserIds],
type: 'project_advanced',
title: 'Your project has advanced!',
message: `Your project has advanced from "${currentRound.name}" to "${toRoundName}".`,
linkUrl: '/applicant',
linkLabel: 'View Dashboard',
icon: 'Trophy',
priority: 'high',
})
}
// Audit
await logAudit({
prisma: ctx.prisma,
userId: ctx.user?.id,
action: 'SEND_ADVANCEMENT_NOTIFICATIONS',
entityType: 'Round',
entityId: roundId,
detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent, failed }
}),
previewRejectionEmail: adminProcedure
.input(
z.object({
roundId: z.string(),
customMessage: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const { roundId, customMessage } = input
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { name: true },
})
// Count recipients: team members of REJECTED projects
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
let recipientCount = 0
if (projectIds.length > 0) {
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: { submittedByEmail: true, teamMembers: { select: { user: { select: { email: true } } } } },
})
const emails = new Set<string>()
for (const p of projects) {
const hasTeamEmail = p.teamMembers.some((tm) => tm.user.email)
if (hasTeamEmail) {
for (const tm of p.teamMembers) {
if (tm.user.email) emails.add(tm.user.email)
}
} else if (p.submittedByEmail) {
emails.add(p.submittedByEmail)
}
}
recipientCount = emails.size
}
const template = getRejectionNotificationTemplate(
'Team Member',
'Your Project',
round.name,
customMessage || undefined
)
return { html: template.html, subject: template.subject, recipientCount }
}),
sendRejectionNotifications: adminProcedure
.input(
z.object({
roundId: z.string(),
customMessage: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, customMessage } = input
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { name: true },
})
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
if (projectIds.length === 0) {
return { sent: 0, failed: 0 }
}
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: { user: { select: { id: true, email: true, name: true } } },
},
},
})
let sent = 0
let failed = 0
const allUserIds = new Set<string>()
for (const project of projects) {
const recipients = new Map<string, string | null>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, tm.user.name)
allUserIds.add(tm.user.id)
}
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
}
for (const [email, name] of recipients) {
try {
await sendStyledNotificationEmail(
email,
name || '',
'REJECTION_NOTIFICATION',
{
title: 'Update on your application',
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
roundName: round.name,
customMessage: customMessage || undefined,
},
}
)
sent++
} catch (err) {
console.error(`[sendRejectionNotifications] Failed for ${email}:`, err)
failed++
}
}
}
// In-app notifications
if (allUserIds.size > 0) {
void createBulkNotifications({
userIds: [...allUserIds],
type: 'NOT_SELECTED',
title: 'Update on your application',
message: `Your project was not selected to advance from "${round.name}".`,
linkUrl: '/applicant',
linkLabel: 'View Dashboard',
icon: 'Info',
})
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user?.id,
action: 'SEND_REJECTION_NOTIFICATIONS',
entityType: 'Round',
entityId: roundId,
detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent, failed }
}),
getBulkInvitePreview: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const { roundId } = input
// Get all projects in this round
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
if (projectIds.length === 0) {
return { uninvitedCount: 0, totalTeamMembers: 0, alreadyInvitedCount: 0 }
}
// Get all team members for these projects
const teamMembers = await ctx.prisma.teamMember.findMany({
where: { projectId: { in: projectIds } },
select: { user: { select: { id: true, status: true } } },
})
// Deduplicate by user ID
const userMap = new Map<string, string>()
for (const tm of teamMembers) {
userMap.set(tm.user.id, tm.user.status)
}
let uninvitedCount = 0
let alreadyInvitedCount = 0
for (const [, status] of userMap) {
if (status === 'ACTIVE' || status === 'INVITED') {
alreadyInvitedCount++
} else {
uninvitedCount++
}
}
return {
uninvitedCount,
totalTeamMembers: userMap.size,
alreadyInvitedCount,
}
}),
bulkInviteTeamMembers: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const { roundId } = input
// Get all projects in this round
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
if (projectIds.length === 0) {
return { invited: 0, skipped: 0, failed: 0 }
}
// Get all team members with user details
const teamMembers = await ctx.prisma.teamMember.findMany({
where: { projectId: { in: projectIds } },
select: {
user: { select: { id: true, email: true, name: true, status: true, role: true } },
},
})
// Deduplicate by user ID
const users = new Map<string, { id: string; email: string; name: string | null; status: string; role: string }>()
for (const tm of teamMembers) {
if (tm.user.email && !users.has(tm.user.id)) {
users.set(tm.user.id, tm.user)
}
}
const baseUrl = getBaseUrl()
const expiryHours = await getInviteExpiryHours(ctx.prisma as unknown as import('@prisma/client').PrismaClient)
const expiryMs = expiryHours * 60 * 60 * 1000
let invited = 0
let skipped = 0
let failed = 0
for (const [, user] of users) {
if (user.status === 'ACTIVE' || user.status === 'INVITED') {
skipped++
continue
}
try {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
invited++
} catch (err) {
console.error(`[bulkInviteTeamMembers] Failed for ${user.email}:`, err)
failed++
}
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user?.id,
action: 'BULK_INVITE_TEAM_MEMBERS',
entityType: 'Round',
entityId: roundId,
detailsJson: { invited, skipped, failed, totalUsers: users.size },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { invited, skipped, failed }
}),
})

View File

@@ -11,6 +11,11 @@ import {
getProjectRoundStates,
getProjectRoundState,
} from '../services/round-engine'
import {
processRoundClose,
getFinalizationSummary,
confirmFinalization,
} from '../services/round-finalization'
const projectRoundStateEnum = z.enum([
'PENDING',
@@ -318,4 +323,110 @@ export const roundEngineRouter = router({
projectIds: result.projectIds,
}
}),
// ─── Finalization Procedures ────────────────────────────────────────────
getFinalizationSummary: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
return getFinalizationSummary(input.roundId, ctx.prisma)
}),
updateProposedOutcome: adminProcedure
.input(z.object({
roundId: z.string(),
projectId: z.string(),
proposedOutcome: projectRoundStateEnum,
}))
.mutation(async ({ ctx, input }) => {
const prs = await ctx.prisma.projectRoundState.findUnique({
where: { projectId_roundId: { projectId: input.projectId, roundId: input.roundId } },
})
if (!prs) throw new TRPCError({ code: 'NOT_FOUND', message: 'Project round state not found' })
return ctx.prisma.projectRoundState.update({
where: { id: prs.id },
data: { proposedOutcome: input.proposedOutcome },
})
}),
batchUpdateProposedOutcomes: adminProcedure
.input(z.object({
roundId: z.string(),
outcomes: z.record(z.string(), projectRoundStateEnum),
}))
.mutation(async ({ ctx, input }) => {
let updated = 0
for (const [projectId, outcome] of Object.entries(input.outcomes)) {
await ctx.prisma.projectRoundState.updateMany({
where: { projectId, roundId: input.roundId },
data: { proposedOutcome: outcome },
})
updated++
}
return { updated }
}),
confirmFinalization: adminProcedure
.input(z.object({
roundId: z.string(),
targetRoundId: z.string().optional(),
advancementMessage: z.string().optional(),
rejectionMessage: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
return confirmFinalization(
input.roundId,
{
targetRoundId: input.targetRoundId,
advancementMessage: input.advancementMessage,
rejectionMessage: input.rejectionMessage,
},
ctx.user.id,
ctx.prisma,
)
}),
endGracePeriod: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId } })
if (round.status !== 'ROUND_CLOSED') {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Round must be ROUND_CLOSED' })
}
if (!round.gracePeriodEndsAt) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Round has no grace period set' })
}
// Clear grace period and process
await ctx.prisma.round.update({
where: { id: input.roundId },
data: { gracePeriodEndsAt: new Date() },
})
const result = await processRoundClose(input.roundId, ctx.user.id, ctx.prisma)
return { processed: result.processed }
}),
/**
* Manually trigger processRoundClose for already-closed rounds.
* Used when a round was closed before the finalization system existed,
* or when processRoundClose failed silently on close.
*/
processRoundProjects: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId } })
if (round.status !== 'ROUND_CLOSED' && round.status !== 'ROUND_ARCHIVED') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Round must be ROUND_CLOSED or ROUND_ARCHIVED, got ${round.status}`,
})
}
const result = await processRoundClose(input.roundId, ctx.user.id, ctx.prisma)
return { processed: result.processed }
}),
})

View File

@@ -4,6 +4,8 @@ import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import { processEligibilityJob } from '../services/award-eligibility-job'
import { sendStyledNotificationEmail } from '@/lib/email'
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
import type { PrismaClient } from '@prisma/client'
/**
@@ -1182,6 +1184,181 @@ export const specialAwardRouter = router({
return round
}),
// ─── Pool Notifications ─────────────────────────────────────────────────
/**
* Get account stats for eligible projects (how many need invite vs have account)
*/
getNotificationStats: adminProcedure
.input(z.object({ awardId: z.string() }))
.query(async ({ ctx, input }) => {
const eligibilities = await ctx.prisma.awardEligibility.findMany({
where: { awardId: input.awardId, eligible: true },
select: {
project: {
select: {
submittedBy: { select: { id: true, passwordHash: true } },
teamMembers: {
select: { user: { select: { id: true, passwordHash: true } } },
},
},
},
},
})
const seen = new Set<string>()
let needsInvite = 0
let hasAccount = 0
for (const e of eligibilities) {
const submitter = e.project.submittedBy
if (submitter && !seen.has(submitter.id)) {
seen.add(submitter.id)
if (submitter.passwordHash) hasAccount++
else needsInvite++
}
for (const tm of e.project.teamMembers) {
if (!seen.has(tm.user.id)) {
seen.add(tm.user.id)
if (tm.user.passwordHash) hasAccount++
else needsInvite++
}
}
}
return { needsInvite, hasAccount, totalProjects: eligibilities.length }
}),
/**
* Notify eligible projects that they've been selected for an award.
* Generates invite tokens for passwordless users.
*/
notifyEligibleProjects: adminProcedure
.input(z.object({
awardId: z.string(),
customMessage: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
select: { id: true, name: true, description: true, status: true },
})
// Get eligible projects with submitter + team members
const eligibilities = await ctx.prisma.awardEligibility.findMany({
where: { awardId: input.awardId, eligible: true },
select: {
id: true,
projectId: true,
project: {
select: {
id: true,
title: true,
submittedBy: {
select: { id: true, email: true, name: true, passwordHash: true },
},
teamMembers: {
select: {
user: {
select: { id: true, email: true, name: true, passwordHash: true },
},
},
},
},
},
},
})
if (eligibilities.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No eligible projects to notify',
})
}
// Pre-generate invite tokens for passwordless users
const expiryMs = await getInviteExpiryMs(ctx.prisma)
const expiresAt = new Date(Date.now() + expiryMs)
const tokenMap = new Map<string, string>() // userId -> token
const allUsers: Array<{ id: string; passwordHash: string | null }> = []
for (const e of eligibilities) {
if (e.project.submittedBy) allUsers.push(e.project.submittedBy)
for (const tm of e.project.teamMembers) allUsers.push(tm.user)
}
const passwordlessUsers = allUsers.filter((u) => !u.passwordHash)
const uniquePasswordless = [...new Map(passwordlessUsers.map((u) => [u.id, u])).values()]
for (const user of uniquePasswordless) {
const token = generateInviteToken()
tokenMap.set(user.id, token)
await ctx.prisma.user.update({
where: { id: user.id },
data: { inviteToken: token, inviteTokenExpiresAt: expiresAt },
})
}
// Send emails
let emailsSent = 0
let emailsFailed = 0
for (const e of eligibilities) {
const recipients: Array<{ id: string; email: string; name: string | null; passwordHash: string | null }> = []
if (e.project.submittedBy) recipients.push(e.project.submittedBy)
for (const tm of e.project.teamMembers) {
if (!recipients.some((r) => r.id === tm.user.id)) {
recipients.push(tm.user)
}
}
for (const recipient of recipients) {
const token = tokenMap.get(recipient.id)
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
try {
await sendStyledNotificationEmail(
recipient.email,
recipient.name || '',
'AWARD_SELECTION_NOTIFICATION',
{
title: `Selected for ${award.name}`,
message: input.customMessage || '',
metadata: {
projectName: e.project.title,
awardName: award.name,
customMessage: input.customMessage,
accountUrl,
},
},
)
emailsSent++
} catch (err) {
console.error(`[award-notify] Failed to email ${recipient.email}:`, err)
emailsFailed++
}
}
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'SpecialAward',
entityId: input.awardId,
detailsJson: {
action: 'NOTIFY_ELIGIBLE_PROJECTS',
eligibleCount: eligibilities.length,
emailsSent,
emailsFailed,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { notified: eligibilities.length, emailsSent, emailsFailed }
}),
/**
* Delete an award round (only if DRAFT)
*/

View File

@@ -1,4 +1,3 @@
import crypto from 'crypto'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import type { Prisma } from '@prisma/client'
@@ -8,29 +7,7 @@ import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
import { hashPassword, validatePassword } from '@/lib/password'
import { attachAvatarUrls } from '@/server/utils/avatar-url'
import { logAudit } from '@/server/utils/audit'
const DEFAULT_INVITE_EXPIRY_HOURS = 72 // 3 days
async function getInviteExpiryHours(prisma: import('@prisma/client').PrismaClient): Promise<number> {
try {
const setting = await prisma.systemSettings.findUnique({
where: { key: 'invite_link_expiry_hours' },
select: { value: true },
})
const hours = setting?.value ? parseInt(setting.value, 10) : DEFAULT_INVITE_EXPIRY_HOURS
return isNaN(hours) || hours < 1 ? DEFAULT_INVITE_EXPIRY_HOURS : hours
} catch {
return DEFAULT_INVITE_EXPIRY_HOURS
}
}
async function getInviteExpiryMs(prisma: import('@prisma/client').PrismaClient): Promise<number> {
return (await getInviteExpiryHours(prisma)) * 60 * 60 * 1000
}
function generateInviteToken(): string {
return crypto.randomBytes(32).toString('hex')
}
import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite'
export const userRouter = router({
/**
@@ -95,9 +72,21 @@ export const userRouter = router({
return { valid: false, error: 'EXPIRED_TOKEN' as const }
}
// Check if user belongs to a team (was invited as team member)
const teamMembership = await ctx.prisma.teamMember.findFirst({
where: { userId: user.id },
select: {
role: true,
project: { select: { title: true, teamName: true } },
},
})
return {
valid: true,
user: { name: user.name, email: user.email, role: user.role },
team: teamMembership
? { projectTitle: teamMembership.project.title, teamName: teamMembership.project.teamName }
: null,
}
}),

View File

@@ -18,6 +18,7 @@ import type {
Prisma,
} from '@prisma/client'
import { logAudit } from '@/server/utils/audit'
import { triggerInProgressOnActivity } from './round-engine'
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -186,7 +187,7 @@ export async function submitVote(
const runoffRound = params.runoffRound ?? 0
return prisma.deliberationVote.upsert({
const vote = await prisma.deliberationVote.upsert({
where: {
sessionId_juryMemberId_projectId_runoffRound: {
sessionId: params.sessionId,
@@ -208,6 +209,17 @@ export async function submitVote(
isWinnerPick: params.isWinnerPick ?? false,
},
})
// Auto-transition: mark project IN_PROGRESS in the deliberation round
try {
if (session.roundId) {
await triggerInProgressOnActivity(params.projectId, session.roundId, params.juryMemberId, prisma)
}
} catch (e) {
console.error('[Deliberation] triggerInProgressOnActivity failed (non-fatal):', e)
}
return vote
}
// ─── Aggregation ────────────────────────────────────────────────────────────

View File

@@ -15,6 +15,7 @@ import type { PrismaClient, ProjectRoundStateValue, Prisma } from '@prisma/clien
import { logAudit } from '@/server/utils/audit'
import { safeValidateRoundConfig } from '@/types/competition-configs'
import { expireIntentsForRound } from './assignment-intent'
import { processRoundClose } from './round-finalization'
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -55,11 +56,11 @@ const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
}
const VALID_PROJECT_TRANSITIONS: Record<string, string[]> = {
PENDING: ['IN_PROGRESS', 'PASSED', 'REJECTED', 'WITHDRAWN'],
IN_PROGRESS: ['PASSED', 'REJECTED', 'WITHDRAWN'],
PASSED: ['COMPLETED', 'WITHDRAWN'],
PENDING: ['IN_PROGRESS', 'REJECTED', 'WITHDRAWN'],
IN_PROGRESS: ['COMPLETED', 'REJECTED', 'WITHDRAWN'],
COMPLETED: ['PASSED', 'REJECTED'],
PASSED: ['IN_PROGRESS', 'WITHDRAWN'],
REJECTED: ['PENDING'], // re-include
COMPLETED: [], // terminal
WITHDRAWN: ['PENDING'], // re-include
}
@@ -174,13 +175,44 @@ export async function activateRound(
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`)
console.log(`[RoundEngine] On activation: auto-completed ${result.transitionedCount} projects with complete documents`)
}
}
} catch (retroError) {
console.error('[RoundEngine] Retroactive document check failed (non-fatal):', retroError)
}
// Mentoring pass-through: for MENTORING rounds with passThroughIfNoRequest,
// auto-set all PENDING projects to PASSED (they pass through unless they request mentoring)
if (round.roundType === 'MENTORING') {
try {
const mentoringConfig = safeValidateRoundConfig('MENTORING', round.configJson as Record<string, unknown>)
if (mentoringConfig.success && mentoringConfig.data.passThroughIfNoRequest) {
const pendingProjects = await prisma.projectRoundState.findMany({
where: { roundId, state: 'PENDING' },
select: { id: true, projectId: true, metadataJson: true },
})
let passedCount = 0
for (const prs of pendingProjects) {
const meta = (prs.metadataJson as Record<string, unknown>) ?? {}
// Only pass-through projects that haven't requested mentoring
if (!meta.mentoringRequested) {
await prisma.projectRoundState.update({
where: { id: prs.id },
data: { state: 'PASSED' },
})
passedCount++
}
}
if (passedCount > 0) {
console.log(`[RoundEngine] Mentoring pass-through: set ${passedCount} projects to PASSED`)
}
}
} catch (mentoringError) {
console.error('[RoundEngine] Mentoring pass-through failed (non-fatal):', mentoringError)
}
}
return {
success: true,
round: { id: updated.id, status: updated.status },
@@ -311,6 +343,26 @@ export async function closeRound(
detailsJson: { name: round.name, roundType: round.roundType },
})
// Grace period / immediate finalization processing
try {
const config = round.configJson ? (round.configJson as Record<string, unknown>) : {}
const gracePeriodHours = (config.gracePeriodHours as number) ?? 0
if (gracePeriodHours > 0) {
const gracePeriodEndsAt = new Date(Date.now() + gracePeriodHours * 60 * 60 * 1000)
await prisma.round.update({
where: { id: roundId },
data: { gracePeriodEndsAt },
})
console.log(`[RoundEngine] Grace period set for round ${roundId}: ${gracePeriodHours}h (until ${gracePeriodEndsAt.toISOString()})`)
} else {
await processRoundClose(roundId, actorId, prisma)
console.log(`[RoundEngine] Processed round close for ${roundId} (no grace period)`)
}
} catch (processError) {
console.error('[RoundEngine] processRoundClose after close failed (non-fatal):', processError)
}
return {
success: true,
round: { id: updated.id, status: updated.status },
@@ -559,10 +611,10 @@ export async function transitionProject(
return { success: false, errors: [`Round ${roundId} not found`] }
}
if (round.status !== 'ROUND_ACTIVE') {
if (round.status !== 'ROUND_ACTIVE' && round.status !== 'ROUND_CLOSED') {
return {
success: false,
errors: [`Round is ${round.status}, must be ROUND_ACTIVE to transition projects`],
errors: [`Round is ${round.status}, must be ROUND_ACTIVE or ROUND_CLOSED to transition projects`],
}
}
@@ -858,12 +910,17 @@ export async function checkRequirementsAndTransition(
return { transitioned: false }
}
// All requirements met — transition to PASSED
const result = await transitionProject(projectId, roundId, 'PASSED' as ProjectRoundStateValue, actorId, prisma)
// If PENDING, first transition to IN_PROGRESS so the state machine path is valid
if (currentState.state === 'PENDING') {
await triggerInProgressOnActivity(projectId, roundId, actorId, prisma)
}
// All requirements met — transition to COMPLETED (finalization will set PASSED/REJECTED)
const result = await transitionProject(projectId, roundId, 'COMPLETED' 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' }
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to COMPLETED in round ${roundId} (all ${requirements.length + submissionRequirements.length} requirements met)`)
return { transitioned: true, newState: 'COMPLETED' }
}
return { transitioned: false }
@@ -894,14 +951,85 @@ export async function batchCheckRequirementsAndTransition(
}
if (transitioned.length > 0) {
console.log(`[RoundEngine] Batch auto-transition: ${transitioned.length}/${projectIds.length} projects moved to PASSED in round ${roundId}`)
console.log(`[RoundEngine] Batch auto-transition: ${transitioned.length}/${projectIds.length} projects moved to COMPLETED in round ${roundId}`)
}
return { transitionedCount: transitioned.length, projectIds: transitioned }
}
// ─── Auto-Transition Hooks ──────────────────────────────────────────────────
/**
* Trigger PENDING → IN_PROGRESS when a project has activity.
* Non-fatal: if the project is not PENDING, this is a no-op.
*/
export async function triggerInProgressOnActivity(
projectId: string,
roundId: string,
actorId: string,
prisma: PrismaClient | any,
): Promise<void> {
try {
const prs = await prisma.projectRoundState.findUnique({
where: { projectId_roundId: { projectId, roundId } },
select: { state: true },
})
if (!prs || prs.state !== 'PENDING') return
const result = await transitionProject(projectId, roundId, 'IN_PROGRESS' as ProjectRoundStateValue, actorId, prisma)
if (result.success) {
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to IN_PROGRESS in round ${roundId}`)
}
} catch (error) {
console.error('[RoundEngine] triggerInProgressOnActivity failed (non-fatal):', error)
}
}
/**
* Check if all jury assignments for a project in an evaluation round are completed.
* If yes, transition from IN_PROGRESS → COMPLETED.
*/
export async function checkEvaluationCompletionAndTransition(
projectId: string,
roundId: string,
actorId: string,
prisma: PrismaClient | any,
): Promise<{ transitioned: boolean }> {
try {
const prs = await prisma.projectRoundState.findUnique({
where: { projectId_roundId: { projectId, roundId } },
select: { state: true },
})
if (!prs || prs.state !== 'IN_PROGRESS') return { transitioned: false }
// Check all assignments for this project in this round
const assignments = await prisma.assignment.findMany({
where: { projectId, roundId },
select: { isCompleted: true },
})
if (assignments.length === 0) return { transitioned: false }
const allCompleted = assignments.every((a: { isCompleted: boolean }) => a.isCompleted)
if (!allCompleted) return { transitioned: false }
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 ${assignments.length} evaluations done)`)
return { transitioned: true }
}
return { transitioned: false }
} catch (error) {
console.error('[RoundEngine] checkEvaluationCompletionAndTransition failed (non-fatal):', error)
return { transitioned: false }
}
}
// ─── Internals ──────────────────────────────────────────────────────────────
function isTerminalState(state: ProjectRoundStateValue): boolean {
return ['PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'].includes(state)
export function isTerminalState(state: ProjectRoundStateValue): boolean {
return ['PASSED', 'REJECTED', 'WITHDRAWN'].includes(state)
}

View File

@@ -0,0 +1,830 @@
/**
* Round Finalization Service
*
* Handles the post-close lifecycle of a round:
* - processRoundClose: auto-sets project states after a round closes
* - getFinalizationSummary: aggregates data for the finalization review UI
* - confirmFinalization: single transaction to apply outcomes, advance projects, send emails
*/
import type { PrismaClient, ProjectRoundStateValue, RoundType, Prisma } from '@prisma/client'
import { transitionProject, isTerminalState } from './round-engine'
import { logAudit } from '@/server/utils/audit'
import {
sendStyledNotificationEmail,
getRejectionNotificationTemplate,
} from '@/lib/email'
import { createBulkNotifications } from '../services/in-app-notification'
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
// ─── Types ──────────────────────────────────────────────────────────────────
export type FinalizationSummary = {
roundId: string
roundName: string
roundType: RoundType
isGracePeriodActive: boolean
gracePeriodEndsAt: Date | null
isFinalized: boolean
finalizedAt: Date | null
stats: {
pending: number
inProgress: number
completed: number
passed: number
rejected: number
withdrawn: number
}
projects: Array<{
id: string
title: string
teamName: string | null
category: string | null
country: string | null
currentState: ProjectRoundStateValue
proposedOutcome: ProjectRoundStateValue | null
evaluationScore?: number | null
rankPosition?: number | null
}>
categoryTargets: {
startupTarget: number | null
conceptTarget: number | null
startupProposed: number
conceptProposed: number
}
nextRound: { id: string; name: string } | null
accountStats: {
needsInvite: number
hasAccount: number
}
}
export type ConfirmFinalizationResult = {
advanced: number
rejected: number
emailsSent: number
emailsFailed: number
}
// ─── processRoundClose ──────────────────────────────────────────────────────
/**
* Process project states after a round closes.
* Auto-transitions projects to COMPLETED/REJECTED and sets proposedOutcome defaults.
* Called immediately on close (if no grace period) or after grace period expires.
*/
export async function processRoundClose(
roundId: string,
actorId: string,
prisma: PrismaClient | any,
): Promise<{ processed: number }> {
const round = await prisma.round.findUnique({
where: { id: roundId },
include: {
competition: {
select: {
rounds: {
select: { id: true, name: true, sortOrder: true },
orderBy: { sortOrder: 'asc' as const },
},
},
},
},
})
if (!round) throw new Error(`Round ${roundId} not found`)
const projectStates = await prisma.projectRoundState.findMany({
where: { roundId },
include: {
project: {
select: {
id: true,
competitionCategory: true,
files: { where: { roundId }, select: { id: true, requirementId: true, submissionFileRequirementId: true } },
assignments: { where: { roundId }, select: { isCompleted: true } },
filteringResults: { where: { roundId }, select: { outcome: true, finalOutcome: true } },
},
},
},
})
let processed = 0
// Pre-compute pass set for EVALUATION rounds using ranking scores + config
let evaluationPassSet: Set<string> | null = null
if ((round.roundType as RoundType) === 'EVALUATION') {
evaluationPassSet = new Set<string>()
const snapshot = await prisma.rankingSnapshot.findFirst({
where: { roundId },
orderBy: { createdAt: 'desc' as const },
select: { startupRankingJson: true, conceptRankingJson: true },
})
if (snapshot) {
const config = (round.configJson as Record<string, unknown>) ?? {}
const advanceMode = (config.advanceMode as string) || 'count'
const advanceScoreThreshold = (config.advanceScoreThreshold as number) ?? 6
const startupAdvanceCount = (config.startupAdvanceCount as number) ?? 0
const conceptAdvanceCount = (config.conceptAdvanceCount as number) ?? 0
type RankEntry = { projectId: string; avgGlobalScore: number | null; rank: number }
const startupRanked = (snapshot.startupRankingJson ?? []) as RankEntry[]
const conceptRanked = (snapshot.conceptRankingJson ?? []) as RankEntry[]
if (advanceMode === 'threshold') {
for (const r of [...startupRanked, ...conceptRanked]) {
if (r.avgGlobalScore != null && r.avgGlobalScore >= advanceScoreThreshold) {
evaluationPassSet.add(r.projectId)
}
}
} else {
// 'count' mode — top N per category by rank
const sortedStartup = [...startupRanked].sort((a, b) => a.rank - b.rank)
const sortedConcept = [...conceptRanked].sort((a, b) => a.rank - b.rank)
for (let i = 0; i < Math.min(startupAdvanceCount, sortedStartup.length); i++) {
evaluationPassSet.add(sortedStartup[i].projectId)
}
for (let i = 0; i < Math.min(conceptAdvanceCount, sortedConcept.length); i++) {
evaluationPassSet.add(sortedConcept[i].projectId)
}
}
}
}
for (const prs of projectStates) {
// Skip already-terminal states
if (isTerminalState(prs.state)) {
// Set proposed outcome to match current state for display
if (!prs.proposedOutcome) {
await prisma.projectRoundState.update({
where: { id: prs.id },
data: { proposedOutcome: prs.state },
})
}
processed++
continue
}
let targetState: ProjectRoundStateValue = prs.state
let proposedOutcome: ProjectRoundStateValue = 'PASSED'
switch (round.roundType as RoundType) {
case 'INTAKE':
case 'SUBMISSION': {
// Projects with activity → COMPLETED, purely PENDING → REJECTED
if (prs.state === 'PENDING') {
targetState = 'REJECTED' as ProjectRoundStateValue
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
} else if (prs.state === 'IN_PROGRESS' || prs.state === 'COMPLETED') {
if (prs.state === 'IN_PROGRESS') targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = 'PASSED' as ProjectRoundStateValue
}
break
}
case 'EVALUATION': {
// Use ranking scores to determine pass/reject
const hasEvals = prs.project.assignments.some((a: { isCompleted: boolean }) => a.isCompleted)
const shouldPass = evaluationPassSet?.has(prs.projectId) ?? false
if (prs.state === 'IN_PROGRESS' || (prs.state === 'PENDING' && hasEvals)) {
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = (shouldPass ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
} else if (prs.state === 'PENDING') {
targetState = 'REJECTED' as ProjectRoundStateValue
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
} else if (prs.state === 'COMPLETED') {
proposedOutcome = (shouldPass ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
}
break
}
case 'FILTERING': {
// Use FilteringResult to determine outcome for each project
const fr = prs.project.filteringResults?.[0] as { outcome: string; finalOutcome: string | null } | undefined
const effectiveOutcome = fr?.finalOutcome || fr?.outcome
const filterPassed = effectiveOutcome !== 'FILTERED_OUT'
if (prs.state === 'COMPLETED') {
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
} else if (prs.state === 'IN_PROGRESS') {
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
} else if (prs.state === 'PENDING') {
// PENDING projects in filtering: check FilteringResult
if (fr) {
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = (filterPassed ? 'PASSED' : 'REJECTED') as ProjectRoundStateValue
} else {
// No filtering result at all → reject
targetState = 'REJECTED' as ProjectRoundStateValue
proposedOutcome = 'REJECTED' as ProjectRoundStateValue
}
}
break
}
case 'MENTORING': {
// Projects already PASSED (pass-through) stay PASSED
if (prs.state === 'PASSED') {
proposedOutcome = 'PASSED' as ProjectRoundStateValue
} else if (prs.state === 'IN_PROGRESS') {
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = 'PASSED' as ProjectRoundStateValue
} else if (prs.state === 'COMPLETED') {
proposedOutcome = 'PASSED' as ProjectRoundStateValue
} else if (prs.state === 'PENDING') {
// Pending = never requested mentoring, pass through
proposedOutcome = 'PASSED' as ProjectRoundStateValue
targetState = 'COMPLETED' as ProjectRoundStateValue
}
break
}
case 'LIVE_FINAL': {
// All presented projects → COMPLETED
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = 'PASSED' as ProjectRoundStateValue
} else if (prs.state === 'COMPLETED') {
proposedOutcome = 'PASSED' as ProjectRoundStateValue
}
break
}
case 'DELIBERATION': {
// All voted projects → COMPLETED
if (prs.state === 'IN_PROGRESS' || prs.state === 'PENDING') {
targetState = 'COMPLETED' as ProjectRoundStateValue
proposedOutcome = 'PASSED' as ProjectRoundStateValue
} else if (prs.state === 'COMPLETED') {
proposedOutcome = 'PASSED' as ProjectRoundStateValue
}
break
}
}
// Transition project if needed (admin override for non-standard paths)
if (targetState !== prs.state && !isTerminalState(prs.state)) {
// Need to handle multi-step transitions
if (prs.state === 'PENDING' && targetState === 'COMPLETED') {
await transitionProject(prs.projectId, roundId, 'IN_PROGRESS' as ProjectRoundStateValue, actorId, prisma, { adminOverride: true })
await transitionProject(prs.projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma, { adminOverride: true })
} else if (prs.state === 'PENDING' && targetState === 'REJECTED') {
await transitionProject(prs.projectId, roundId, targetState, actorId, prisma, { adminOverride: true })
} else {
await transitionProject(prs.projectId, roundId, targetState, actorId, prisma, { adminOverride: true })
}
}
// Set proposed outcome
await prisma.projectRoundState.update({
where: { id: prs.id },
data: { proposedOutcome },
})
processed++
}
return { processed }
}
// ─── getFinalizationSummary ─────────────────────────────────────────────────
export async function getFinalizationSummary(
roundId: string,
prisma: PrismaClient | any,
): Promise<FinalizationSummary> {
const round = await prisma.round.findUniqueOrThrow({
where: { id: roundId },
include: {
competition: {
select: {
rounds: {
select: { id: true, name: true, sortOrder: true },
orderBy: { sortOrder: 'asc' as const },
},
},
},
},
})
const now = new Date()
const isGracePeriodActive = !!(round.gracePeriodEndsAt && new Date(round.gracePeriodEndsAt) > now && !round.finalizedAt)
const isFinalized = !!round.finalizedAt
// Get config for category targets
const config = (round.configJson as Record<string, unknown>) ?? {}
// Find next round
const rounds = round.competition.rounds
const currentIdx = rounds.findIndex((r: { id: string }) => r.id === roundId)
const nextRound = currentIdx >= 0 && currentIdx < rounds.length - 1
? rounds[currentIdx + 1]
: null
// Get all project states with project details
const projectStates = await prisma.projectRoundState.findMany({
where: { roundId },
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
competitionCategory: true,
country: true,
},
},
},
orderBy: { createdAt: 'asc' as const },
})
// Compute stats
const stats = { pending: 0, inProgress: 0, completed: 0, passed: 0, rejected: 0, withdrawn: 0 }
for (const prs of projectStates) {
switch (prs.state) {
case 'PENDING': stats.pending++; break
case 'IN_PROGRESS': stats.inProgress++; break
case 'COMPLETED': stats.completed++; break
case 'PASSED': stats.passed++; break
case 'REJECTED': stats.rejected++; break
case 'WITHDRAWN': stats.withdrawn++; break
}
}
// Get evaluation scores if this is an evaluation round
let scoreMap = new Map<string, number>()
let rankMap = new Map<string, number>()
if (round.roundType === 'EVALUATION') {
// Get latest ranking snapshot (per-category fields)
const snapshot = await prisma.rankingSnapshot.findFirst({
where: { roundId },
orderBy: { createdAt: 'desc' as const },
select: { startupRankingJson: true, conceptRankingJson: true },
})
if (snapshot) {
type RankEntry = { projectId: string; avgGlobalScore?: number; compositeScore?: number; rank?: number }
const allRanked = [
...((snapshot.startupRankingJson ?? []) as RankEntry[]),
...((snapshot.conceptRankingJson ?? []) as RankEntry[]),
]
for (const r of allRanked) {
if (r.avgGlobalScore != null) scoreMap.set(r.projectId, r.avgGlobalScore)
else if (r.compositeScore != null) scoreMap.set(r.projectId, r.compositeScore)
if (r.rank != null) rankMap.set(r.projectId, r.rank)
}
}
}
// Build project list
const projects = projectStates.map((prs: any) => ({
id: prs.project.id,
title: prs.project.title,
teamName: prs.project.teamName,
category: prs.project.competitionCategory,
country: prs.project.country,
currentState: prs.state as ProjectRoundStateValue,
proposedOutcome: prs.proposedOutcome as ProjectRoundStateValue | null,
evaluationScore: scoreMap.get(prs.project.id) ?? null,
rankPosition: rankMap.get(prs.project.id) ?? null,
}))
// Category target progress
const startupTarget = (config.startupAdvanceCount as number | undefined) ?? null
const conceptTarget = (config.conceptAdvanceCount as number | undefined) ?? null
let startupProposed = 0
let conceptProposed = 0
for (const p of projects) {
if (p.proposedOutcome === 'PASSED') {
if (p.category === 'STARTUP') startupProposed++
else if (p.category === 'BUSINESS_CONCEPT') conceptProposed++
}
}
// Account stats: count how many advancing projects need invite vs already have accounts
let needsInvite = 0
let hasAccount = 0
const passedProjectIds = projects.filter((p: { proposedOutcome: string | null }) => p.proposedOutcome === 'PASSED').map((p: { id: string }) => p.id)
if (passedProjectIds.length > 0) {
const passedProjects = await prisma.project.findMany({
where: { id: { in: passedProjectIds } },
select: {
id: true,
submittedBy: { select: { passwordHash: true } },
teamMembers: { select: { user: { select: { passwordHash: true } } } },
},
})
for (const p of passedProjects) {
// Check team members first, then submittedBy
const users = p.teamMembers.length > 0
? p.teamMembers.map((tm: any) => tm.user)
: p.submittedBy ? [p.submittedBy] : []
const anyHasPassword = users.some((u: any) => !!u.passwordHash)
if (anyHasPassword) hasAccount++
else needsInvite++
}
}
return {
roundId,
roundName: round.name,
roundType: round.roundType,
isGracePeriodActive,
gracePeriodEndsAt: round.gracePeriodEndsAt,
isFinalized,
finalizedAt: round.finalizedAt,
stats,
projects,
categoryTargets: {
startupTarget,
conceptTarget,
startupProposed,
conceptProposed,
},
nextRound: nextRound ? { id: nextRound.id, name: nextRound.name } : null,
accountStats: { needsInvite, hasAccount },
}
}
// ─── confirmFinalization ────────────────────────────────────────────────────
export async function confirmFinalization(
roundId: string,
options: {
targetRoundId?: string
advancementMessage?: string
rejectionMessage?: string
},
actorId: string,
prisma: PrismaClient | any,
): Promise<ConfirmFinalizationResult> {
// Validate: round is CLOSED, not already finalized, grace period expired
const round = await prisma.round.findUniqueOrThrow({
where: { id: roundId },
include: {
competition: {
select: {
id: true,
rounds: {
select: { id: true, name: true, sortOrder: true },
orderBy: { sortOrder: 'asc' as const },
},
},
},
},
})
if (round.status !== 'ROUND_CLOSED') {
throw new Error(`Round must be ROUND_CLOSED to finalize, got ${round.status}`)
}
if (round.finalizedAt) {
throw new Error('Round is already finalized')
}
const now = new Date()
if (round.gracePeriodEndsAt && new Date(round.gracePeriodEndsAt) > now) {
throw new Error('Cannot finalize: grace period is still active')
}
// Determine target round
const rounds = round.competition.rounds
const currentIdx = rounds.findIndex((r: { id: string }) => r.id === roundId)
const targetRoundId = options.targetRoundId
?? (currentIdx >= 0 && currentIdx < rounds.length - 1
? rounds[currentIdx + 1].id
: undefined)
const targetRoundName = targetRoundId
? rounds.find((r: { id: string }) => r.id === targetRoundId)?.name ?? 'Next Round'
: 'Next Round'
// Execute finalization in a transaction
const result = await prisma.$transaction(async (tx: any) => {
const projectStates = await tx.projectRoundState.findMany({
where: { roundId, proposedOutcome: { not: null } },
include: {
project: {
select: {
id: true,
title: true,
status: true,
},
},
},
})
let advanced = 0
let rejected = 0
for (const prs of projectStates) {
const proposed = prs.proposedOutcome as ProjectRoundStateValue
// Skip if already in the proposed state
if (prs.state === proposed) {
if (proposed === 'PASSED') advanced++
else if (proposed === 'REJECTED') rejected++
continue
}
// Transition to proposed outcome
if (proposed === 'PASSED' || proposed === 'REJECTED') {
// Ensure we're in COMPLETED before transitioning to PASSED/REJECTED
if (prs.state !== 'COMPLETED' && prs.state !== 'PASSED' && prs.state !== 'REJECTED') {
// Force through intermediate states
if (prs.state === 'PENDING') {
await tx.projectRoundState.update({
where: { id: prs.id },
data: { state: 'IN_PROGRESS' },
})
}
if (prs.state === 'PENDING' || prs.state === 'IN_PROGRESS') {
await tx.projectRoundState.update({
where: { id: prs.id },
data: { state: 'COMPLETED' },
})
}
}
// Now transition to final state
await tx.projectRoundState.update({
where: { id: prs.id },
data: {
state: proposed,
exitedAt: now,
},
})
if (proposed === 'PASSED') {
advanced++
// Create ProjectRoundState in target round (if exists)
if (targetRoundId) {
await tx.projectRoundState.upsert({
where: {
projectId_roundId: {
projectId: prs.projectId,
roundId: targetRoundId,
},
},
create: {
projectId: prs.projectId,
roundId: targetRoundId,
state: 'PENDING',
enteredAt: now,
},
update: {}, // skip if already exists
})
}
// Update Project.status to ASSIGNED
await tx.project.update({
where: { id: prs.projectId },
data: { status: 'ASSIGNED' },
})
// Create ProjectStatusHistory
await tx.projectStatusHistory.create({
data: {
projectId: prs.projectId,
status: 'ASSIGNED',
changedBy: actorId,
reason: `Advanced from round "${round.name}" via finalization`,
},
})
} else {
rejected++
}
// Audit log per project
await tx.decisionAuditLog.create({
data: {
eventType: 'finalization.project_outcome',
entityType: 'ProjectRoundState',
entityId: prs.id,
actorId,
detailsJson: {
projectId: prs.projectId,
roundId,
previousState: prs.state,
outcome: proposed,
targetRoundId: proposed === 'PASSED' ? targetRoundId : null,
} as Prisma.InputJsonValue,
snapshotJson: {
timestamp: now.toISOString(),
emittedBy: 'round-finalization',
},
},
})
}
}
// Mark round as finalized
await tx.round.update({
where: { id: roundId },
data: {
finalizedAt: now,
finalizedBy: actorId,
},
})
// Finalization audit
await tx.decisionAuditLog.create({
data: {
eventType: 'round.finalized',
entityType: 'Round',
entityId: roundId,
actorId,
detailsJson: {
roundName: round.name,
advanced,
rejected,
targetRoundId,
hasCustomAdvancementMessage: !!options.advancementMessage,
hasCustomRejectionMessage: !!options.rejectionMessage,
} as Prisma.InputJsonValue,
snapshotJson: {
timestamp: now.toISOString(),
emittedBy: 'round-finalization',
},
},
})
return { advanced, rejected }
})
// Send emails outside transaction (non-fatal)
let emailsSent = 0
let emailsFailed = 0
try {
// Get all projects that were finalized
const finalizedStates = await prisma.projectRoundState.findMany({
where: { roundId, state: { in: ['PASSED', 'REJECTED'] } },
include: {
project: {
select: {
id: true,
title: true,
submittedByEmail: true,
submittedByUserId: true,
submittedBy: { select: { id: true, email: true, name: true, passwordHash: true } },
teamMembers: {
select: { user: { select: { id: true, email: true, name: true, passwordHash: true } } },
},
},
},
},
})
// Pre-generate invite tokens for passwordless users on advancing projects
const inviteTokenMap = new Map<string, string>() // userId → token
const expiryMs = await getInviteExpiryMs(prisma)
for (const prs of finalizedStates) {
if (prs.state !== 'PASSED') continue
const users = prs.project.teamMembers.length > 0
? prs.project.teamMembers.map((tm: any) => tm.user)
: prs.project.submittedBy ? [prs.project.submittedBy] : []
for (const user of users) {
if (user && !user.passwordHash && !inviteTokenMap.has(user.id)) {
const token = generateInviteToken()
inviteTokenMap.set(user.id, token)
await prisma.user.update({
where: { id: user.id },
data: {
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
},
})
}
}
}
const advancedUserIds = new Set<string>()
const rejectedUserIds = new Set<string>()
for (const prs of finalizedStates) {
type Recipient = { email: string; name: string | null; userId: string | null }
const recipients: Recipient[] = []
for (const tm of prs.project.teamMembers) {
if (tm.user.email) {
recipients.push({ email: tm.user.email, name: tm.user.name, userId: tm.user.id })
if (prs.state === 'PASSED') advancedUserIds.add(tm.user.id)
else rejectedUserIds.add(tm.user.id)
}
}
if (recipients.length === 0 && prs.project.submittedBy?.email) {
recipients.push({
email: prs.project.submittedBy.email,
name: prs.project.submittedBy.name,
userId: prs.project.submittedBy.id,
})
if (prs.state === 'PASSED') advancedUserIds.add(prs.project.submittedBy.id)
else rejectedUserIds.add(prs.project.submittedBy.id)
} else if (recipients.length === 0 && prs.project.submittedByEmail) {
recipients.push({ email: prs.project.submittedByEmail, name: null, userId: null })
}
for (const recipient of recipients) {
try {
if (prs.state === 'PASSED') {
// Build account creation URL for passwordless users
const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
await sendStyledNotificationEmail(
recipient.email,
recipient.name || '',
'ADVANCEMENT_NOTIFICATION',
{
title: 'Your project has advanced!',
message: '',
linkUrl: accountUrl || '/applicant',
metadata: {
projectName: prs.project.title,
fromRoundName: round.name,
toRoundName: targetRoundName,
customMessage: options.advancementMessage || undefined,
accountUrl,
},
},
)
} else {
await sendStyledNotificationEmail(
recipient.email,
recipient.name || '',
'REJECTION_NOTIFICATION',
{
title: `Update on your application: "${prs.project.title}"`,
message: '',
metadata: {
projectName: prs.project.title,
roundName: round.name,
customMessage: options.rejectionMessage || undefined,
},
},
)
}
emailsSent++
} catch (err) {
console.error(`[Finalization] Email failed for ${recipient.email}:`, err)
emailsFailed++
}
}
}
// Create in-app notifications
if (advancedUserIds.size > 0) {
void createBulkNotifications({
userIds: [...advancedUserIds],
type: 'project_advanced',
title: 'Your project has advanced!',
message: `Your project has advanced from "${round.name}" to "${targetRoundName}".`,
linkUrl: '/applicant',
linkLabel: 'View Dashboard',
icon: 'Trophy',
priority: 'high',
})
}
if (rejectedUserIds.size > 0) {
void createBulkNotifications({
userIds: [...rejectedUserIds],
type: 'project_rejected',
title: 'Competition Update',
message: `Your project did not advance past "${round.name}".`,
linkUrl: '/applicant',
linkLabel: 'View Dashboard',
icon: 'Info',
priority: 'normal',
})
}
} catch (emailError) {
console.error('[Finalization] Email batch failed (non-fatal):', emailError)
}
// External audit log
await logAudit({
userId: actorId,
action: 'ROUND_FINALIZED',
entityType: 'Round',
entityId: roundId,
detailsJson: {
roundName: round.name,
advanced: result.advanced,
rejected: result.rejected,
emailsSent,
emailsFailed,
},
})
return {
advanced: result.advanced,
rejected: result.rejected,
emailsSent,
emailsFailed,
}
}

View File

@@ -0,0 +1,25 @@
import crypto from 'crypto'
import type { PrismaClient } from '@prisma/client'
const DEFAULT_INVITE_EXPIRY_HOURS = 72 // 3 days
export function generateInviteToken(): string {
return crypto.randomBytes(32).toString('hex')
}
export async function getInviteExpiryHours(prisma: PrismaClient): Promise<number> {
try {
const setting = await prisma.systemSettings.findUnique({
where: { key: 'invite_link_expiry_hours' },
select: { value: true },
})
const hours = setting?.value ? parseInt(setting.value, 10) : DEFAULT_INVITE_EXPIRY_HOURS
return isNaN(hours) || hours < 1 ? DEFAULT_INVITE_EXPIRY_HOURS : hours
} catch {
return DEFAULT_INVITE_EXPIRY_HOURS
}
}
export async function getInviteExpiryMs(prisma: PrismaClient): Promise<number> {
return (await getInviteExpiryHours(prisma)) * 60 * 60 * 1000
}

View File

@@ -14,8 +14,7 @@ const generalSettingsFields = {
conceptAdvanceCount: z.number().int().nonnegative().optional(),
advanceMode: z.enum(['count', 'threshold']).default('count'),
advanceScoreThreshold: z.number().min(0).max(10).optional(),
notifyOnEntry: z.boolean().default(false),
notifyOnAdvance: z.boolean().default(false),
gracePeriodHours: z.number().int().nonnegative().default(0),
}
// ─── 1. IntakeConfig ─────────────────────────────────────────────────────────
@@ -193,6 +192,9 @@ export const MentoringConfigSchema = z.object({
promotionTargetWindowId: z.string().optional(),
autoAssignMentors: z.boolean().default(false),
mentoringRequestDeadlineDays: z.number().int().positive().default(14),
passThroughIfNoRequest: z.boolean().default(true),
})
export type MentoringConfig = z.infer<typeof MentoringConfigSchema>