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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user