Add observer project detail page with files, evaluations & reviews
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m59s

New page at /observer/projects/[projectId] showing project info,
documents grouped by round requirements, and jury evaluations with
click-through to full review details. Dashboard table rows now link
to project detail. Also cleans up redundant programName prefixes
and fixes chart edge cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-20 18:39:53 +01:00
parent f1062f4805
commit 03c59c188e
9 changed files with 1236 additions and 57 deletions

View File

@@ -1,6 +1,7 @@
import { z } from 'zod'
import { router, observerProcedure } from '../trpc'
import { normalizeCountryToCode } from '@/lib/countries'
import { getUserAvatarUrl } from '../utils/avatar-url'
const editionOrRoundInput = z.object({
roundId: z.string().optional(),
@@ -1237,4 +1238,136 @@ export const analyticsRouter = router({
return { roundType, stats: {} }
}
}),
/**
* Observer-accessible project detail: project info + assignments with evaluations + competition rounds + files.
* Read-only combined endpoint to avoid multiple round-trips.
*/
getProjectDetail: observerProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const [projectRaw, projectTags, assignments, submittedEvaluations] = await Promise.all([
ctx.prisma.project.findUniqueOrThrow({
where: { id: input.id },
include: {
files: {
select: {
id: true, fileName: true, fileType: true, mimeType: true, size: true,
bucket: true, objectKey: true, pageCount: true, textPreview: true,
detectedLang: true, langConfidence: true, analyzedAt: true,
requirementId: true,
requirement: { select: { id: true, name: true, description: true, isRequired: true } },
},
},
teamMembers: {
include: {
user: {
select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true },
},
},
orderBy: { joinedAt: 'asc' },
},
},
}),
ctx.prisma.projectTag.findMany({
where: { projectId: input.id },
include: { tag: { select: { id: true, name: true, category: true, color: true } } },
orderBy: { confidence: 'desc' },
}).catch(() => [] as { id: string; projectId: string; tagId: string; confidence: number; tag: { id: string; name: string; category: string | null; color: string | null } }[]),
ctx.prisma.assignment.findMany({
where: { projectId: input.id },
include: {
user: { select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true } },
round: { select: { id: true, name: true } },
evaluation: {
select: {
id: true, status: true, submittedAt: true, globalScore: true,
binaryDecision: true, criterionScoresJson: true, feedbackText: true,
},
},
},
orderBy: { createdAt: 'desc' },
}),
ctx.prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: { projectId: input.id },
},
}),
])
// Compute evaluation stats
let stats = null
if (submittedEvaluations.length > 0) {
const globalScores = submittedEvaluations
.map((e) => e.globalScore)
.filter((s): s is number => s !== null)
const yesVotes = submittedEvaluations.filter((e) => e.binaryDecision === true).length
stats = {
totalEvaluations: submittedEvaluations.length,
averageGlobalScore: globalScores.length > 0
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
: null,
minScore: globalScores.length > 0 ? Math.min(...globalScores) : null,
maxScore: globalScores.length > 0 ? Math.max(...globalScores) : null,
yesVotes,
noVotes: submittedEvaluations.length - yesVotes,
yesPercentage: (yesVotes / submittedEvaluations.length) * 100,
}
}
// Get competition rounds for file grouping
let competitionRounds: { id: string; name: string }[] = []
const competition = await ctx.prisma.competition.findFirst({
where: { programId: projectRaw.programId },
include: { rounds: { select: { id: true, name: true }, orderBy: { sortOrder: 'asc' } } },
})
if (competition) {
competitionRounds = competition.rounds
}
// Get file requirements for all rounds
let allRequirements: { id: string; roundId: string; name: string; description: string | null; isRequired: boolean; acceptedMimeTypes: string[]; maxSizeMB: number | null }[] = []
if (competitionRounds.length > 0) {
allRequirements = await ctx.prisma.fileRequirement.findMany({
where: { roundId: { in: competitionRounds.map((r) => r.id) } },
select: { id: true, roundId: true, name: true, description: true, isRequired: true, acceptedMimeTypes: true, maxSizeMB: true },
orderBy: { sortOrder: 'asc' },
})
}
// Attach avatar URLs
const [teamMembersWithAvatars, assignmentsWithAvatars] = await Promise.all([
Promise.all(
projectRaw.teamMembers.map(async (member) => ({
...member,
user: {
...member.user,
avatarUrl: await getUserAvatarUrl(member.user.profileImageKey, member.user.profileImageProvider),
},
}))
),
Promise.all(
assignments.map(async (a) => ({
...a,
user: {
...a.user,
avatarUrl: await getUserAvatarUrl(a.user.profileImageKey, a.user.profileImageProvider),
},
}))
),
])
return {
project: {
...projectRaw,
projectTags,
teamMembers: teamMembersWithAvatars,
},
assignments: assignmentsWithAvatars,
stats,
competitionRounds,
allRequirements,
}
}),
})