fix: security hardening — block self-registration, SSE auth, audit logging fixes
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

Security fixes:
- Block self-registration via magic link (PrismaAdapter createUser throws)
- Magic links only sent to existing ACTIVE users (prevents enumeration)
- signIn callback rejects non-existent users (defense-in-depth)
- Change schema default role from JURY_MEMBER to APPLICANT
- Add authentication to live-voting SSE stream endpoint
- Fix false FILE_OPENED/FILE_DOWNLOADED audit events on page load
  (remove purpose from eagerly pre-fetched URL queries)

Bug fixes:
- Fix impersonation skeleton screen on applicant dashboard
- Fix onboarding redirect loop in auth layout

Observer dashboard redesign (Steps 1-6):
- Clickable round pipeline with selected round highlighting
- Round-type-specific dashboard panels (intake, filtering, evaluation,
  submission, mentoring, live final, deliberation)
- Enhanced activity feed with server-side humanization
- Previous round comparison section
- New backend queries for round-specific analytics

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 20:18:50 +01:00
parent 13f125af28
commit 875c2e8f48
23 changed files with 2126 additions and 410 deletions

View File

@@ -4,6 +4,8 @@ import { normalizeCountryToCode } from '@/lib/countries'
import { getUserAvatarUrl } from '../utils/avatar-url'
import { getProjectLogoUrl } from '../utils/project-logo-url'
import { aggregateVotes } from '../services/deliberation'
import { validateRoundConfig } from '@/types/competition-configs'
import type { LiveFinalConfig } from '@/types/competition-configs'
const editionOrRoundInput = z.object({
roundId: z.string().optional(),
@@ -1482,13 +1484,26 @@ export const analyticsRouter = router({
* Activity feed — recent audit log entries for observer dashboard
*/
getActivityFeed: observerProcedure
.input(z.object({ limit: z.number().min(1).max(50).default(10) }).optional())
.input(z.object({
limit: z.number().min(1).max(50).default(10),
roundId: z.string().optional(),
}).optional())
.query(async ({ ctx, input }) => {
const limit = input?.limit ?? 10
const roundId = input?.roundId
const entries = await ctx.prisma.decisionAuditLog.findMany({
// --- DecisionAuditLog entries (dot-notation events) ---
const dalWhere: Record<string, unknown> = {}
if (roundId) {
dalWhere.OR = [
{ entityType: 'Round', entityId: roundId },
{ detailsJson: { path: ['roundId'], equals: roundId } },
]
}
const dalEntries = await ctx.prisma.decisionAuditLog.findMany({
where: dalWhere,
orderBy: { createdAt: 'desc' },
take: limit,
take: limit * 2,
select: {
id: true,
eventType: true,
@@ -1500,25 +1515,203 @@ export const analyticsRouter = router({
},
})
// --- AuditLog entries (SCREAMING_SNAKE_CASE actions) ---
const alActions = [
'EVALUATION_SUBMITTED', 'EVALUATION_SAVE_DRAFT',
'PROJECT_CREATE', 'PROJECT_UPDATE',
'FILE_VIEWED', 'FILE_OPENED', 'FILE_DOWNLOADED',
]
const alWhere: Record<string, unknown> = { action: { in: alActions } }
if (roundId) {
alWhere.detailsJson = { path: ['roundId'], equals: roundId }
}
const alEntries = await ctx.prisma.auditLog.findMany({
where: alWhere,
orderBy: { timestamp: 'desc' },
take: limit * 2,
select: {
id: true,
action: true,
entityType: true,
entityId: true,
userId: true,
detailsJson: true,
timestamp: true,
},
})
// Batch-fetch actor names
const actorIds = [...new Set(entries.map((e) => e.actorId).filter(Boolean))] as string[]
const actors = actorIds.length > 0
const allActorIds = [
...new Set([
...dalEntries.map((e) => e.actorId),
...alEntries.map((e) => e.userId),
].filter(Boolean)),
] as string[]
const actors = allActorIds.length > 0
? await ctx.prisma.user.findMany({
where: { id: { in: actorIds } },
where: { id: { in: allActorIds } },
select: { id: true, name: true },
})
: []
const actorMap = new Map(actors.map((a) => [a.id, a.name]))
return entries.map((entry) => ({
id: entry.id,
eventType: entry.eventType,
entityType: entry.entityType,
entityId: entry.entityId,
actorName: entry.actorId ? actorMap.get(entry.actorId) ?? null : null,
details: entry.detailsJson as Record<string, unknown> | null,
createdAt: entry.createdAt,
}))
type FeedItem = {
id: string
description: string
category: 'round' | 'evaluation' | 'project' | 'file' | 'deliberation' | 'system'
createdAt: Date
}
// Humanize DecisionAuditLog entries
const dalItems: FeedItem[] = dalEntries
.filter((e) => !e.eventType.includes('transitioned') && !e.eventType.includes('cursor_updated'))
.map((entry) => {
const actor = entry.actorId ? actorMap.get(entry.actorId) ?? 'System' : 'System'
const details = (entry.detailsJson ?? {}) as Record<string, unknown>
const roundName = (details.roundName ?? '') as string
const projectTitle = (details.projectTitle ?? details.projectName ?? '') as string
let description: string
let category: FeedItem['category'] = 'system'
switch (entry.eventType) {
case 'round.activated':
case 'round.reopened':
description = roundName ? `${roundName} was opened` : 'A round was opened'
category = 'round'
break
case 'round.closed':
description = roundName ? `${roundName} was closed` : 'A round was closed'
category = 'round'
break
case 'round.archived':
description = roundName ? `${roundName} was archived` : 'A round was archived'
category = 'round'
break
case 'round.finalized':
description = roundName ? `Results finalized for ${roundName}` : 'Round results finalized'
category = 'round'
break
case 'results.locked':
description = roundName ? `Results locked for ${roundName}` : 'Results were locked'
category = 'round'
break
case 'results.unlocked':
description = roundName ? `Results unlocked for ${roundName}` : 'Results were unlocked'
category = 'round'
break
case 'override.applied':
description = projectTitle
? `${actor} overrode decision for ${projectTitle}`
: `${actor} applied a decision override`
category = 'project'
break
case 'finalization.project_outcome':
description = projectTitle
? `${projectTitle} outcome: ${(details.outcome as string) ?? 'determined'}`
: 'Project outcome determined'
category = 'project'
break
case 'deliberation.created':
description = 'Deliberation session created'
category = 'deliberation'
break
case 'deliberation.finalized':
description = 'Deliberation session finalized'
category = 'deliberation'
break
case 'deliberation.admin_override':
description = `${actor} applied deliberation override`
category = 'deliberation'
break
case 'live.session_started':
description = 'Live voting session started'
category = 'round'
break
case 'submission_window.opened':
description = 'Submission window opened'
category = 'round'
break
case 'submission_window.closed':
description = 'Submission window closed'
category = 'round'
break
case 'mentor_workspace.activated':
description = projectTitle
? `Mentoring workspace activated for ${projectTitle}`
: 'Mentoring workspace activated'
category = 'project'
break
default:
description = `${actor}: ${entry.eventType.replace(/[_.]/g, ' ')}`
break
}
return { id: entry.id, description, category, createdAt: entry.createdAt }
})
// Humanize AuditLog entries
const alItems: FeedItem[] = alEntries.map((entry) => {
const actor = entry.userId ? actorMap.get(entry.userId) ?? 'Someone' : 'System'
const details = (entry.detailsJson ?? {}) as Record<string, unknown>
const projectTitle = (details.projectTitle ?? details.entityLabel ?? '') as string
let description: string
let category: FeedItem['category'] = 'system'
switch (entry.action) {
case 'EVALUATION_SUBMITTED':
description = projectTitle
? `${actor} submitted evaluation for ${projectTitle}`
: `${actor} submitted an evaluation`
category = 'evaluation'
break
case 'EVALUATION_SAVE_DRAFT':
description = projectTitle
? `${actor} saved draft evaluation for ${projectTitle}`
: `${actor} saved a draft evaluation`
category = 'evaluation'
break
case 'PROJECT_CREATE':
description = projectTitle
? `New project submitted: ${projectTitle}`
: 'New project submitted'
category = 'project'
break
case 'PROJECT_UPDATE':
description = projectTitle
? `${projectTitle} was updated`
: 'A project was updated'
category = 'project'
break
case 'FILE_VIEWED':
case 'FILE_OPENED':
description = `${actor} viewed a document${projectTitle ? ` for ${projectTitle}` : ''}`
category = 'file'
break
case 'FILE_DOWNLOADED':
description = `${actor} downloaded a document${projectTitle ? ` for ${projectTitle}` : ''}`
category = 'file'
break
default:
description = `${actor}: ${entry.action.replace(/_/g, ' ').toLowerCase()}`
break
}
return {
id: `al_${entry.id}`,
description,
category,
createdAt: entry.timestamp,
}
})
// Merge and sort by date, take limit
const merged = [...dalItems, ...alItems]
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, limit)
return merged
}),
// =========================================================================
@@ -1752,4 +1945,338 @@ export const analyticsRouter = router({
totalProjects: projectAssignCounts.size,
}
}),
// =========================================================================
// Observer Dashboard V2 Queries
// =========================================================================
getPreviousRoundComparison: observerProcedure
.input(z.object({ currentRoundId: z.string() }))
.query(async ({ ctx, input }) => {
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.currentRoundId },
select: { id: true, name: true, roundType: true, sortOrder: true, competitionId: true },
})
// Find the previous round by sortOrder
const previousRound = await ctx.prisma.round.findFirst({
where: {
competitionId: currentRound.competitionId,
sortOrder: { lt: currentRound.sortOrder },
},
orderBy: { sortOrder: 'desc' },
select: { id: true, name: true, roundType: true, sortOrder: true },
})
if (!previousRound) {
return { hasPrevious: false as const }
}
// Get project counts and category breakdowns for both rounds
const [prevStates, currStates, prevProjects, currProjects] = await Promise.all([
ctx.prisma.projectRoundState.findMany({
where: { roundId: previousRound.id },
select: { projectId: true, state: true },
}),
ctx.prisma.projectRoundState.findMany({
where: { roundId: currentRound.id },
select: { projectId: true, state: true },
}),
ctx.prisma.project.findMany({
where: { projectRoundStates: { some: { roundId: previousRound.id } } },
select: { id: true, competitionCategory: true, country: true },
}),
ctx.prisma.project.findMany({
where: { projectRoundStates: { some: { roundId: currentRound.id } } },
select: { id: true, competitionCategory: true, country: true },
}),
])
const prevPassedCount = prevStates.filter(s => s.state === 'PASSED').length
const prevRejectedCount = prevStates.filter(s => s.state === 'REJECTED').length
// Category breakdown
const prevByCategory = new Map<string, number>()
prevProjects.forEach(p => {
const cat = p.competitionCategory ?? 'Uncategorized'
prevByCategory.set(cat, (prevByCategory.get(cat) ?? 0) + 1)
})
const currByCategory = new Map<string, number>()
currProjects.forEach(p => {
const cat = p.competitionCategory ?? 'Uncategorized'
currByCategory.set(cat, (currByCategory.get(cat) ?? 0) + 1)
})
const allCategories = new Set([...prevByCategory.keys(), ...currByCategory.keys()])
const categoryBreakdown = [...allCategories].map(cat => ({
category: cat,
previous: prevByCategory.get(cat) ?? 0,
current: currByCategory.get(cat) ?? 0,
eliminated: (prevByCategory.get(cat) ?? 0) - (currByCategory.get(cat) ?? 0),
}))
// Country attrition
const prevByCountry = new Map<string, number>()
prevProjects.forEach(p => {
const c = p.country ?? 'Unknown'
prevByCountry.set(c, (prevByCountry.get(c) ?? 0) + 1)
})
const currByCountry = new Map<string, number>()
currProjects.forEach(p => {
const c = p.country ?? 'Unknown'
currByCountry.set(c, (currByCountry.get(c) ?? 0) + 1)
})
const allCountries = new Set([...prevByCountry.keys(), ...currByCountry.keys()])
const countryAttrition = [...allCountries]
.map(country => ({
country,
previous: prevByCountry.get(country) ?? 0,
current: currByCountry.get(country) ?? 0,
lost: (prevByCountry.get(country) ?? 0) - (currByCountry.get(country) ?? 0),
}))
.filter(c => c.lost > 0)
.sort((a, b) => b.lost - a.lost)
.slice(0, 10)
// Average scores (if evaluation rounds)
let prevAvgScore: number | null = null
let currAvgScore: number | null = null
const [prevEvals, currEvals] = await Promise.all([
ctx.prisma.evaluation.findMany({
where: { assignment: { roundId: previousRound.id }, status: 'SUBMITTED', globalScore: { not: null } },
select: { globalScore: true },
}),
ctx.prisma.evaluation.findMany({
where: { assignment: { roundId: currentRound.id }, status: 'SUBMITTED', globalScore: { not: null } },
select: { globalScore: true },
}),
])
if (prevEvals.length > 0) {
prevAvgScore = prevEvals.reduce((sum, e) => sum + (e.globalScore ?? 0), 0) / prevEvals.length
}
if (currEvals.length > 0) {
currAvgScore = currEvals.reduce((sum, e) => sum + (e.globalScore ?? 0), 0) / currEvals.length
}
return {
hasPrevious: true as const,
previousRound: {
id: previousRound.id,
name: previousRound.name,
type: previousRound.roundType,
projectCount: prevProjects.length,
avgScore: prevAvgScore,
passedCount: prevPassedCount,
rejectedCount: prevRejectedCount,
},
currentRound: {
id: currentRound.id,
name: currentRound.name,
type: currentRound.roundType,
projectCount: currProjects.length,
avgScore: currAvgScore,
},
eliminated: prevProjects.length - currProjects.length,
categoryBreakdown,
countryAttrition,
}
}),
getRoundAdvancementConfig: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { roundType: true, configJson: true },
})
const config = round.configJson as Record<string, unknown> | null
if (!config) return null
return {
advanceMode: (config.advanceMode as string) ?? 'count',
startupAdvanceCount: config.startupAdvanceCount as number | undefined,
conceptAdvanceCount: config.conceptAdvanceCount as number | undefined,
advanceScoreThreshold: config.advanceScoreThreshold as number | undefined,
advancementMode: config.advancementMode as string | undefined,
advancementConfig: config.advancementConfig as Record<string, unknown> | undefined,
}
}),
getRecentFiles: observerProcedure
.input(z.object({ roundId: z.string(), limit: z.number().min(1).max(50).default(10) }))
.query(async ({ ctx, input }) => {
const files = await ctx.prisma.projectFile.findMany({
where: { roundId: input.roundId },
orderBy: { createdAt: 'desc' },
take: input.limit,
select: {
id: true,
fileName: true,
fileType: true,
createdAt: true,
project: {
select: { id: true, title: true, teamName: true },
},
},
})
return files
}),
getMentoringDashboard: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const assignments = await ctx.prisma.mentorAssignment.findMany({
where: {
project: { projectRoundStates: { some: { roundId: input.roundId } } },
},
select: {
id: true,
mentorId: true,
projectId: true,
completionStatus: true,
mentor: { select: { id: true, name: true } },
project: { select: { id: true, title: true, teamName: true } },
messages: { select: { id: true } },
},
})
// Group by mentor
const mentorMap = new Map<string, {
mentorName: string
mentorId: string
projects: { id: string; title: string; teamName: string | null; messageCount: number }[]
}>()
let totalMessages = 0
const activeMentorIds = new Set<string>()
for (const a of assignments) {
const mentorId = a.mentorId
if (!mentorMap.has(mentorId)) {
mentorMap.set(mentorId, {
mentorName: a.mentor.name ?? 'Unknown',
mentorId,
projects: [],
})
}
const messageCount = a.messages.length
totalMessages += messageCount
if (messageCount > 0) activeMentorIds.add(mentorId)
mentorMap.get(mentorId)!.projects.push({
id: a.project.id,
title: a.project.title,
teamName: a.project.teamName,
messageCount,
})
}
return {
assignments: [...mentorMap.values()],
totalMessages,
activeMentors: activeMentorIds.size,
totalMentors: mentorMap.size,
}
}),
getLiveFinalDashboard: observerProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Get round config for observer visibility setting
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { configJson: true, roundType: true },
})
let observerScoreVisibility: 'realtime' | 'after_completion' | 'hidden' = 'after_completion'
try {
if (round.roundType === 'LIVE_FINAL') {
const config = validateRoundConfig('LIVE_FINAL', round.configJson) as LiveFinalConfig
observerScoreVisibility = config.observerScoreVisibility ?? 'after_completion'
}
} catch { /* use default */ }
const session = await ctx.prisma.liveVotingSession.findUnique({
where: { roundId: input.roundId },
select: {
id: true,
status: true,
currentProjectIndex: true,
projectOrderJson: true,
votingMode: true,
votes: {
select: {
userId: true,
projectId: true,
score: true,
},
},
},
})
if (!session) {
return {
sessionStatus: 'NOT_STARTED' as const,
observerScoreVisibility,
voteCount: 0,
jurors: [] as { id: string; name: string; hasVoted: boolean }[],
standings: null,
}
}
// Get jurors assigned to this round
const jurorUsers = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
select: { userId: true, user: { select: { id: true, name: true } } },
distinct: ['userId'],
})
const voterIds = new Set(session.votes.map(v => v.userId))
const jurors = jurorUsers.map(j => ({
id: j.user.id,
name: j.user.name ?? 'Unknown',
hasVoted: voterIds.has(j.user.id),
}))
// Calculate standings if visibility allows
const showScores =
observerScoreVisibility === 'realtime' ||
(observerScoreVisibility === 'after_completion' && session.status === 'COMPLETED')
let standings: { projectId: string; projectTitle: string; avgScore: number; voteCount: number }[] | null = null
if (showScores && session.votes.length > 0) {
const projectScores = new Map<string, number[]>()
for (const v of session.votes) {
if (v.score != null) {
if (!projectScores.has(v.projectId)) projectScores.set(v.projectId, [])
projectScores.get(v.projectId)!.push(v.score)
}
}
const projectIds = [...projectScores.keys()]
const projects = await ctx.prisma.project.findMany({
where: { id: { in: projectIds } },
select: { id: true, title: true },
})
const projMap = new Map(projects.map(p => [p.id, p.title]))
standings = [...projectScores.entries()]
.map(([projectId, scores]) => ({
projectId,
projectTitle: projMap.get(projectId) ?? 'Unknown',
avgScore: scores.reduce((a, b) => a + b, 0) / scores.length,
voteCount: scores.length,
}))
.sort((a, b) => b.avgScore - a.avgScore)
}
return {
sessionStatus: session.status,
observerScoreVisibility,
voteCount: session.votes.length,
jurors,
standings,
}
}),
})

View File

@@ -1,6 +1,6 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure } from '../trpc'
import { router, adminProcedure, protectedProcedure } from '../trpc'
import { generateLogoKey, type StorageProviderType } from '@/lib/storage'
import {
getImageUploadUrl,
@@ -110,9 +110,9 @@ export const logoRouter = router({
}),
/**
* Get a project's logo URL
* Get a project's logo URL (any authenticated user — logos are public display data)
*/
getUrl: adminProcedure
getUrl: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
return getImageUrl(ctx.prisma, logoConfig, input.projectId)

View File

@@ -70,6 +70,7 @@ export const programRouter = router({
competitionId: round.competitionId,
status: round.status,
roundType: round.roundType,
sortOrder: round.sortOrder,
votingEndAt: round.windowCloseAt,
_count: {
projects: round._count?.projectRoundStates || 0,

View File

@@ -2,6 +2,7 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url'
import { logAudit } from '../utils/audit'
import { processEligibilityJob } from '../services/award-eligibility-job'
import { getAwardSelectionNotificationTemplate } from '@/lib/email'
@@ -481,7 +482,7 @@ export const specialAwardRouter = router({
listJurors: protectedProcedure
.input(z.object({ awardId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.awardJuror.findMany({
const jurors = await ctx.prisma.awardJuror.findMany({
where: { awardId: input.awardId },
include: {
user: {
@@ -496,6 +497,15 @@ export const specialAwardRouter = router({
},
},
})
return Promise.all(
jurors.map(async (j) => ({
...j,
user: {
...j.user,
avatarUrl: await getUserAvatarUrl(j.user.profileImageKey, j.user.profileImageProvider),
},
}))
)
}),
/**