- Add super-admin impersonation: "Login As" from user list, red banner with "Return to Admin", audit logged start/end, nested impersonation blocked, onboarding gate skipped during impersonation - Fix semi-finalist stats: check latest terminal state (not any PASSED), use passwordHash OR status=ACTIVE for activation check - Add /admin/semi-finalists detail page with search, category/status filters - Add account_reminder_days setting to notifications tab - Add tRPC resilience: retry on 503/HTML responses, custom fetch detects nginx error pages, exponential backoff (2s/4s/8s) - Reduce dashboard polling intervals (60s stats, 30s activity, 120s semi) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
929 lines
32 KiB
TypeScript
929 lines
32 KiB
TypeScript
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 ──────────────────────────────────────────────────────────────────
|
|
|
|
type ProjectStateCounts = {
|
|
PENDING: number
|
|
IN_PROGRESS: number
|
|
PASSED: number
|
|
REJECTED: number
|
|
COMPLETED: number
|
|
WITHDRAWN: number
|
|
total: number
|
|
}
|
|
|
|
export type PipelineRound = {
|
|
id: string
|
|
name: string
|
|
slug: string
|
|
roundType: RoundType
|
|
status: RoundStatus
|
|
sortOrder: number
|
|
windowOpenAt: Date | null
|
|
windowCloseAt: Date | null
|
|
projectStates: ProjectStateCounts
|
|
assignmentCount: number
|
|
evalSubmitted: number
|
|
evalDraft: number
|
|
evalTotal: number
|
|
filteringPassed: number
|
|
filteringRejected: number
|
|
filteringFlagged: number
|
|
filteringTotal: number
|
|
liveSessionStatus: string | null
|
|
deliberationCount: number
|
|
}
|
|
|
|
export type DashboardAction = {
|
|
id: string
|
|
severity: 'critical' | 'warning' | 'info'
|
|
title: string
|
|
description: string
|
|
href: string
|
|
roundId?: string
|
|
roundType?: RoundType
|
|
count?: number
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function emptyStateCounts(): ProjectStateCounts {
|
|
return { PENDING: 0, IN_PROGRESS: 0, PASSED: 0, REJECTED: 0, COMPLETED: 0, WITHDRAWN: 0, total: 0 }
|
|
}
|
|
|
|
function daysUntil(date: Date): number {
|
|
return Math.ceil((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
|
}
|
|
|
|
function formatRoundType(rt: RoundType): string {
|
|
return rt.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
|
|
}
|
|
|
|
// ─── Router ──────────────────────────────────────────────────────────────────
|
|
|
|
export const dashboardRouter = router({
|
|
/**
|
|
* Get all dashboard stats in a single query batch.
|
|
* Returns pipeline rounds, smart actions, and supporting data.
|
|
*/
|
|
getStats: adminProcedure
|
|
.input(z.object({ editionId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const { editionId } = input
|
|
|
|
const edition = await ctx.prisma.program.findUnique({
|
|
where: { id: editionId },
|
|
select: { name: true, year: true },
|
|
})
|
|
|
|
if (!edition) return null
|
|
|
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
|
|
|
// ── All queries in parallel ──────────────────────────────────────
|
|
|
|
const [
|
|
// Pipeline rounds (all, ordered by sortOrder)
|
|
allRounds,
|
|
// Per-round project state breakdown
|
|
stateBreakdown,
|
|
// Per-round eval data (assignments with eval status)
|
|
roundEvalData,
|
|
// Per-round filtering results
|
|
filteringStats,
|
|
// Live session statuses
|
|
liveSessions,
|
|
// Deliberation session counts
|
|
deliberationCounts,
|
|
// Summary counts
|
|
projectCount,
|
|
newProjectsThisWeek,
|
|
totalJurors,
|
|
activeJurors,
|
|
evaluationStats,
|
|
totalAssignments,
|
|
// Lists
|
|
latestProjects,
|
|
categoryBreakdown,
|
|
oceanIssueBreakdown,
|
|
recentActivity,
|
|
// Recently active
|
|
recentlyActiveEvals,
|
|
// Action signals
|
|
pendingCOIs,
|
|
] = await Promise.all([
|
|
// 1. All pipeline rounds
|
|
ctx.prisma.round.findMany({
|
|
where: { competition: { programId: editionId } },
|
|
orderBy: { sortOrder: 'asc' },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
slug: true,
|
|
roundType: true,
|
|
status: true,
|
|
sortOrder: true,
|
|
windowOpenAt: true,
|
|
windowCloseAt: true,
|
|
_count: {
|
|
select: {
|
|
projectRoundStates: true,
|
|
assignments: true,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
|
|
// 2. Per-round project state counts
|
|
ctx.prisma.projectRoundState.groupBy({
|
|
by: ['roundId', 'state'],
|
|
where: { round: { competition: { programId: editionId } } },
|
|
_count: true,
|
|
}),
|
|
|
|
// 3. Assignments with eval status (for per-round eval aggregation)
|
|
ctx.prisma.assignment.findMany({
|
|
where: { round: { competition: { programId: editionId } } },
|
|
select: {
|
|
roundId: true,
|
|
evaluation: { select: { status: true } },
|
|
},
|
|
}),
|
|
|
|
// 4. Filtering results per round
|
|
ctx.prisma.filteringResult.groupBy({
|
|
by: ['roundId', 'outcome'],
|
|
where: { round: { competition: { programId: editionId } } },
|
|
_count: true,
|
|
}),
|
|
|
|
// 5. Live session statuses
|
|
ctx.prisma.liveVotingSession.findMany({
|
|
where: { round: { competition: { programId: editionId } } },
|
|
select: { roundId: true, status: true },
|
|
}),
|
|
|
|
// 6. Deliberation session counts
|
|
ctx.prisma.deliberationSession.groupBy({
|
|
by: ['roundId'],
|
|
where: { competition: { programId: editionId } },
|
|
_count: true,
|
|
}),
|
|
|
|
// 7. Project count
|
|
ctx.prisma.project.count({
|
|
where: { programId: editionId },
|
|
}),
|
|
|
|
// 8. New projects this week
|
|
ctx.prisma.project.count({
|
|
where: { programId: editionId, createdAt: { gte: sevenDaysAgo } },
|
|
}),
|
|
|
|
// 9. Total jurors
|
|
ctx.prisma.user.count({
|
|
where: {
|
|
roles: { has: 'JURY_MEMBER' },
|
|
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
|
|
assignments: { some: { round: { competition: { programId: editionId } } } },
|
|
},
|
|
}),
|
|
|
|
// 10. Active jurors
|
|
ctx.prisma.user.count({
|
|
where: {
|
|
roles: { has: 'JURY_MEMBER' },
|
|
status: 'ACTIVE',
|
|
assignments: { some: { round: { competition: { programId: editionId } } } },
|
|
},
|
|
}),
|
|
|
|
// 11. Global evaluation stats
|
|
ctx.prisma.evaluation.groupBy({
|
|
by: ['status'],
|
|
where: { assignment: { round: { competition: { programId: editionId } } } },
|
|
_count: true,
|
|
}),
|
|
|
|
// 12. Total assignments
|
|
ctx.prisma.assignment.count({
|
|
where: { round: { competition: { programId: editionId } } },
|
|
}),
|
|
|
|
// 13. Latest projects
|
|
ctx.prisma.project.findMany({
|
|
where: { programId: editionId },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: 8,
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
teamName: true,
|
|
country: true,
|
|
competitionCategory: true,
|
|
oceanIssue: true,
|
|
logoKey: true,
|
|
createdAt: true,
|
|
submittedAt: true,
|
|
status: true,
|
|
},
|
|
}),
|
|
|
|
// 14. Category breakdown
|
|
ctx.prisma.project.groupBy({
|
|
by: ['competitionCategory'],
|
|
where: { programId: editionId },
|
|
_count: true,
|
|
}),
|
|
|
|
// 15. Ocean issue breakdown
|
|
ctx.prisma.project.groupBy({
|
|
by: ['oceanIssue'],
|
|
where: { programId: editionId },
|
|
_count: true,
|
|
}),
|
|
|
|
// 16. Recent activity
|
|
ctx.prisma.auditLog.findMany({
|
|
where: { timestamp: { gte: sevenDaysAgo } },
|
|
orderBy: { timestamp: 'desc' },
|
|
take: 8,
|
|
select: {
|
|
id: true,
|
|
action: true,
|
|
entityType: true,
|
|
timestamp: true,
|
|
user: { select: { name: true } },
|
|
},
|
|
}),
|
|
|
|
// 17. Recently active projects (with recent evaluations)
|
|
ctx.prisma.evaluation.findMany({
|
|
where: {
|
|
status: 'SUBMITTED',
|
|
assignment: {
|
|
round: { competition: { programId: editionId } },
|
|
},
|
|
},
|
|
orderBy: { submittedAt: 'desc' },
|
|
take: 8,
|
|
select: {
|
|
id: true,
|
|
globalScore: true,
|
|
submittedAt: true,
|
|
assignment: {
|
|
select: {
|
|
user: { select: { name: true } },
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
teamName: true,
|
|
country: true,
|
|
competitionCategory: true,
|
|
oceanIssue: true,
|
|
logoKey: true,
|
|
createdAt: true,
|
|
submittedAt: true,
|
|
status: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
|
|
// 18. Pending COIs
|
|
ctx.prisma.conflictOfInterest.count({
|
|
where: {
|
|
hasConflict: true,
|
|
reviewedAt: null,
|
|
assignment: { round: { competition: { programId: editionId } } },
|
|
},
|
|
}),
|
|
])
|
|
|
|
// ── Assemble pipeline rounds ────────────────────────────────────
|
|
|
|
// Build state counts map: roundId -> ProjectStateCounts
|
|
const stateMap = new Map<string, ProjectStateCounts>()
|
|
for (const row of stateBreakdown) {
|
|
if (!stateMap.has(row.roundId)) stateMap.set(row.roundId, emptyStateCounts())
|
|
const counts = stateMap.get(row.roundId)!
|
|
const state = row.state as ProjectRoundStateValue
|
|
if (state in counts) {
|
|
counts[state as keyof Omit<ProjectStateCounts, 'total'>] = row._count
|
|
counts.total += row._count
|
|
}
|
|
}
|
|
|
|
// Build eval map: roundId -> { submitted, draft, total }
|
|
const evalMap = new Map<string, { submitted: number; draft: number; total: number }>()
|
|
for (const a of roundEvalData) {
|
|
if (!evalMap.has(a.roundId)) evalMap.set(a.roundId, { submitted: 0, draft: 0, total: 0 })
|
|
const entry = evalMap.get(a.roundId)!
|
|
entry.total++
|
|
if (a.evaluation?.status === 'SUBMITTED') entry.submitted++
|
|
else if (a.evaluation?.status === 'DRAFT') entry.draft++
|
|
}
|
|
|
|
// Build filtering map: roundId -> { passed, rejected, flagged, total }
|
|
const filterMap = new Map<string, { passed: number; rejected: number; flagged: number; total: number }>()
|
|
for (const row of filteringStats) {
|
|
if (!filterMap.has(row.roundId)) filterMap.set(row.roundId, { passed: 0, rejected: 0, flagged: 0, total: 0 })
|
|
const entry = filterMap.get(row.roundId)!
|
|
entry.total += row._count
|
|
if (row.outcome === 'PASSED') entry.passed = row._count
|
|
else if (row.outcome === 'FILTERED_OUT') entry.rejected = row._count
|
|
else if (row.outcome === 'FLAGGED') entry.flagged = row._count
|
|
}
|
|
|
|
// Build live session map: roundId -> status
|
|
const liveMap = new Map<string, string>()
|
|
for (const s of liveSessions) {
|
|
if (s.roundId) liveMap.set(s.roundId, s.status)
|
|
}
|
|
|
|
// Build deliberation map: roundId -> count
|
|
const delibMap = new Map<string, number>()
|
|
for (const row of deliberationCounts) {
|
|
delibMap.set(row.roundId, row._count)
|
|
}
|
|
|
|
// Assemble pipeline rounds
|
|
const pipelineRounds: PipelineRound[] = allRounds.map((round) => {
|
|
const states = stateMap.get(round.id) ?? emptyStateCounts()
|
|
const evals = evalMap.get(round.id) ?? { submitted: 0, draft: 0, total: 0 }
|
|
const filters = filterMap.get(round.id) ?? { passed: 0, rejected: 0, flagged: 0, total: 0 }
|
|
return {
|
|
id: round.id,
|
|
name: round.name,
|
|
slug: round.slug,
|
|
roundType: round.roundType,
|
|
status: round.status,
|
|
sortOrder: round.sortOrder,
|
|
windowOpenAt: round.windowOpenAt,
|
|
windowCloseAt: round.windowCloseAt,
|
|
projectStates: states,
|
|
assignmentCount: round._count.assignments,
|
|
evalSubmitted: evals.submitted,
|
|
evalDraft: evals.draft,
|
|
evalTotal: evals.total,
|
|
filteringPassed: filters.passed,
|
|
filteringRejected: filters.rejected,
|
|
filteringFlagged: filters.flagged,
|
|
filteringTotal: filters.total,
|
|
liveSessionStatus: liveMap.get(round.id) ?? null,
|
|
deliberationCount: delibMap.get(round.id) ?? 0,
|
|
}
|
|
})
|
|
|
|
// ── Determine active round ──────────────────────────────────────
|
|
|
|
const activeRound = pipelineRounds.find((r) => r.status === 'ROUND_ACTIVE') ?? null
|
|
const activeRoundId = activeRound?.id ?? null
|
|
|
|
// ── Compute smart actions ───────────────────────────────────────
|
|
|
|
const nextActions: DashboardAction[] = []
|
|
const activeRounds = pipelineRounds.filter((r) => r.status === 'ROUND_ACTIVE')
|
|
const lastActiveSortOrder = Math.max(...activeRounds.map((r) => r.sortOrder), -1)
|
|
|
|
// 1. Next draft round (only the first one after the last active)
|
|
const nextDraft = pipelineRounds.find(
|
|
(r) => r.status === 'ROUND_DRAFT' && r.sortOrder > lastActiveSortOrder
|
|
)
|
|
if (nextDraft) {
|
|
nextActions.push({
|
|
id: `draft-${nextDraft.id}`,
|
|
severity: 'info',
|
|
title: `Configure "${nextDraft.name}"`,
|
|
description: `Next round (${formatRoundType(nextDraft.roundType)}) is in draft`,
|
|
href: `/admin/rounds/${nextDraft.id}`,
|
|
roundId: nextDraft.id,
|
|
roundType: nextDraft.roundType,
|
|
})
|
|
}
|
|
|
|
// 2. Per-active-round actions
|
|
for (const round of activeRounds) {
|
|
// Evaluation rounds: flag unassigned projects
|
|
if (round.roundType === 'EVALUATION' && round.projectStates.total > 0 && round.assignmentCount === 0) {
|
|
nextActions.push({
|
|
id: `unassigned-${round.id}`,
|
|
severity: 'warning',
|
|
title: `${round.projectStates.total} unassigned projects`,
|
|
description: `"${round.name}" has projects without jury assignments`,
|
|
href: `/admin/rounds/${round.id}`,
|
|
roundId: round.id,
|
|
roundType: round.roundType,
|
|
count: round.projectStates.total,
|
|
})
|
|
}
|
|
|
|
// Filtering rounds: flag if filtering not started
|
|
if (round.roundType === 'FILTERING' && round.filteringTotal === 0 && round.projectStates.total > 0) {
|
|
nextActions.push({
|
|
id: `filtering-${round.id}`,
|
|
severity: 'warning',
|
|
title: 'Filtering not started',
|
|
description: `"${round.name}" has ${round.projectStates.total} projects awaiting filtering`,
|
|
href: `/admin/rounds/${round.id}`,
|
|
roundId: round.id,
|
|
roundType: round.roundType,
|
|
})
|
|
}
|
|
|
|
// Deadline warnings
|
|
if (round.windowCloseAt) {
|
|
const days = daysUntil(round.windowCloseAt)
|
|
if (days > 0 && days <= 3) {
|
|
nextActions.push({
|
|
id: `deadline-${round.id}`,
|
|
severity: 'critical',
|
|
title: `${days}d until "${round.name}" closes`,
|
|
description: `Window closes ${round.windowCloseAt.toLocaleDateString()}`,
|
|
href: `/admin/rounds/${round.id}`,
|
|
roundId: round.id,
|
|
roundType: round.roundType,
|
|
})
|
|
} else if (days > 3 && days <= 7) {
|
|
nextActions.push({
|
|
id: `deadline-${round.id}`,
|
|
severity: 'warning',
|
|
title: `${days}d until "${round.name}" closes`,
|
|
description: `Window closes ${round.windowCloseAt.toLocaleDateString()}`,
|
|
href: `/admin/rounds/${round.id}`,
|
|
roundId: round.id,
|
|
roundType: round.roundType,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Pending COIs
|
|
if (pendingCOIs > 0) {
|
|
nextActions.push({
|
|
id: 'pending-cois',
|
|
severity: 'warning',
|
|
title: `${pendingCOIs} COI declarations pending`,
|
|
description: 'Jury members have declared conflicts that need admin review',
|
|
href: '/admin/rounds',
|
|
count: pendingCOIs,
|
|
})
|
|
}
|
|
|
|
// Sort by severity
|
|
const severityOrder = { critical: 0, warning: 1, info: 2 }
|
|
nextActions.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity])
|
|
|
|
// ── Return ──────────────────────────────────────────────────────
|
|
|
|
// Deduplicate recently active projects (same project may have multiple evals)
|
|
const seenProjectIds = new Set<string>()
|
|
const recentlyActiveProjects = recentlyActiveEvals
|
|
.filter((e) => {
|
|
const pid = e.assignment.project.id
|
|
if (seenProjectIds.has(pid)) return false
|
|
seenProjectIds.add(pid)
|
|
return true
|
|
})
|
|
.map((e) => ({
|
|
...e.assignment.project,
|
|
latestEvaluator: e.assignment.user.name,
|
|
latestScore: e.globalScore,
|
|
evaluatedAt: e.submittedAt,
|
|
}))
|
|
|
|
return {
|
|
edition,
|
|
// Pipeline
|
|
pipelineRounds,
|
|
activeRoundId,
|
|
// Smart actions
|
|
nextActions,
|
|
// Summary counts
|
|
projectCount,
|
|
newProjectsThisWeek,
|
|
totalJurors,
|
|
activeJurors,
|
|
evaluationStats,
|
|
totalAssignments,
|
|
pendingCOIs,
|
|
// Lists
|
|
latestProjects,
|
|
recentlyActiveProjects,
|
|
categoryBreakdown,
|
|
oceanIssueBreakdown,
|
|
recentActivity,
|
|
}
|
|
}),
|
|
|
|
getRecentEvaluations: adminProcedure
|
|
.input(z.object({ editionId: z.string(), limit: z.number().int().min(1).max(50).optional() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const take = input.limit ?? 10
|
|
|
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
|
where: {
|
|
status: 'SUBMITTED',
|
|
assignment: {
|
|
round: { competition: { programId: input.editionId } },
|
|
},
|
|
},
|
|
orderBy: { submittedAt: 'desc' },
|
|
take,
|
|
select: {
|
|
id: true,
|
|
globalScore: true,
|
|
binaryDecision: true,
|
|
submittedAt: true,
|
|
feedbackText: true,
|
|
assignment: {
|
|
select: {
|
|
project: { select: { id: true, title: true } },
|
|
round: { select: { id: true, name: true } },
|
|
user: { select: { id: true, name: true, email: true } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
return evaluations
|
|
}),
|
|
|
|
getRecentActivity: adminProcedure
|
|
.input(z.object({ limit: z.number().int().min(1).max(20).optional() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
|
return ctx.prisma.auditLog.findMany({
|
|
where: { timestamp: { gte: sevenDaysAgo } },
|
|
orderBy: { timestamp: 'desc' },
|
|
take: input.limit ?? 8,
|
|
select: {
|
|
id: true,
|
|
action: true,
|
|
entityType: true,
|
|
timestamp: true,
|
|
user: { select: { name: true } },
|
|
},
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* 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 projects whose LATEST terminal state (PASSED/REJECTED/WITHDRAWN) is PASSED.
|
|
// A project that passed round 1 but was rejected in round 2 is NOT a semi-finalist.
|
|
const terminalStates = await ctx.prisma.projectRoundState.findMany({
|
|
where: {
|
|
state: { in: ['PASSED', 'REJECTED', 'WITHDRAWN'] },
|
|
round: { competition: { programId: editionId } },
|
|
},
|
|
select: {
|
|
projectId: true,
|
|
state: 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,
|
|
status: true,
|
|
passwordHash: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// For each project, keep only the terminal state from the highest-sortOrder round
|
|
const projectMap = new Map<string, (typeof terminalStates)[0]>()
|
|
for (const ts of terminalStates) {
|
|
const existing = projectMap.get(ts.projectId)
|
|
if (!existing || ts.round.sortOrder > existing.round.sortOrder) {
|
|
projectMap.set(ts.projectId, ts)
|
|
}
|
|
}
|
|
|
|
// Only include projects whose latest terminal state is PASSED
|
|
const uniqueProjects = Array.from(projectMap.values()).filter(ps => ps.state === 'PASSED')
|
|
|
|
// 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 || tm.user.status === 'ACTIVE')
|
|
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 || tm.user.status === 'ACTIVE')
|
|
if (hasActivated) accountsSet++
|
|
else accountsNotSet++
|
|
}
|
|
}
|
|
return { awardId, awardName, total: projectIds.size, accountsSet, accountsNotSet }
|
|
})
|
|
|
|
// Unactivated projects: no team member has set up their account
|
|
const unactivatedProjects = uniqueProjects
|
|
.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: ps.round.name,
|
|
}))
|
|
|
|
return { byCategory, byAward, 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.
|
|
*/
|
|
getSemiFinalistDetail: adminProcedure
|
|
.input(z.object({ editionId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const { editionId } = input
|
|
|
|
// Fetch all terminal states for projects in this edition
|
|
const terminalStates = await ctx.prisma.projectRoundState.findMany({
|
|
where: {
|
|
state: { in: ['PASSED', 'REJECTED', 'WITHDRAWN'] },
|
|
round: { competition: { programId: editionId } },
|
|
},
|
|
select: {
|
|
projectId: true,
|
|
state: true,
|
|
round: { select: { id: true, name: true, sortOrder: true, roundType: true } },
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
teamName: true,
|
|
competitionCategory: true,
|
|
country: true,
|
|
teamMembers: {
|
|
select: {
|
|
role: true,
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
email: true,
|
|
name: true,
|
|
status: true,
|
|
passwordHash: true,
|
|
lastLoginAt: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Keep the latest terminal state per project
|
|
const projectMap = new Map<string, (typeof terminalStates)[0]>()
|
|
for (const ts of terminalStates) {
|
|
const existing = projectMap.get(ts.projectId)
|
|
if (!existing || ts.round.sortOrder > existing.round.sortOrder) {
|
|
projectMap.set(ts.projectId, ts)
|
|
}
|
|
}
|
|
|
|
// Only include PASSED projects
|
|
const semiFinalists = Array.from(projectMap.values())
|
|
.filter(ps => ps.state === 'PASSED')
|
|
.map(ps => ({
|
|
projectId: ps.projectId,
|
|
title: ps.project.title,
|
|
teamName: ps.project.teamName,
|
|
category: ps.project.competitionCategory,
|
|
country: ps.project.country,
|
|
currentRound: ps.round.name,
|
|
currentRoundType: ps.round.roundType,
|
|
teamMembers: ps.project.teamMembers.map(tm => ({
|
|
name: tm.user.name,
|
|
email: tm.user.email,
|
|
role: tm.role,
|
|
accountStatus: tm.user.passwordHash !== null
|
|
? 'active' as const
|
|
: tm.user.status === 'ACTIVE'
|
|
? 'active' as const
|
|
: tm.user.status === 'INVITED'
|
|
? 'invited' as const
|
|
: 'none' as const,
|
|
lastLogin: tm.user.lastLoginAt,
|
|
})),
|
|
allActivated: ps.project.teamMembers.every(
|
|
tm => tm.user.passwordHash !== null || tm.user.status === 'ACTIVE'
|
|
),
|
|
}))
|
|
.sort((a, b) => a.title.localeCompare(b.title))
|
|
|
|
return semiFinalists
|
|
}),
|
|
|
|
/**
|
|
* 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,
|
|
status: 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 && tm.user.status !== 'ACTIVE' && !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 }
|
|
}),
|
|
})
|