fix: security hardening — block self-registration, SSE auth, audit logging fixes
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
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:
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}))
|
||||
)
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user