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:
2026-03-04 17:55:44 +01:00
parent b1a994a9d6
commit 6c52e519e5
18 changed files with 814 additions and 74 deletions

View File

@@ -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) {