Add observer project detail page with files, evaluations & reviews
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m59s
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:
@@ -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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user