feat: semi-finalist tracker dashboard, account reminders, search + UX fixes
- Add getSemiFinalistStats query with per-category/per-award breakdown - Add sendAccountReminders mutation with invite token generation and dedup - Add SemiFinalistTracker dashboard widget with progress bars and remind buttons - Add ACCOUNT_REMINDER email template - Extend project search to match team member name/email (7 locations) - Fix Passed count deduplication: count distinct projects, not round-state rows - Fix role switcher: visible pills above user section, auto-refresh session on mount Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ import { ActivityFeed } from '@/components/dashboard/activity-feed'
|
|||||||
import { CategoryBreakdown } from '@/components/dashboard/category-breakdown'
|
import { CategoryBreakdown } from '@/components/dashboard/category-breakdown'
|
||||||
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton'
|
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton'
|
||||||
import { RecentEvaluations } from '@/components/dashboard/recent-evaluations'
|
import { RecentEvaluations } from '@/components/dashboard/recent-evaluations'
|
||||||
|
import { SemiFinalistTracker } from '@/components/dashboard/semi-finalist-tracker'
|
||||||
|
|
||||||
type DashboardContentProps = {
|
type DashboardContentProps = {
|
||||||
editionId: string
|
editionId: string
|
||||||
@@ -125,6 +126,10 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||||||
{ limit: 8 },
|
{ limit: 8 },
|
||||||
{ enabled: !!editionId, refetchInterval: 5_000 }
|
{ enabled: !!editionId, refetchInterval: 5_000 }
|
||||||
)
|
)
|
||||||
|
const { data: semiFinalistStats } = trpc.dashboard.getSemiFinalistStats.useQuery(
|
||||||
|
{ editionId },
|
||||||
|
{ enabled: !!editionId, refetchInterval: 60_000 }
|
||||||
|
)
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <DashboardSkeleton />
|
return <DashboardSkeleton />
|
||||||
@@ -271,7 +276,18 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||||||
<SmartActions actions={nextActions} />
|
<SmartActions actions={nextActions} />
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
|
{semiFinalistStats && semiFinalistStats.byCategory.length > 0 && (
|
||||||
<AnimatedCard index={6}>
|
<AnimatedCard index={6}>
|
||||||
|
<SemiFinalistTracker
|
||||||
|
byCategory={semiFinalistStats.byCategory}
|
||||||
|
byAward={semiFinalistStats.byAward}
|
||||||
|
unactivatedProjects={semiFinalistStats.unactivatedProjects}
|
||||||
|
editionId={editionId}
|
||||||
|
/>
|
||||||
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatedCard index={7}>
|
||||||
<ActivityFeed activity={liveActivity ?? recentActivity} />
|
<ActivityFeed activity={liveActivity ?? recentActivity} />
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,12 +296,12 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||||||
{/* Bottom Full Width */}
|
{/* Bottom Full Width */}
|
||||||
<div className="grid gap-6 lg:grid-cols-12">
|
<div className="grid gap-6 lg:grid-cols-12">
|
||||||
<div className="lg:col-span-8">
|
<div className="lg:col-span-8">
|
||||||
<AnimatedCard index={7}>
|
<AnimatedCard index={8}>
|
||||||
<GeographicSummaryCard programId={editionId} />
|
<GeographicSummaryCard programId={editionId} />
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
</div>
|
</div>
|
||||||
<div className="lg:col-span-4">
|
<div className="lg:col-span-4">
|
||||||
<AnimatedCard index={8}>
|
<AnimatedCard index={9}>
|
||||||
<CategoryBreakdown
|
<CategoryBreakdown
|
||||||
categories={categoryBreakdown}
|
categories={categoryBreakdown}
|
||||||
issues={oceanIssueBreakdown}
|
issues={oceanIssueBreakdown}
|
||||||
|
|||||||
235
src/components/dashboard/semi-finalist-tracker.tsx
Normal file
235
src/components/dashboard/semi-finalist-tracker.tsx
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Progress } from '@/components/ui/progress'
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Send,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Trophy,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
type CategoryStat = {
|
||||||
|
category: string
|
||||||
|
total: number
|
||||||
|
accountsSet: number
|
||||||
|
accountsNotSet: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type AwardStat = {
|
||||||
|
awardId: string
|
||||||
|
awardName: string
|
||||||
|
total: number
|
||||||
|
accountsSet: number
|
||||||
|
accountsNotSet: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type UnactivatedProject = {
|
||||||
|
projectId: string
|
||||||
|
projectTitle: string
|
||||||
|
category: string | null
|
||||||
|
teamEmails: string[]
|
||||||
|
roundName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SemiFinalistTrackerProps = {
|
||||||
|
byCategory: CategoryStat[]
|
||||||
|
byAward: AwardStat[]
|
||||||
|
unactivatedProjects: UnactivatedProject[]
|
||||||
|
editionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryLabels: Record<string, string> = {
|
||||||
|
STARTUP: 'Startup',
|
||||||
|
BUSINESS_CONCEPT: 'Business Concept',
|
||||||
|
UNKNOWN: 'Unknown',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SemiFinalistTracker({
|
||||||
|
byCategory,
|
||||||
|
byAward,
|
||||||
|
unactivatedProjects,
|
||||||
|
editionId,
|
||||||
|
}: SemiFinalistTrackerProps) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const sendReminders = trpc.dashboard.sendAccountReminders.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success(`Sent ${data.sent} reminder${data.sent !== 1 ? 's' : ''}${data.failed > 0 ? `, ${data.failed} failed` : ''}`)
|
||||||
|
utils.dashboard.getSemiFinalistStats.invalidate()
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(`Failed to send reminders: ${err.message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const [sendingTarget, setSendingTarget] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const totalProjects = byCategory.reduce((sum, c) => sum + c.total, 0)
|
||||||
|
const totalActivated = byCategory.reduce((sum, c) => sum + c.accountsSet, 0)
|
||||||
|
const totalPending = byCategory.reduce((sum, c) => sum + c.accountsNotSet, 0)
|
||||||
|
|
||||||
|
if (totalProjects === 0) return null
|
||||||
|
|
||||||
|
const handleSendReminder = async (target: string, opts: { category?: 'STARTUP' | 'BUSINESS_CONCEPT'; awardId?: string }) => {
|
||||||
|
setSendingTarget(target)
|
||||||
|
try {
|
||||||
|
await sendReminders.mutateAsync({
|
||||||
|
editionId,
|
||||||
|
category: opts.category,
|
||||||
|
awardId: opts.awardId,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setSendingTarget(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Users className="h-4 w-4 text-brand-blue" />
|
||||||
|
Semi-Finalist Tracker
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{totalActivated}/{totalProjects} activated
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Per-category rows */}
|
||||||
|
{byCategory.map((cat) => {
|
||||||
|
const pct = cat.total > 0 ? Math.round((cat.accountsSet / cat.total) * 100) : 0
|
||||||
|
const allSet = cat.accountsNotSet === 0
|
||||||
|
return (
|
||||||
|
<div key={cat.category} className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-medium">
|
||||||
|
{categoryLabels[cat.category] || cat.category}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{cat.accountsSet} of {cat.total} activated
|
||||||
|
</span>
|
||||||
|
{allSet ? (
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
disabled={sendReminders.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
handleSendReminder(`cat-${cat.category}`, {
|
||||||
|
category: cat.category as 'STARTUP' | 'BUSINESS_CONCEPT',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sendingTarget === `cat-${cat.category}` ? (
|
||||||
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="mr-1 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Remind
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Progress value={pct} className="h-1.5" />
|
||||||
|
{cat.accountsNotSet > 0 && (
|
||||||
|
<p className="text-[11px] text-amber-600">
|
||||||
|
<AlertCircle className="mr-0.5 inline h-3 w-3" />
|
||||||
|
{cat.accountsNotSet} pending account setup
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Per-award rows */}
|
||||||
|
{byAward.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||||
|
Special Awards
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{byAward.map((award) => {
|
||||||
|
const pct = award.total > 0 ? Math.round((award.accountsSet / award.total) * 100) : 0
|
||||||
|
const allSet = award.accountsNotSet === 0
|
||||||
|
return (
|
||||||
|
<div key={award.awardId} className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="flex items-center gap-1.5 font-medium">
|
||||||
|
<Trophy className="h-3.5 w-3.5 text-amber-500" />
|
||||||
|
{award.awardName}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{award.accountsSet} of {award.total}
|
||||||
|
</span>
|
||||||
|
{allSet ? (
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
disabled={sendReminders.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
handleSendReminder(`award-${award.awardId}`, {
|
||||||
|
awardId: award.awardId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{sendingTarget === `award-${award.awardId}` ? (
|
||||||
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="mr-1 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Remind
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Progress value={pct} className="h-1.5" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-semibold">
|
||||||
|
Total: {totalActivated} of {totalProjects} team accounts activated
|
||||||
|
</span>
|
||||||
|
{totalPending > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={sendReminders.isPending}
|
||||||
|
onClick={() => handleSendReminder('all', {})}
|
||||||
|
>
|
||||||
|
{sendingTarget === 'all' ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="mr-1.5 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Remind All ({totalPending})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Route } from 'next'
|
import type { Route } from 'next'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
@@ -164,13 +164,21 @@ const ROLE_SWITCH_OPTIONS: Record<string, { label: string; path: string; icon: t
|
|||||||
export function AdminSidebar({ user }: AdminSidebarProps) {
|
export function AdminSidebar({ user }: AdminSidebarProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||||
const { data: session, status: sessionStatus } = useSession()
|
const { data: session, status: sessionStatus, update: updateSession } = useSession()
|
||||||
const isAuthenticated = sessionStatus === 'authenticated'
|
const isAuthenticated = sessionStatus === 'authenticated'
|
||||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
})
|
})
|
||||||
const { currentEdition } = useEdition()
|
const { currentEdition } = useEdition()
|
||||||
|
|
||||||
|
// Auto-refresh session on mount to pick up role changes without requiring re-login
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
updateSession()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
const isSuperAdmin = user.role === 'SUPER_ADMIN'
|
const isSuperAdmin = user.role === 'SUPER_ADMIN'
|
||||||
const roleLabel = roleLabels[user.role || ''] || 'User'
|
const roleLabel = roleLabels[user.role || ''] || 'User'
|
||||||
|
|
||||||
@@ -307,6 +315,26 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Role Switcher — visible above user section */}
|
||||||
|
{switchableRoles.length > 0 && (
|
||||||
|
<div className="border-t px-3 py-2">
|
||||||
|
<p className="mb-1.5 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||||
|
<ArrowRightLeft className="h-3 w-3" />
|
||||||
|
Switch View
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{switchableRoles.map(([, opt]) => (
|
||||||
|
<Link key={opt.path} href={opt.path as Route} onClick={() => setIsMobileMenuOpen(false)}>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 gap-1.5 px-2.5 text-xs">
|
||||||
|
<opt.icon className="h-3 w-3" />
|
||||||
|
{opt.label}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* User Profile Section */}
|
{/* User Profile Section */}
|
||||||
<div className="border-t p-3">
|
<div className="border-t p-3">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ function isNavItemActive(pathname: string, href: string, basePath: string): bool
|
|||||||
export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector, helpEmail }: RoleNavProps) {
|
export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector, helpEmail }: RoleNavProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||||
const { data: session, status: sessionStatus } = useSession()
|
const { data: session, status: sessionStatus, update: updateSession } = useSession()
|
||||||
const isAuthenticated = sessionStatus === 'authenticated'
|
const isAuthenticated = sessionStatus === 'authenticated'
|
||||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, {
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
@@ -78,6 +78,14 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
|||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
useEffect(() => setMounted(true), [])
|
useEffect(() => setMounted(true), [])
|
||||||
|
|
||||||
|
// Auto-refresh session on mount to pick up role changes without requiring re-login
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
updateSession()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isAuthenticated])
|
||||||
|
|
||||||
// Roles the user can switch to (excluding current view)
|
// Roles the user can switch to (excluding current view)
|
||||||
const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? []
|
const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? []
|
||||||
const switchableRoles = Object.entries(ROLE_SWITCH_OPTIONS)
|
const switchableRoles = Object.entries(ROLE_SWITCH_OPTIONS)
|
||||||
|
|||||||
@@ -1959,6 +1959,32 @@ export function getEmailPreviewHtml(subject: string, body: string): string {
|
|||||||
return getEmailWrapper(content)
|
return getEmailWrapper(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate "Account Setup Reminder" email template
|
||||||
|
* Sent to semi-finalist team members who haven't set up their account yet.
|
||||||
|
*/
|
||||||
|
export function getAccountReminderTemplate(
|
||||||
|
name: string,
|
||||||
|
projectName: string,
|
||||||
|
accountUrl: string,
|
||||||
|
): EmailTemplate {
|
||||||
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
${sectionTitle(greeting)}
|
||||||
|
${paragraph(`Your project <strong>"${projectName}"</strong> has been selected as a semi-finalist in the Monaco Ocean Protection Challenge.`)}
|
||||||
|
${infoBox('Please set up your account to access your applicant dashboard and stay up to date with the competition.', 'warning')}
|
||||||
|
${ctaButton(accountUrl, 'Set Up Your Account')}
|
||||||
|
${paragraph('If you have any questions, please contact the MOPC team.')}
|
||||||
|
`
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: `Action Required: Set up your MOPC account — "${projectName}"`,
|
||||||
|
html: getEmailWrapper(content),
|
||||||
|
text: `${greeting}\n\nYour project "${projectName}" has been selected as a semi-finalist in the Monaco Ocean Protection Challenge.\n\nPlease set up your account to access your applicant dashboard.\n\nSet up your account: ${getBaseUrl()}${accountUrl}\n\nIf you have any questions, please contact the MOPC team.\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template registry mapping notification types to template generators
|
* Template registry mapping notification types to template generators
|
||||||
*/
|
*/
|
||||||
@@ -2137,6 +2163,13 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
|
|||||||
ctx.metadata?.accountUrl as string | undefined,
|
ctx.metadata?.accountUrl as string | undefined,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
ACCOUNT_REMINDER: (ctx) =>
|
||||||
|
getAccountReminderTemplate(
|
||||||
|
ctx.name || '',
|
||||||
|
(ctx.metadata?.projectName as string) || 'Your Project',
|
||||||
|
(ctx.metadata?.accountUrl as string) || '/accept-invite',
|
||||||
|
),
|
||||||
|
|
||||||
// Admin templates
|
// Admin templates
|
||||||
NEW_APPLICATION: (ctx) =>
|
NEW_APPLICATION: (ctx) =>
|
||||||
getNewApplicationTemplate(
|
getNewApplicationTemplate(
|
||||||
|
|||||||
@@ -1004,6 +1004,8 @@ export const analyticsRouter = router({
|
|||||||
where.OR = [
|
where.OR = [
|
||||||
{ title: { contains: input.search, mode: 'insensitive' } },
|
{ title: { contains: input.search, mode: 'insensitive' } },
|
||||||
{ teamName: { contains: input.search, mode: 'insensitive' } },
|
{ teamName: { contains: input.search, mode: 'insensitive' } },
|
||||||
|
{ teamMembers: { some: { user: { name: { contains: input.search, mode: 'insensitive' } } } } },
|
||||||
|
{ teamMembers: { some: { user: { email: { contains: input.search, mode: 'insensitive' } } } } },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { router, adminProcedure } from '../trpc'
|
import { router, adminProcedure } from '../trpc'
|
||||||
import type { RoundType, RoundStatus, ProjectRoundStateValue } from '@prisma/client'
|
import type { RoundType, RoundStatus, ProjectRoundStateValue } from '@prisma/client'
|
||||||
|
import { generateInviteToken, getInviteExpiryMs } from '../utils/invite'
|
||||||
|
import { sendBatchNotifications } from '../services/notification-sender'
|
||||||
|
import type { NotificationItem } from '../services/notification-sender'
|
||||||
|
import { getBaseUrl } from '@/lib/email'
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -571,4 +575,262 @@ export const dashboardRouter = router({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get semi-finalist stats: counts by category, by award, and unactivated projects.
|
||||||
|
* "Semi-finalist" = project with PASSED ProjectRoundState.
|
||||||
|
* "Account set up" = at least 1 team member has passwordHash.
|
||||||
|
*/
|
||||||
|
getSemiFinalistStats: adminProcedure
|
||||||
|
.input(z.object({ editionId: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const { editionId } = input
|
||||||
|
|
||||||
|
// Find all projects with at least one PASSED state in this edition's rounds.
|
||||||
|
// Use the highest sortOrder PASSED round per project to avoid double-counting.
|
||||||
|
const passedStates = await ctx.prisma.projectRoundState.findMany({
|
||||||
|
where: {
|
||||||
|
state: 'PASSED',
|
||||||
|
round: { competition: { programId: editionId } },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
projectId: true,
|
||||||
|
round: { select: { id: true, name: true, sortOrder: true } },
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
competitionCategory: true,
|
||||||
|
teamMembers: {
|
||||||
|
select: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
passwordHash: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deduplicate: keep only the highest-sortOrder PASSED round per project
|
||||||
|
const projectMap = new Map<string, (typeof passedStates)[0]>()
|
||||||
|
for (const ps of passedStates) {
|
||||||
|
const existing = projectMap.get(ps.projectId)
|
||||||
|
if (!existing || ps.round.sortOrder > existing.round.sortOrder) {
|
||||||
|
projectMap.set(ps.projectId, ps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueProjects = Array.from(projectMap.values())
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
const catMap = new Map<string, { total: number; accountsSet: number; accountsNotSet: number }>()
|
||||||
|
for (const ps of uniqueProjects) {
|
||||||
|
const cat = ps.project.competitionCategory || 'UNKNOWN'
|
||||||
|
if (!catMap.has(cat)) catMap.set(cat, { total: 0, accountsSet: 0, accountsNotSet: 0 })
|
||||||
|
const entry = catMap.get(cat)!
|
||||||
|
entry.total++
|
||||||
|
const hasActivated = ps.project.teamMembers.some((tm) => tm.user.passwordHash !== null)
|
||||||
|
if (hasActivated) entry.accountsSet++
|
||||||
|
else entry.accountsNotSet++
|
||||||
|
}
|
||||||
|
|
||||||
|
const byCategory = Array.from(catMap.entries()).map(([category, counts]) => ({
|
||||||
|
category,
|
||||||
|
...counts,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Get award eligibility for PASSED projects
|
||||||
|
const passedProjectIds = uniqueProjects.map((p) => p.projectId)
|
||||||
|
const awardEligibility = passedProjectIds.length > 0
|
||||||
|
? await ctx.prisma.awardEligibility.findMany({
|
||||||
|
where: {
|
||||||
|
projectId: { in: passedProjectIds },
|
||||||
|
eligible: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
projectId: true,
|
||||||
|
award: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Group by award
|
||||||
|
const awardMap = new Map<string, { awardName: string; projectIds: Set<string> }>()
|
||||||
|
for (const ae of awardEligibility) {
|
||||||
|
if (!awardMap.has(ae.award.id)) {
|
||||||
|
awardMap.set(ae.award.id, { awardName: ae.award.name, projectIds: new Set() })
|
||||||
|
}
|
||||||
|
awardMap.get(ae.award.id)!.projectIds.add(ae.projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const byAward = Array.from(awardMap.entries()).map(([awardId, { awardName, projectIds }]) => {
|
||||||
|
let accountsSet = 0
|
||||||
|
let accountsNotSet = 0
|
||||||
|
for (const pid of projectIds) {
|
||||||
|
const ps = projectMap.get(pid)
|
||||||
|
if (ps) {
|
||||||
|
const hasActivated = ps.project.teamMembers.some((tm) => tm.user.passwordHash !== null)
|
||||||
|
if (hasActivated) accountsSet++
|
||||||
|
else accountsNotSet++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { awardId, awardName, total: projectIds.size, accountsSet, accountsNotSet }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unactivated projects: no team member has passwordHash
|
||||||
|
const unactivatedProjects = uniqueProjects
|
||||||
|
.filter((ps) => !ps.project.teamMembers.some((tm) => tm.user.passwordHash !== null))
|
||||||
|
.map((ps) => ({
|
||||||
|
projectId: ps.projectId,
|
||||||
|
projectTitle: ps.project.title,
|
||||||
|
category: ps.project.competitionCategory,
|
||||||
|
teamEmails: ps.project.teamMembers
|
||||||
|
.filter((tm) => tm.user.passwordHash === null)
|
||||||
|
.map((tm) => tm.user.email),
|
||||||
|
roundName: ps.round.name,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { byCategory, byAward, unactivatedProjects }
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send account setup reminder emails to semi-finalist team members
|
||||||
|
* who haven't set their password yet.
|
||||||
|
*/
|
||||||
|
sendAccountReminders: adminProcedure
|
||||||
|
.input(z.object({
|
||||||
|
projectIds: z.array(z.string()).optional(),
|
||||||
|
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
||||||
|
awardId: z.string().optional(),
|
||||||
|
editionId: z.string(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { editionId, projectIds, category, awardId } = input
|
||||||
|
|
||||||
|
// Build filter for projects
|
||||||
|
let targetProjectIds: string[] = projectIds ?? []
|
||||||
|
|
||||||
|
if (!projectIds?.length) {
|
||||||
|
// Find PASSED projects matching filters
|
||||||
|
const passedWhere: Record<string, unknown> = {
|
||||||
|
state: 'PASSED' as const,
|
||||||
|
round: { competition: { programId: editionId } },
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
passedWhere.project = { competitionCategory: category }
|
||||||
|
}
|
||||||
|
|
||||||
|
const passedStates = await ctx.prisma.projectRoundState.findMany({
|
||||||
|
where: passedWhere,
|
||||||
|
select: { projectId: true },
|
||||||
|
distinct: ['projectId'],
|
||||||
|
})
|
||||||
|
|
||||||
|
targetProjectIds = passedStates.map((ps) => ps.projectId)
|
||||||
|
|
||||||
|
// If filtering by award, intersect with award eligibility
|
||||||
|
if (awardId) {
|
||||||
|
const eligible = await ctx.prisma.awardEligibility.findMany({
|
||||||
|
where: {
|
||||||
|
projectId: { in: targetProjectIds },
|
||||||
|
awardId,
|
||||||
|
eligible: true,
|
||||||
|
},
|
||||||
|
select: { projectId: true },
|
||||||
|
})
|
||||||
|
const eligibleSet = new Set(eligible.map((e) => e.projectId))
|
||||||
|
targetProjectIds = targetProjectIds.filter((id) => eligibleSet.has(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetProjectIds.length === 0) {
|
||||||
|
return { sent: 0, failed: 0, total: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find team members without passwordHash on these projects
|
||||||
|
const projects = await ctx.prisma.project.findMany({
|
||||||
|
where: { id: { in: targetProjectIds } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
teamMembers: {
|
||||||
|
select: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
passwordHash: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Skip projects that already had a recent ACCOUNT_REMINDER
|
||||||
|
const recentReminders = await ctx.prisma.notificationLog.findMany({
|
||||||
|
where: {
|
||||||
|
type: 'ACCOUNT_REMINDER',
|
||||||
|
status: 'SENT',
|
||||||
|
createdAt: { gte: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000) }, // 3 days
|
||||||
|
},
|
||||||
|
select: { email: true },
|
||||||
|
})
|
||||||
|
const recentReminderEmails = new Set(recentReminders.map((r) => r.email).filter(Boolean))
|
||||||
|
|
||||||
|
const expiryMs = await getInviteExpiryMs(ctx.prisma)
|
||||||
|
const expiresAt = new Date(Date.now() + expiryMs)
|
||||||
|
const baseUrl = getBaseUrl()
|
||||||
|
const notifications: NotificationItem[] = []
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
const unactivated = project.teamMembers.filter(
|
||||||
|
(tm) => tm.user.passwordHash === null && !recentReminderEmails.has(tm.user.email)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const tm of unactivated) {
|
||||||
|
// Generate invite token for each user
|
||||||
|
const token = generateInviteToken()
|
||||||
|
await ctx.prisma.user.update({
|
||||||
|
where: { id: tm.user.id },
|
||||||
|
data: { inviteToken: token, inviteTokenExpiresAt: expiresAt },
|
||||||
|
})
|
||||||
|
|
||||||
|
const accountUrl = `/accept-invite?token=${token}`
|
||||||
|
|
||||||
|
notifications.push({
|
||||||
|
email: tm.user.email,
|
||||||
|
name: tm.user.name || '',
|
||||||
|
type: 'ACCOUNT_REMINDER',
|
||||||
|
context: {
|
||||||
|
title: 'Set Up Your Account',
|
||||||
|
message: `Your project "${project.title}" is a semi-finalist. Please set up your account.`,
|
||||||
|
linkUrl: `${baseUrl}${accountUrl}`,
|
||||||
|
metadata: {
|
||||||
|
projectName: project.title,
|
||||||
|
accountUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
projectId: project.id,
|
||||||
|
userId: tm.user.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notifications.length === 0) {
|
||||||
|
return { sent: 0, failed: 0, total: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sendBatchNotifications(notifications)
|
||||||
|
return { sent: result.sent, failed: result.failed, total: notifications.length }
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1008,6 +1008,8 @@ export const fileRouter = router({
|
|||||||
projectWhere.OR = [
|
projectWhere.OR = [
|
||||||
{ title: { contains: input.search, mode: 'insensitive' } },
|
{ title: { contains: input.search, mode: 'insensitive' } },
|
||||||
{ teamName: { contains: input.search, mode: 'insensitive' } },
|
{ teamName: { contains: input.search, mode: 'insensitive' } },
|
||||||
|
{ teamMembers: { some: { user: { name: { contains: input.search, mode: 'insensitive' } } } } },
|
||||||
|
{ teamMembers: { some: { user: { email: { contains: input.search, mode: 'insensitive' } } } } },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1317,6 +1319,8 @@ export const fileRouter = router({
|
|||||||
projectWhere.OR = [
|
projectWhere.OR = [
|
||||||
{ title: { contains: input.search, mode: 'insensitive' } },
|
{ title: { contains: input.search, mode: 'insensitive' } },
|
||||||
{ teamName: { contains: input.search, mode: 'insensitive' } },
|
{ teamName: { contains: input.search, mode: 'insensitive' } },
|
||||||
|
{ teamMembers: { some: { user: { name: { contains: input.search, mode: 'insensitive' } } } } },
|
||||||
|
{ teamMembers: { some: { user: { email: { contains: input.search, mode: 'insensitive' } } } } },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const projectPoolRouter = router({
|
|||||||
where.competitionCategory = competitionCategory
|
where.competitionCategory = competitionCategory
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search in title, teamName, description, institution, country, geographicZone, team member names
|
// Search in title, teamName, description, institution, country, geographicZone, team member names/emails
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ title: { contains: search, mode: 'insensitive' } },
|
{ title: { contains: search, mode: 'insensitive' } },
|
||||||
@@ -67,6 +67,7 @@ export const projectPoolRouter = router({
|
|||||||
{ country: { contains: search, mode: 'insensitive' } },
|
{ country: { contains: search, mode: 'insensitive' } },
|
||||||
{ geographicZone: { contains: search, mode: 'insensitive' } },
|
{ geographicZone: { contains: search, mode: 'insensitive' } },
|
||||||
{ teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } },
|
{ teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } },
|
||||||
|
{ teamMembers: { some: { user: { email: { contains: search, mode: 'insensitive' } } } } },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,6 +345,8 @@ export const projectPoolRouter = router({
|
|||||||
OR: [
|
OR: [
|
||||||
{ title: { contains: search, mode: 'insensitive' } },
|
{ title: { contains: search, mode: 'insensitive' } },
|
||||||
{ teamName: { contains: search, mode: 'insensitive' } },
|
{ teamName: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } },
|
||||||
|
{ teamMembers: { some: { user: { email: { contains: search, mode: 'insensitive' } } } } },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ export const projectRouter = router({
|
|||||||
{ title: { contains: search, mode: 'insensitive' } },
|
{ title: { contains: search, mode: 'insensitive' } },
|
||||||
{ teamName: { contains: search, mode: 'insensitive' } },
|
{ teamName: { contains: search, mode: 'insensitive' } },
|
||||||
{ description: { contains: search, mode: 'insensitive' } },
|
{ description: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } },
|
||||||
|
{ teamMembers: { some: { user: { email: { contains: search, mode: 'insensitive' } } } } },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,17 +165,28 @@ export const projectRouter = router({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
ctx.prisma.project.count({ where }),
|
ctx.prisma.project.count({ where }),
|
||||||
ctx.prisma.projectRoundState.groupBy({
|
// Count distinct projects per state (avoids double-counting projects that passed multiple rounds)
|
||||||
by: ['state'],
|
(async () => {
|
||||||
where: where.programId ? { project: { programId: where.programId as string } } : {},
|
const stateFilter = where.programId ? { project: { programId: where.programId as string } } : {}
|
||||||
_count: true,
|
const states = ['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'] as const
|
||||||
}),
|
const counts = await Promise.all(
|
||||||
|
states.map(async (state) => ({
|
||||||
|
state,
|
||||||
|
count: await ctx.prisma.projectRoundState.findMany({
|
||||||
|
where: { ...stateFilter, state },
|
||||||
|
select: { projectId: true },
|
||||||
|
distinct: ['projectId'],
|
||||||
|
}).then((rows) => rows.length),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return counts
|
||||||
|
})(),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Build round-state counts
|
// Build round-state counts (distinct projects per state)
|
||||||
const statusCounts: Record<string, number> = {}
|
const statusCounts: Record<string, number> = {}
|
||||||
for (const g of roundStateCounts) {
|
for (const g of roundStateCounts) {
|
||||||
statusCounts[g.state] = g._count
|
statusCounts[g.state] = g.count
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectsWithLogos = await attachProjectLogoUrls(projects)
|
const projectsWithLogos = await attachProjectLogoUrls(projects)
|
||||||
@@ -259,6 +272,8 @@ export const projectRouter = router({
|
|||||||
{ title: { contains: search, mode: 'insensitive' } },
|
{ title: { contains: search, mode: 'insensitive' } },
|
||||||
{ teamName: { contains: search, mode: 'insensitive' } },
|
{ teamName: { contains: search, mode: 'insensitive' } },
|
||||||
{ description: { contains: search, mode: 'insensitive' } },
|
{ description: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } },
|
||||||
|
{ teamMembers: { some: { user: { email: { contains: search, mode: 'insensitive' } } } } },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1151,6 +1166,8 @@ export const projectRouter = router({
|
|||||||
where.OR = [
|
where.OR = [
|
||||||
{ title: { contains: search, mode: 'insensitive' } },
|
{ title: { contains: search, mode: 'insensitive' } },
|
||||||
{ teamName: { contains: search, mode: 'insensitive' } },
|
{ teamName: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } },
|
||||||
|
{ teamMembers: { some: { user: { email: { contains: search, mode: 'insensitive' } } } } },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user