feat: round user tracker + fix INVITED status not updating on login
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Replace Semi-Finalist Tracker with Round User Tracker on dashboard - New getRoundUserStats query: round-aware account activation stats - Round selector dropdown to view any round's passed projects - sendAccountReminders now accepts optional roundId for scoped reminders - Fix: signIn callback now sets status=ACTIVE for INVITED users on login - DB fix: 5 users who logged in via magic link but stayed INVITED → ACTIVE Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,7 +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'
|
import { RoundUserTracker } from '@/components/dashboard/round-user-tracker'
|
||||||
|
|
||||||
type DashboardContentProps = {
|
type DashboardContentProps = {
|
||||||
editionId: string
|
editionId: string
|
||||||
@@ -126,11 +126,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||||||
{ limit: 8 },
|
{ limit: 8 },
|
||||||
{ enabled: !!editionId, refetchInterval: 30_000 }
|
{ enabled: !!editionId, refetchInterval: 30_000 }
|
||||||
)
|
)
|
||||||
const { data: semiFinalistStats } = trpc.dashboard.getSemiFinalistStats.useQuery(
|
// Round User Tracker is self-contained — it fetches its own data
|
||||||
{ editionId },
|
|
||||||
{ enabled: !!editionId, refetchInterval: 120_000 }
|
|
||||||
)
|
|
||||||
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery()
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <DashboardSkeleton />
|
return <DashboardSkeleton />
|
||||||
@@ -277,17 +273,9 @@ 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
|
<RoundUserTracker editionId={editionId} />
|
||||||
byCategory={semiFinalistStats.byCategory}
|
|
||||||
byAward={semiFinalistStats.byAward}
|
|
||||||
unactivatedProjects={semiFinalistStats.unactivatedProjects}
|
|
||||||
editionId={editionId}
|
|
||||||
reminderThresholdDays={featureFlags?.accountReminderDays}
|
|
||||||
/>
|
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
)}
|
|
||||||
|
|
||||||
<AnimatedCard index={7}>
|
<AnimatedCard index={7}>
|
||||||
<ActivityFeed activity={liveActivity ?? recentActivity} />
|
<ActivityFeed activity={liveActivity ?? recentActivity} />
|
||||||
|
|||||||
198
src/components/dashboard/round-user-tracker.tsx
Normal file
198
src/components/dashboard/round-user-tracker.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
'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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Send,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const categoryLabels: Record<string, string> = {
|
||||||
|
STARTUP: 'Startup',
|
||||||
|
BUSINESS_CONCEPT: 'Business Concept',
|
||||||
|
UNKNOWN: 'Unknown',
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoundUserTrackerProps = {
|
||||||
|
editionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoundUserTracker({ editionId }: RoundUserTrackerProps) {
|
||||||
|
const [selectedRoundId, setSelectedRoundId] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
const { data, isLoading } = trpc.dashboard.getRoundUserStats.useQuery(
|
||||||
|
{ editionId, roundId: selectedRoundId },
|
||||||
|
{ enabled: !!editionId, refetchInterval: 120_000 }
|
||||||
|
)
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
const sendReminders = trpc.dashboard.sendAccountReminders.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
toast.success(`Sent ${result.sent} reminder${result.sent !== 1 ? 's' : ''}${result.failed > 0 ? `, ${result.failed} failed` : ''}`)
|
||||||
|
utils.dashboard.getRoundUserStats.invalidate()
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(`Failed to send reminders: ${err.message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const [sendingTarget, setSendingTarget] = useState<string | null>(null)
|
||||||
|
|
||||||
|
if (isLoading || !data) return null
|
||||||
|
|
||||||
|
const { rounds, byCategory } = data
|
||||||
|
const effectiveRoundId = data.selectedRoundId
|
||||||
|
|
||||||
|
// Don't render if no rounds or no data
|
||||||
|
if (!effectiveRoundId || rounds.length === 0) return 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 selectedRound = rounds.find(r => r.id === effectiveRoundId)
|
||||||
|
|
||||||
|
const handleSendReminder = async (target: string, opts: { category?: 'STARTUP' | 'BUSINESS_CONCEPT' }) => {
|
||||||
|
setSendingTarget(target)
|
||||||
|
try {
|
||||||
|
await sendReminders.mutateAsync({
|
||||||
|
editionId,
|
||||||
|
roundId: effectiveRoundId,
|
||||||
|
category: opts.category,
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setSendingTarget(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Users className="h-4 w-4 text-brand-blue" />
|
||||||
|
Round User Tracker
|
||||||
|
</CardTitle>
|
||||||
|
<Badge variant="outline" className="text-xs shrink-0">
|
||||||
|
{totalActivated}/{totalProjects} activated
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{/* Round selector */}
|
||||||
|
<Select
|
||||||
|
value={effectiveRoundId}
|
||||||
|
onValueChange={(val) => setSelectedRoundId(val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs mt-2">
|
||||||
|
<SelectValue placeholder="Select round" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{rounds.map((r) => (
|
||||||
|
<SelectItem key={r.id} value={r.id} className="text-xs">
|
||||||
|
{r.name}
|
||||||
|
{r.status === 'ROUND_ACTIVE' && (
|
||||||
|
<span className="ml-1.5 text-emerald-600">(active)</span>
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Subtitle showing round context */}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Projects that passed <span className="font-medium">{selectedRound?.name ?? 'this round'}</span> — account activation status
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="font-semibold">
|
||||||
|
Total: {totalActivated} of {totalProjects} 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -390,11 +390,21 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
user.mustSetPassword = dbUser.mustSetPassword || !dbUser.passwordHash
|
user.mustSetPassword = dbUser.mustSetPassword || !dbUser.passwordHash
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last login time on actual sign-in
|
// Update last login time on actual sign-in, and activate INVITED users on login
|
||||||
if (user.email) {
|
if (user.email) {
|
||||||
|
const loginUser = await prisma.user.findUnique({
|
||||||
|
where: { email: user.email },
|
||||||
|
select: { status: true },
|
||||||
|
})
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { email: user.email },
|
where: { email: user.email },
|
||||||
data: { lastLoginAt: new Date() },
|
data: {
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
// If user is still INVITED but successfully logged in, activate them
|
||||||
|
...(loginUser && loginUser.status === 'INVITED'
|
||||||
|
? { status: 'ACTIVE' }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// Ignore errors from updating last login
|
// Ignore errors from updating last login
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -703,6 +703,117 @@ export const dashboardRouter = router({
|
|||||||
return { byCategory, byAward, unactivatedProjects }
|
return { byCategory, byAward, unactivatedProjects }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round User Tracker: for a given round, find projects that PASSED that round
|
||||||
|
* and show team member account activation stats grouped by category.
|
||||||
|
* If no roundId is provided, uses the latest active or most recently closed round.
|
||||||
|
*/
|
||||||
|
getRoundUserStats: adminProcedure
|
||||||
|
.input(z.object({ editionId: z.string(), roundId: z.string().optional() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const { editionId, roundId } = input
|
||||||
|
|
||||||
|
// Get all rounds for this edition to power the round selector
|
||||||
|
const allRounds = await ctx.prisma.round.findMany({
|
||||||
|
where: { competition: { programId: editionId } },
|
||||||
|
select: { id: true, name: true, sortOrder: true, status: true, roundType: true },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Determine which round to show
|
||||||
|
let selectedRoundId = roundId
|
||||||
|
if (!selectedRoundId) {
|
||||||
|
// Pick latest active round, or if none, the most recently closed
|
||||||
|
const activeRound = allRounds.find(r => r.status === 'ROUND_ACTIVE')
|
||||||
|
if (activeRound) {
|
||||||
|
selectedRoundId = activeRound.id
|
||||||
|
} else {
|
||||||
|
const closedRounds = allRounds.filter(r => r.status === 'ROUND_CLOSED' || r.status === 'ROUND_ARCHIVED')
|
||||||
|
if (closedRounds.length > 0) {
|
||||||
|
selectedRoundId = closedRounds[closedRounds.length - 1].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedRoundId) {
|
||||||
|
return { rounds: allRounds, selectedRoundId: null, byCategory: [], unactivatedProjects: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedRound = allRounds.find(r => r.id === selectedRoundId)
|
||||||
|
|
||||||
|
// Find projects that PASSED this specific round
|
||||||
|
const passedStates = await ctx.prisma.projectRoundState.findMany({
|
||||||
|
where: {
|
||||||
|
roundId: selectedRoundId,
|
||||||
|
state: 'PASSED',
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
projectId: true,
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
competitionCategory: true,
|
||||||
|
teamMembers: {
|
||||||
|
select: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
status: true,
|
||||||
|
passwordHash: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
const catMap = new Map<string, { total: number; accountsSet: number; accountsNotSet: number }>()
|
||||||
|
for (const ps of passedStates) {
|
||||||
|
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 || tm.user.status === 'ACTIVE'
|
||||||
|
)
|
||||||
|
if (hasActivated) entry.accountsSet++
|
||||||
|
else entry.accountsNotSet++
|
||||||
|
}
|
||||||
|
|
||||||
|
const byCategory = Array.from(catMap.entries()).map(([category, counts]) => ({
|
||||||
|
category,
|
||||||
|
...counts,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Unactivated projects
|
||||||
|
const unactivatedProjects = passedStates
|
||||||
|
.filter((ps) => !ps.project.teamMembers.some(
|
||||||
|
(tm) => tm.user.passwordHash !== null || tm.user.status === 'ACTIVE'
|
||||||
|
))
|
||||||
|
.map((ps) => ({
|
||||||
|
projectId: ps.projectId,
|
||||||
|
projectTitle: ps.project.title,
|
||||||
|
category: ps.project.competitionCategory,
|
||||||
|
teamEmails: ps.project.teamMembers
|
||||||
|
.filter((tm) => tm.user.passwordHash === null && tm.user.status !== 'ACTIVE')
|
||||||
|
.map((tm) => tm.user.email),
|
||||||
|
roundName: selectedRound?.name ?? '',
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
rounds: allRounds,
|
||||||
|
selectedRoundId,
|
||||||
|
byCategory,
|
||||||
|
unactivatedProjects,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get detailed semi-finalist list for the "See All" page.
|
* Get detailed semi-finalist list for the "See All" page.
|
||||||
* Returns every project whose latest terminal state is PASSED, with team and round info.
|
* Returns every project whose latest terminal state is PASSED, with team and round info.
|
||||||
@@ -800,10 +911,11 @@ export const dashboardRouter = router({
|
|||||||
projectIds: z.array(z.string()).optional(),
|
projectIds: z.array(z.string()).optional(),
|
||||||
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
|
||||||
awardId: z.string().optional(),
|
awardId: z.string().optional(),
|
||||||
|
roundId: z.string().optional(),
|
||||||
editionId: z.string(),
|
editionId: z.string(),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { editionId, projectIds, category, awardId } = input
|
const { editionId, projectIds, category, awardId, roundId } = input
|
||||||
|
|
||||||
// Build filter for projects
|
// Build filter for projects
|
||||||
let targetProjectIds: string[] = projectIds ?? []
|
let targetProjectIds: string[] = projectIds ?? []
|
||||||
@@ -812,7 +924,13 @@ export const dashboardRouter = router({
|
|||||||
// Find PASSED projects matching filters
|
// Find PASSED projects matching filters
|
||||||
const passedWhere: Record<string, unknown> = {
|
const passedWhere: Record<string, unknown> = {
|
||||||
state: 'PASSED' as const,
|
state: 'PASSED' as const,
|
||||||
round: { competition: { programId: editionId } },
|
}
|
||||||
|
|
||||||
|
// If roundId is provided, scope to that specific round; otherwise edition-wide
|
||||||
|
if (roundId) {
|
||||||
|
passedWhere.roundId = roundId
|
||||||
|
} else {
|
||||||
|
passedWhere.round = { competition: { programId: editionId } }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
|
|||||||
Reference in New Issue
Block a user