feat: impersonation system, semi-finalist detail page, tRPC resilience
- 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>
This commit is contained in:
@@ -586,15 +586,16 @@ export const dashboardRouter = router({
|
||||
.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({
|
||||
// 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: 'PASSED',
|
||||
state: { in: ['PASSED', 'REJECTED', 'WITHDRAWN'] },
|
||||
round: { competition: { programId: editionId } },
|
||||
},
|
||||
select: {
|
||||
projectId: true,
|
||||
state: true,
|
||||
round: { select: { id: true, name: true, sortOrder: true } },
|
||||
project: {
|
||||
select: {
|
||||
@@ -608,6 +609,7 @@ export const dashboardRouter = router({
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
status: true,
|
||||
passwordHash: true,
|
||||
},
|
||||
},
|
||||
@@ -618,16 +620,17 @@ export const dashboardRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueProjects = Array.from(projectMap.values())
|
||||
// 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 }>()
|
||||
@@ -636,7 +639,7 @@ export const dashboardRouter = router({
|
||||
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)
|
||||
const hasActivated = ps.project.teamMembers.some((tm) => tm.user.passwordHash !== null || tm.user.status === 'ACTIVE')
|
||||
if (hasActivated) entry.accountsSet++
|
||||
else entry.accountsNotSet++
|
||||
}
|
||||
@@ -676,7 +679,7 @@ export const dashboardRouter = router({
|
||||
for (const pid of projectIds) {
|
||||
const ps = projectMap.get(pid)
|
||||
if (ps) {
|
||||
const hasActivated = ps.project.teamMembers.some((tm) => tm.user.passwordHash !== null)
|
||||
const hasActivated = ps.project.teamMembers.some((tm) => tm.user.passwordHash !== null || tm.user.status === 'ACTIVE')
|
||||
if (hasActivated) accountsSet++
|
||||
else accountsNotSet++
|
||||
}
|
||||
@@ -684,15 +687,15 @@ export const dashboardRouter = router({
|
||||
return { awardId, awardName, total: projectIds.size, accountsSet, accountsNotSet }
|
||||
})
|
||||
|
||||
// Unactivated projects: no team member has passwordHash
|
||||
// Unactivated projects: no team member has set up their account
|
||||
const unactivatedProjects = uniqueProjects
|
||||
.filter((ps) => !ps.project.teamMembers.some((tm) => tm.user.passwordHash !== null))
|
||||
.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)
|
||||
.filter((tm) => tm.user.passwordHash === null && tm.user.status !== 'ACTIVE')
|
||||
.map((tm) => tm.user.email),
|
||||
roundName: ps.round.name,
|
||||
}))
|
||||
@@ -700,6 +703,94 @@ export const dashboardRouter = router({
|
||||
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.
|
||||
@@ -768,6 +859,7 @@ export const dashboardRouter = router({
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
status: true,
|
||||
passwordHash: true,
|
||||
},
|
||||
},
|
||||
@@ -794,7 +886,7 @@ export const dashboardRouter = router({
|
||||
|
||||
for (const project of projects) {
|
||||
const unactivated = project.teamMembers.filter(
|
||||
(tm) => tm.user.passwordHash === null && !recentReminderEmails.has(tm.user.email)
|
||||
(tm) => tm.user.passwordHash === null && tm.user.status !== 'ACTIVE' && !recentReminderEmails.has(tm.user.email)
|
||||
)
|
||||
|
||||
for (const tm of unactivated) {
|
||||
|
||||
Reference in New Issue
Block a user