Performance optimization, applicant portal, and missing DB migration

Performance:
- Convert admin dashboard from SSR to client-side tRPC (fixes 503/ChunkLoadError)
- New dashboard.getStats tRPC endpoint batches 16 queries into single response
- Parallelize jury dashboard queries (assignments + gracePeriods via Promise.all)
- Add project.getFullDetail combined endpoint (project + assignments + stats)
- Configure Prisma connection pool (connection_limit=20, pool_timeout=10)
- Add optimizePackageImports for lucide-react tree-shaking
- Increase React Query staleTime from 1min to 5min

Applicant portal:
- Add applicant layout, nav, dashboard, documents, team, and mentor pages
- Add applicant router with document and team management endpoints
- Add chunk error recovery utility
- Update role nav and auth redirect for applicant role

Database:
- Add migration for missing schema elements (SpecialAward job tracking
  columns, WizardTemplate table, missing indexes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 11:04:26 +01:00
parent 09091d7c08
commit 98f4a957cc
32 changed files with 3002 additions and 1121 deletions

View File

@@ -1164,4 +1164,121 @@ export const projectRouter = router({
return { projects, total, page, perPage, totalPages: Math.ceil(total / perPage) }
}),
/**
* Get full project detail with assignments and evaluation stats in one call.
* Reduces client-side waterfall by combining project.get + assignment.listByProject + evaluation.getProjectStats.
*/
getFullDetail: adminProcedure
.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: true,
round: true,
teamMembers: {
include: {
user: {
select: { id: true, name: true, email: true, profileImageKey: true, profileImageProvider: true },
},
},
orderBy: { joinedAt: 'asc' },
},
mentorAssignment: {
include: {
mentor: {
select: { id: true, name: true, email: true, expertiseTags: true, profileImageKey: true, profileImageProvider: true },
},
},
},
},
}),
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, expertiseTags: true, profileImageKey: true, profileImageProvider: true } },
evaluation: { select: { status: true, submittedAt: true, globalScore: true, binaryDecision: 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,
}
}
// Attach avatar URLs in parallel
const [teamMembersWithAvatars, assignmentsWithAvatars, mentorWithAvatar] = 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),
},
}))
),
projectRaw.mentorAssignment
? (async () => ({
...projectRaw.mentorAssignment!,
mentor: {
...projectRaw.mentorAssignment!.mentor,
avatarUrl: await getUserAvatarUrl(
projectRaw.mentorAssignment!.mentor.profileImageKey,
projectRaw.mentorAssignment!.mentor.profileImageProvider
),
},
}))()
: Promise.resolve(null),
])
return {
project: {
...projectRaw,
projectTags,
teamMembers: teamMembersWithAvatars,
mentorAssignment: mentorWithAvatar,
},
assignments: assignmentsWithAvatars,
stats,
}
}),
})