feat: round user tracker + fix INVITED status not updating on login
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:
2026-03-05 14:00:19 +01:00
parent ee8e90132e
commit 8cdcc85555
4 changed files with 335 additions and 21 deletions

View File

@@ -703,6 +703,117 @@ export const dashboardRouter = router({
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.
* 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(),
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
awardId: z.string().optional(),
roundId: z.string().optional(),
editionId: z.string(),
}))
.mutation(async ({ ctx, input }) => {
const { editionId, projectIds, category, awardId } = input
const { editionId, projectIds, category, awardId, roundId } = input
// Build filter for projects
let targetProjectIds: string[] = projectIds ?? []
@@ -812,7 +924,13 @@ export const dashboardRouter = router({
// Find PASSED projects matching filters
const passedWhere: Record<string, unknown> = {
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) {