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