feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -58,3 +58,6 @@ build-output.txt
|
||||
# Misc
|
||||
*.log
|
||||
.vercel
|
||||
|
||||
# Private keys and secrets
|
||||
private/
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2193,6 +2193,11 @@ model Round {
|
||||
submissionWindowId String?
|
||||
specialAwardId String?
|
||||
|
||||
// Finalization
|
||||
gracePeriodEndsAt DateTime?
|
||||
finalizedAt DateTime?
|
||||
finalizedBy String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -2241,6 +2246,7 @@ model ProjectRoundState {
|
||||
projectId String
|
||||
roundId String
|
||||
state ProjectRoundStateValue @default(PENDING)
|
||||
proposedOutcome ProjectRoundStateValue?
|
||||
enteredAt DateTime @default(now())
|
||||
exitedAt DateTime?
|
||||
metadataJson Json? @db.JsonB
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,6 +485,64 @@ export default function AwardDetailPage({
|
||||
</Button>
|
||||
)}
|
||||
{award.status === 'NOMINATIONS_OPEN' && (
|
||||
<>
|
||||
<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 "Selected for {award.name}" 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}
|
||||
@@ -475,6 +550,7 @@ export default function AwardDetailPage({
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Open Voting
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{award.status === 'VOTING_OPEN' && (
|
||||
<Button
|
||||
|
||||
@@ -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,10 +688,21 @@ 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-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>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Delivery Channels</p>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
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)
|
||||
// Small delay to let cache invalidation complete before opening dialog
|
||||
setTimeout(() => setAdvanceDialogOpen(true), 300)
|
||||
}
|
||||
// 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,24 +1189,32 @@ 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
|
||||
{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`
|
||||
@@ -1194,21 +1225,18 @@ export default function RoundDetailPage() {
|
||||
</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={() => {
|
||||
<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>
|
||||
|
||||
@@ -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,6 +120,15 @@ export default function ApplicantDashboardPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between flex-wrap gap-4">
|
||||
<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>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
|
||||
@@ -135,17 +143,10 @@ export default function ApplicantDashboardPage() {
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
{project.isTeamLead && currentStatus !== 'REJECTED' && (currentStatus as string) !== 'WINNER' && (
|
||||
<WithdrawButton projectId={project.id} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
@@ -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'}
|
||||
/>
|
||||
)}
|
||||
</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}>
|
||||
|
||||
@@ -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,23 +241,169 @@ 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>
|
||||
</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>
|
||||
<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>
|
||||
<Button size="sm">
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Invite Member
|
||||
Invite
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
@@ -355,14 +532,6 @@ export default function ApplicantTeamPage() {
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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've been invited to join the team for
|
||||
</p>
|
||||
<p className="font-semibold text-blue-900">“{team.projectTitle}”</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>
|
||||
|
||||
@@ -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,8 +297,17 @@ 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.
|
||||
</p>
|
||||
@@ -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'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}!
|
||||
|
||||
@@ -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>
|
||||
|
||||
47
src/app/api/cron/process-grace-periods/route.ts
Normal file
47
src/app/api/cron/process-grace-periods/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
82
src/components/admin/round/bulk-invite-button.tsx
Normal file
82
src/components/admin/round/bulk-invite-button.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
130
src/components/admin/round/email-preview-dialog.tsx
Normal file
130
src/components/admin/round/email-preview-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
665
src/components/admin/round/finalization-tab.tsx
Normal file
665
src/components/admin/round/finalization-tab.tsx
Normal 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 "Process" 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>
|
||||
)
|
||||
}
|
||||
62
src/components/admin/round/notify-advanced-button.tsx
Normal file
62
src/components/admin/round/notify-advanced-button.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
61
src/components/admin/round/notify-rejected-button.tsx
Normal file
61
src/components/admin/round/notify-rejected-button.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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,8 +236,12 @@ 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">
|
||||
@@ -236,22 +250,51 @@ export function ProjectStatesTable({ competitionId, roundId, onAssignProjects }:
|
||||
</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.
|
||||
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" className="mt-4">
|
||||
<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>
|
||||
</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 && <> · {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>
|
||||
|
||||
@@ -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="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'
|
||||
|
||||
// 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 inner content
|
||||
let dotInner: React.ReactNode = null
|
||||
let dotClasses = 'border-2 border-muted-foreground/20 bg-background'
|
||||
|
||||
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="relative flex gap-3">
|
||||
{/* Connecting line */}
|
||||
{!isLast && (
|
||||
<div className="absolute left-[7px] top-[20px] h-full w-0.5 bg-muted" />
|
||||
)}
|
||||
|
||||
<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={`relative z-10 mt-1.5 h-4 w-4 rounded-full shrink-0 ${dotColor}`} />
|
||||
<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 pb-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-sm font-medium ${
|
||||
isRejected
|
||||
? 'text-destructive'
|
||||
: isCompleted || isActive
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
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>
|
||||
{isRejected && (
|
||||
<p className="text-xs text-destructive">Not Selected</p>
|
||||
)}
|
||||
{isActive && (
|
||||
<p className="text-xs text-primary">In Progress</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>
|
||||
)
|
||||
}
|
||||
|
||||
133
src/components/applicant/mentoring-request-card.tsx
Normal file
133
src/components/applicant/mentoring-request-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
src/components/applicant/withdraw-button.tsx
Normal file
70
src/components/applicant/withdraw-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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,6 +183,7 @@ export function ProjectLogoUpload({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{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"
|
||||
@@ -174,33 +194,37 @@ export function ProjectLogoUpload({
|
||||
<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
|
||||
|
||||
@@ -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">
|
||||
<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
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
242
src/lib/email.ts
242
src/lib/email.ts
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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(
|
||||
|
||||
@@ -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,8 +415,10 @@ export const applicantRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Auto-transition: if uploading against a round requirement, check completion
|
||||
if (roundId && requirementId) {
|
||||
// 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,
|
||||
@@ -424,6 +426,7 @@ export const applicantRouter = router({
|
||||
ctx.prisma,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
|
||||
import('../services/document-analyzer').then(({ analyzeFileDelayed }) =>
|
||||
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
830
src/server/services/round-finalization.ts
Normal file
830
src/server/services/round-finalization.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
25
src/server/utils/invite.ts
Normal file
25
src/server/utils/invite.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user