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:
2026-03-04 15:41:03 +01:00
parent af03c12ae5
commit 43e21c6c6e
10 changed files with 622 additions and 14 deletions

View File

@@ -1004,6 +1004,8 @@ export const analyticsRouter = router({
where.OR = [
{ title: { 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' } } } } },
]
}

View File

@@ -1,6 +1,10 @@
import { z } from 'zod'
import { router, adminProcedure } from '../trpc'
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 ──────────────────────────────────────────────────────────────────
@@ -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 }
}),
})

View File

@@ -1008,6 +1008,8 @@ export const fileRouter = router({
projectWhere.OR = [
{ title: { 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 = [
{ title: { 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' } } } } },
]
}

View File

@@ -57,7 +57,7 @@ export const projectPoolRouter = router({
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) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
@@ -67,6 +67,7 @@ export const projectPoolRouter = router({
{ country: { contains: search, mode: 'insensitive' } },
{ geographicZone: { 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: [
{ title: { 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' } } } } },
],
}
}

View File

@@ -133,6 +133,8 @@ export const projectRouter = router({
{ title: { contains: search, mode: 'insensitive' } },
{ teamName: { 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.projectRoundState.groupBy({
by: ['state'],
where: where.programId ? { project: { programId: where.programId as string } } : {},
_count: true,
}),
// Count distinct projects per state (avoids double-counting projects that passed multiple rounds)
(async () => {
const stateFilter = where.programId ? { project: { programId: where.programId as string } } : {}
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> = {}
for (const g of roundStateCounts) {
statusCounts[g.state] = g._count
statusCounts[g.state] = g.count
}
const projectsWithLogos = await attachProjectLogoUrls(projects)
@@ -259,6 +272,8 @@ export const projectRouter = router({
{ title: { contains: search, mode: 'insensitive' } },
{ teamName: { 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 = [
{ title: { 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' } } } } },
]
}