feat: observer team tab, admin-controlled applicant feedback visibility
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m13s

- Add Team tab to observer project detail (configurable via admin settings)
- Move applicant jury feedback visibility from per-round config to admin settings
- Add per-round-type controls: evaluation, live final, deliberation
- Support anonymous LiveVote and DeliberationVote display for applicants
- Add fine-grained toggles: scores, criteria, written feedback, hide from rejected
- Backwards compatible: falls back to old per-round config if admin settings not set
- New admin settings section under Analytics tab with all visibility controls
- Seed new SystemSettings keys for observer/applicant visibility

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 16:50:20 +01:00
parent 6b6f5e33f5
commit 94814bd505
6 changed files with 477 additions and 168 deletions

View File

@@ -916,6 +916,27 @@ async function main() {
} }
console.log(`${visibilityLinks.length} submission visibility links created`) console.log(`${visibilityLinks.length} submission visibility links created`)
// --- Applicant/Observer visibility settings ---
const visibilitySettings = [
{ key: 'observer_show_team_tab', value: 'true', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show Team tab on observer project detail page' },
{ key: 'applicant_show_evaluation_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show anonymous jury evaluation feedback to applicants' },
{ key: 'applicant_show_evaluation_scores', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show global scores in evaluation feedback' },
{ key: 'applicant_show_evaluation_criteria', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show per-criterion scores in evaluation feedback' },
{ key: 'applicant_show_evaluation_text', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show written feedback text in evaluation feedback' },
{ key: 'applicant_show_livefinal_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show live final scores to applicants' },
{ key: 'applicant_show_livefinal_scores', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show individual jury scores from live finals' },
{ key: 'applicant_show_deliberation_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show deliberation results to applicants' },
{ key: 'applicant_hide_feedback_from_rejected', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Hide feedback from rejected projects' },
]
for (const s of visibilitySettings) {
await prisma.systemSettings.upsert({
where: { key: s.key },
update: {},
create: s,
})
}
console.log(` ✓ Created ${visibilitySettings.length} applicant/observer visibility settings`)
// --- Feature flag: enable competition model --- // --- Feature flag: enable competition model ---
await prisma.systemSettings.upsert({ await prisma.systemSettings.upsert({
where: { key: 'feature.useCompetitionModel' }, where: { key: 'feature.useCompetitionModel' },

View File

@@ -9,7 +9,7 @@ import {
} from '@/components/ui/card' } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Star, MessageSquare } from 'lucide-react' import { Star, MessageSquare, Trophy, Vote } from 'lucide-react'
export default function ApplicantEvaluationsPage() { export default function ApplicantEvaluationsPage() {
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery() const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
@@ -58,84 +58,97 @@ export default function ApplicantEvaluationsPage() {
</Card> </Card>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
{rounds.map((round) => ( {rounds.map((round) => {
<Card key={round.roundId}> const roundIcon = round.roundType === 'LIVE_FINAL'
<CardHeader> ? <Trophy className="h-5 w-5 text-amber-500" />
<div className="flex items-center justify-between"> : round.roundType === 'DELIBERATION'
<CardTitle>{round.roundName}</CardTitle> ? <Vote className="h-5 w-5 text-violet-500" />
<Badge variant="secondary"> : <Star className="h-5 w-5 text-yellow-500" />
{round.evaluationCount} evaluation{round.evaluationCount !== 1 ? 's' : ''}
</Badge> return (
</div> <Card key={round.roundId}>
</CardHeader> <CardHeader>
<CardContent className="space-y-4"> <div className="flex items-center justify-between">
{round.evaluations.map((ev, idx) => ( <CardTitle className="flex items-center gap-2">
<div {roundIcon}
key={ev.id} {round.roundName}
className="rounded-lg border p-4 space-y-3" </CardTitle>
> <Badge variant="secondary">
<div className="flex items-center justify-between"> {round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'evaluation'}{round.evaluationCount !== 1 ? 's' : ''}
<span className="font-medium text-sm"> </Badge>
Evaluator #{idx + 1} </div>
</span> </CardHeader>
{ev.submittedAt && ( <CardContent className="space-y-4">
<span className="text-xs text-muted-foreground"> {round.evaluations.map((ev, idx) => (
{new Date(ev.submittedAt).toLocaleDateString()} <div
key={ev.id}
className="rounded-lg border p-4 space-y-3"
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">
{round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`}
</span> </span>
{ev.submittedAt && (
<span className="text-xs text-muted-foreground">
{new Date(ev.submittedAt).toLocaleDateString()}
</span>
)}
</div>
{ev.globalScore !== null && round.roundType !== 'DELIBERATION' && (
<div className="flex items-center gap-2">
<Star className="h-4 w-4 text-yellow-500" />
<span className="text-lg font-semibold">{ev.globalScore}</span>
<span className="text-sm text-muted-foreground">
/ {round.roundType === 'LIVE_FINAL' ? '10' : '100'}
</span>
</div>
)}
{ev.criterionScores && ev.criteria && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Criterion Scores</p>
<div className="grid gap-2">
{(() => {
const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }>
const scores = ev.criterionScores as Record<string, number>
return criteria
.filter((c) => c.id || c.label || c.name)
.map((c, ci) => {
const key = c.id || String(ci)
const score = scores[key]
return (
<div key={ci} className="flex items-center justify-between text-sm">
<span>{c.label || c.name || `Criterion ${ci + 1}`}</span>
<span className="font-medium">
{score !== undefined ? score : '—'}
{c.maxScore ? ` / ${c.maxScore}` : ''}
</span>
</div>
)
})
})()}
</div>
</div>
)}
{ev.feedbackText && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" />
{round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'}
</div>
<blockquote className="border-l-2 border-muted pl-4 text-sm italic text-muted-foreground">
{ev.feedbackText}
</blockquote>
</div>
)} )}
</div> </div>
))}
{ev.globalScore !== null && ( </CardContent>
<div className="flex items-center gap-2"> </Card>
<Star className="h-4 w-4 text-yellow-500" /> )
<span className="text-lg font-semibold">{ev.globalScore}</span> })}
<span className="text-sm text-muted-foreground">/ 100</span>
</div>
)}
{ev.criterionScores && ev.criteria && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Criterion Scores</p>
<div className="grid gap-2">
{(() => {
const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }>
const scores = ev.criterionScores as Record<string, number>
return criteria
.filter((c) => c.id || c.label || c.name)
.map((c, ci) => {
const key = c.id || String(ci)
const score = scores[key]
return (
<div key={ci} className="flex items-center justify-between text-sm">
<span>{c.label || c.name || `Criterion ${ci + 1}`}</span>
<span className="font-medium">
{score !== undefined ? score : '—'}
{c.maxScore ? ` / ${c.maxScore}` : ''}
</span>
</div>
)
})
})()}
</div>
</div>
)}
{ev.feedbackText && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" />
Written Feedback
</div>
<blockquote className="border-l-2 border-muted pl-4 text-sm italic text-muted-foreground">
{ev.feedbackText}
</blockquote>
</div>
)}
</div>
))}
</CardContent>
</Card>
))}
<p className="text-xs text-muted-foreground text-center"> <p className="text-xs text-muted-foreground text-center">
Evaluator identities are kept confidential. Evaluator identities are kept confidential.

View File

@@ -47,6 +47,7 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
{ id: projectId }, { id: projectId },
{ refetchInterval: 30_000 }, { refetchInterval: 30_000 },
) )
const { data: flags } = trpc.settings.getFeatureFlags.useQuery()
const roundId = data?.assignments?.[0]?.roundId as string | undefined const roundId = data?.assignments?.[0]?.roundId as string | undefined
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery( const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
@@ -242,6 +243,14 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
)} )}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="files">Files</TabsTrigger> <TabsTrigger value="files">Files</TabsTrigger>
{flags?.observerShowTeamTab && project.teamMembers.length > 0 && (
<TabsTrigger value="team">
Team
<Badge variant="secondary" className="ml-1.5 h-4 px-1 text-xs">
{project.teamMembers.length}
</Badge>
</TabsTrigger>
)}
</TabsList> </TabsList>
{/* ── Overview Tab ── */} {/* ── Overview Tab ── */}
@@ -854,6 +863,48 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
)} )}
</TabsContent> </TabsContent>
{/* ── Team Tab ── */}
{flags?.observerShowTeamTab && project.teamMembers.length > 0 && (
<TabsContent value="team" className="mt-6">
<AnimatedCard index={0}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-indigo-500/10 p-1.5">
<Users className="h-4 w-4 text-indigo-500" />
</div>
Team Members
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{project.teamMembers.map((member) => (
<div key={member.userId} className="flex items-center gap-3 rounded-lg border p-3">
<UserAvatar
user={member.user}
avatarUrl={(member.user as { avatarUrl?: string | null }).avatarUrl}
size="md"
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold truncate">
{member.user.name || 'Unnamed'}
</p>
<p className="text-xs text-muted-foreground truncate">
{member.user.email}
</p>
</div>
<Badge variant="outline" className="shrink-0 text-xs">
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
)}
{/* ── Files Tab ── */} {/* ── Files Tab ── */}
<TabsContent value="files" className="mt-6"> <TabsContent value="files" className="mt-6">
<Card> <Card>

View File

@@ -145,6 +145,15 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
'analytics_observer_comparison_tab', 'analytics_observer_comparison_tab',
'analytics_pdf_enabled', 'analytics_pdf_enabled',
'analytics_pdf_sections', 'analytics_pdf_sections',
'observer_show_team_tab',
'applicant_show_evaluation_feedback',
'applicant_show_evaluation_scores',
'applicant_show_evaluation_criteria',
'applicant_show_evaluation_text',
'applicant_show_livefinal_feedback',
'applicant_show_livefinal_scores',
'applicant_show_deliberation_feedback',
'applicant_hide_feedback_from_rejected',
]) ])
const auditSecuritySettings = getSettingsByKeys([ const auditSecuritySettings = getSettingsByKeys([
@@ -785,6 +794,66 @@ function AnalyticsSettingsSection({ settings }: { settings: Record<string, strin
settingKey="analytics_observer_comparison_tab" settingKey="analytics_observer_comparison_tab"
value={settings.analytics_observer_comparison_tab || 'true'} value={settings.analytics_observer_comparison_tab || 'true'}
/> />
<SettingToggle
label="Team Tab"
description="Show team members on observer project detail page"
settingKey="observer_show_team_tab"
value={settings.observer_show_team_tab || 'true'}
/>
<div className="border-t pt-4 space-y-3">
<Label className="text-sm font-medium">Applicant Feedback Visibility</Label>
<p className="text-xs text-muted-foreground">
Control what anonymous jury feedback applicants can see on their dashboard
</p>
<SettingToggle
label="Show Evaluation Feedback"
description="Enable anonymous jury evaluation reviews for applicants"
settingKey="applicant_show_evaluation_feedback"
value={settings.applicant_show_evaluation_feedback || 'false'}
/>
<SettingToggle
label="Show Global Scores"
description="Show overall score in evaluation feedback"
settingKey="applicant_show_evaluation_scores"
value={settings.applicant_show_evaluation_scores || 'false'}
/>
<SettingToggle
label="Show Criterion Scores"
description="Show per-criterion breakdown in evaluation feedback"
settingKey="applicant_show_evaluation_criteria"
value={settings.applicant_show_evaluation_criteria || 'false'}
/>
<SettingToggle
label="Show Written Feedback"
description="Show jury's written comments to applicants"
settingKey="applicant_show_evaluation_text"
value={settings.applicant_show_evaluation_text || 'false'}
/>
<SettingToggle
label="Show Live Final Feedback"
description="Show live final jury scores to applicants"
settingKey="applicant_show_livefinal_feedback"
value={settings.applicant_show_livefinal_feedback || 'false'}
/>
<SettingToggle
label="Show Live Final Individual Scores"
description="Show individual jury member scores from live finals"
settingKey="applicant_show_livefinal_scores"
value={settings.applicant_show_livefinal_scores || 'false'}
/>
<SettingToggle
label="Show Deliberation Results"
description="Show deliberation voting results to applicants"
settingKey="applicant_show_deliberation_feedback"
value={settings.applicant_show_deliberation_feedback || 'false'}
/>
<SettingToggle
label="Hide from Rejected Projects"
description="Hide all feedback from projects that have been rejected"
settingKey="applicant_hide_feedback_from_rejected"
value={settings.applicant_hide_feedback_from_rejected || 'false'}
/>
</div>
<div className="border-t pt-4 space-y-3"> <div className="border-t pt-4 space-y-3">
<Label className="text-sm font-medium">PDF Reports</Label> <Label className="text-sm font-medium">PDF Reports</Label>
<SettingToggle <SettingToggle

View File

@@ -10,7 +10,7 @@ import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification' import { createNotification } from '../services/in-app-notification'
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine' import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs' import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
import type { Prisma } from '@prisma/client' import type { Prisma, RoundType } from '@prisma/client'
// All uploads use the single configured bucket (MINIO_BUCKET / mopc-files). // All uploads use the single configured bucket (MINIO_BUCKET / mopc-files).
// Files are organized by path prefix: {ProjectName}/{RoundName}/... for submissions, // Files are organized by path prefix: {ProjectName}/{RoundName}/... for submissions,
@@ -1785,13 +1785,30 @@ export const applicantRouter = router({
/** /**
* Get anonymous jury evaluations visible to the applicant. * Get anonymous jury evaluations visible to the applicant.
* Respects per-round applicantVisibility config. NEVER leaks juror identity. * Reads visibility config from admin SystemSettings (not per-round configJson).
* Supports EVALUATION, LIVE_FINAL, and DELIBERATION round types.
* NEVER leaks juror identity.
*/ */
getMyEvaluations: protectedProcedure.query(async ({ ctx }) => { getMyEvaluations: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role !== 'APPLICANT') { if (ctx.user.role !== 'APPLICANT') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' }) throw new TRPCError({ code: 'FORBIDDEN', message: 'Only applicants can access this' })
} }
// Load admin visibility settings
const visKeys = [
'applicant_show_evaluation_feedback', 'applicant_show_evaluation_scores',
'applicant_show_evaluation_criteria', 'applicant_show_evaluation_text',
'applicant_show_livefinal_feedback', 'applicant_show_livefinal_scores',
'applicant_show_deliberation_feedback',
'applicant_hide_feedback_from_rejected',
]
const settingsRows = await ctx.prisma.systemSettings.findMany({
where: { key: { in: visKeys } },
})
const sMap = new Map(settingsRows.map((s) => [s.key, s.value]))
const adminSettingsExist = settingsRows.length > 0
const flag = (k: string) => sMap.get(k) === 'true'
const project = await ctx.prisma.project.findFirst({ const project = await ctx.prisma.project.findFirst({
where: { where: {
OR: [ OR: [
@@ -1804,34 +1821,18 @@ export const applicantRouter = router({
if (!project?.programId) return [] if (!project?.programId) return []
// Get closed/archived EVALUATION rounds — only ones this project participated in
const projectRoundIds = new Set( const projectRoundIds = new Set(
(await ctx.prisma.projectRoundState.findMany({ (await ctx.prisma.projectRoundState.findMany({
where: { projectId: project.id }, where: { projectId: project.id },
select: { roundId: true }, select: { roundId: true },
})).map((prs) => prs.roundId) })).map((prs) => prs.roundId)
) )
if (projectRoundIds.size === 0) return [] if (projectRoundIds.size === 0) return []
const evalRounds = await ctx.prisma.round.findMany({ type ResultItem = {
where: {
competition: { programId: project.programId },
roundType: 'EVALUATION',
status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
id: { in: [...projectRoundIds] },
},
select: {
id: true,
name: true,
configJson: true,
},
orderBy: { sortOrder: 'asc' },
})
const results: Array<{
roundId: string roundId: string
roundName: string roundName: string
roundType: string
evaluationCount: number evaluationCount: number
evaluations: Array<{ evaluations: Array<{
id: string id: string
@@ -1841,56 +1842,203 @@ export const applicantRouter = router({
feedbackText: string | null feedbackText: string | null
criteria: Prisma.JsonValue | null criteria: Prisma.JsonValue | null
}> }>
}> = [] }
const results: ResultItem[] = []
const projectIsRejected = await isProjectRejected(ctx.prisma, project.id) // --- Backwards compatibility: if no admin settings exist yet, fall back to
// the old per-round applicantVisibility config for EVALUATION rounds ---
for (let i = 0; i < evalRounds.length; i++) { if (!adminSettingsExist) {
const round = evalRounds[i] const evalRounds = await ctx.prisma.round.findMany({
const parsed = EvaluationConfigSchema.safeParse(round.configJson)
if (!parsed.success || !parsed.data.applicantVisibility.enabled) continue
// Skip this round if hideFromRejected is on and the project has been rejected
if (parsed.data.applicantVisibility.hideFromRejected && projectIsRejected) continue
const vis = parsed.data.applicantVisibility
// Get evaluations via assignments — NEVER select userId or user relation
const evaluations = await ctx.prisma.evaluation.findMany({
where: { where: {
assignment: { competition: { programId: project.programId },
projectId: project.id, roundType: 'EVALUATION',
roundId: round.id, status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
id: { in: [...projectRoundIds] },
},
select: { id: true, name: true, configJson: true },
orderBy: { sortOrder: 'asc' },
})
const projectIsRejected = await isProjectRejected(ctx.prisma, project.id)
for (let i = 0; i < evalRounds.length; i++) {
const round = evalRounds[i]
const parsed = EvaluationConfigSchema.safeParse(round.configJson)
if (!parsed.success || !parsed.data.applicantVisibility.enabled) continue
if (parsed.data.applicantVisibility.hideFromRejected && projectIsRejected) continue
const vis = parsed.data.applicantVisibility
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { projectId: project.id, roundId: round.id },
status: { in: ['SUBMITTED', 'LOCKED'] },
}, },
status: { in: ['SUBMITTED', 'LOCKED'] }, select: {
}, id: true, submittedAt: true,
select: { globalScore: vis.showGlobalScore,
id: true, criterionScoresJson: vis.showCriterionScores,
submittedAt: true, feedbackText: vis.showFeedbackText,
globalScore: vis.showGlobalScore, form: vis.showCriterionScores ? { select: { criteriaJson: true } } : false,
criterionScoresJson: vis.showCriterionScores, },
feedbackText: vis.showFeedbackText, orderBy: { submittedAt: 'asc' },
form: vis.showCriterionScores ? { select: { criteriaJson: true } } : false, })
}, if (evaluations.length === 0) continue
orderBy: { submittedAt: 'asc' },
})
// Mask round names: "Evaluation Round 1", "Evaluation Round 2", etc. results.push({
const maskedName = `Evaluation Round ${i + 1}` roundId: round.id,
roundName: `Evaluation Round ${i + 1}`,
roundType: 'EVALUATION',
evaluationCount: evaluations.length,
evaluations: evaluations.map((ev) => ({
id: ev.id,
submittedAt: ev.submittedAt,
globalScore: vis.showGlobalScore ? (ev as { globalScore?: number | null }).globalScore ?? null : null,
criterionScores: vis.showCriterionScores ? (ev as { criterionScoresJson?: Prisma.JsonValue }).criterionScoresJson ?? null : null,
feedbackText: vis.showFeedbackText ? (ev as { feedbackText?: string | null }).feedbackText ?? null : null,
criteria: vis.showCriterionScores ? ((ev as { form?: { criteriaJson: Prisma.JsonValue } | null }).form?.criteriaJson ?? null) : null,
})),
})
}
return results
}
results.push({ // --- New admin settings flow ---
roundId: round.id, const evalEnabled = flag('applicant_show_evaluation_feedback')
roundName: maskedName, const evalShowScores = flag('applicant_show_evaluation_scores')
evaluationCount: evaluations.length, const evalShowCriteria = flag('applicant_show_evaluation_criteria')
evaluations: evaluations.map((ev) => ({ const evalShowText = flag('applicant_show_evaluation_text')
id: ev.id, const liveFinalEnabled = flag('applicant_show_livefinal_feedback')
submittedAt: ev.submittedAt, const liveFinalShowScores = flag('applicant_show_livefinal_scores')
globalScore: vis.showGlobalScore ? (ev as { globalScore?: number | null }).globalScore ?? null : null, const deliberationEnabled = flag('applicant_show_deliberation_feedback')
criterionScores: vis.showCriterionScores ? (ev as { criterionScoresJson?: Prisma.JsonValue }).criterionScoresJson ?? null : null, const hideFromRejected = flag('applicant_hide_feedback_from_rejected')
feedbackText: vis.showFeedbackText ? (ev as { feedbackText?: string | null }).feedbackText ?? null : null,
criteria: vis.showCriterionScores ? ((ev as { form?: { criteriaJson: Prisma.JsonValue } | null }).form?.criteriaJson ?? null) : null, if (!evalEnabled && !liveFinalEnabled && !deliberationEnabled) return []
})),
}) const projectIsRejected = hideFromRejected ? await isProjectRejected(ctx.prisma, project.id) : false
if (projectIsRejected) return []
// Build round type filter
const enabledTypes: RoundType[] = []
if (evalEnabled) enabledTypes.push('EVALUATION')
if (liveFinalEnabled) enabledTypes.push('LIVE_FINAL')
if (deliberationEnabled) enabledTypes.push('DELIBERATION')
const rounds = await ctx.prisma.round.findMany({
where: {
competition: { programId: project.programId },
roundType: { in: enabledTypes },
status: { in: ['ROUND_CLOSED', 'ROUND_ARCHIVED'] },
id: { in: [...projectRoundIds] },
},
select: { id: true, name: true, roundType: true },
orderBy: { sortOrder: 'asc' },
})
let evalCounter = 0
let liveFinalCounter = 0
let deliberationCounter = 0
for (const round of rounds) {
if (round.roundType === 'EVALUATION') {
evalCounter++
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { projectId: project.id, roundId: round.id },
status: { in: ['SUBMITTED', 'LOCKED'] },
},
select: {
id: true,
submittedAt: true,
globalScore: evalShowScores,
criterionScoresJson: evalShowCriteria,
feedbackText: evalShowText,
form: evalShowCriteria ? { select: { criteriaJson: true } } : false,
},
orderBy: { submittedAt: 'asc' },
})
if (evaluations.length === 0) continue
results.push({
roundId: round.id,
roundName: `Evaluation Round ${evalCounter}`,
roundType: 'EVALUATION',
evaluationCount: evaluations.length,
evaluations: evaluations.map((ev) => ({
id: ev.id,
submittedAt: ev.submittedAt,
globalScore: evalShowScores ? (ev as { globalScore?: number | null }).globalScore ?? null : null,
criterionScores: evalShowCriteria ? (ev as { criterionScoresJson?: Prisma.JsonValue }).criterionScoresJson ?? null : null,
feedbackText: evalShowText ? (ev as { feedbackText?: string | null }).feedbackText ?? null : null,
criteria: evalShowCriteria ? ((ev as { form?: { criteriaJson: Prisma.JsonValue } | null }).form?.criteriaJson ?? null) : null,
})),
})
} else if (round.roundType === 'LIVE_FINAL') {
liveFinalCounter++
// LiveVote scores — anonymized
// Only show jury votes, not audience votes
const votes = await ctx.prisma.liveVote.findMany({
where: {
projectId: project.id,
session: { roundId: round.id },
isAudienceVote: false,
},
select: {
id: true,
score: true,
votedAt: true,
},
orderBy: { votedAt: 'asc' },
})
if (votes.length === 0) continue
results.push({
roundId: round.id,
roundName: `Live Final ${liveFinalCounter}`,
roundType: 'LIVE_FINAL',
evaluationCount: votes.length,
evaluations: votes.map((v) => ({
id: v.id,
submittedAt: v.votedAt,
globalScore: liveFinalShowScores ? v.score : null,
criterionScores: null,
feedbackText: null,
criteria: null,
})),
})
} else if (round.roundType === 'DELIBERATION') {
deliberationCounter++
// DeliberationVote — per-juror votes for this project
const votes = await ctx.prisma.deliberationVote.findMany({
where: {
session: { roundId: round.id },
projectId: project.id,
runoffRound: 0,
},
select: {
id: true,
createdAt: true,
rank: true,
isWinnerPick: true,
},
orderBy: { createdAt: 'asc' },
})
if (votes.length === 0) continue
results.push({
roundId: round.id,
roundName: `Deliberation ${deliberationCounter}`,
roundType: 'DELIBERATION',
evaluationCount: votes.length,
evaluations: votes.map((v) => ({
id: v.id,
submittedAt: v.createdAt,
globalScore: v.rank,
criterionScores: null,
feedbackText: v.isWinnerPick ? 'Selected as winner' : (v.rank ? `Ranked #${v.rank}` : null),
criteria: null,
})),
})
}
} }
return results return results

View File

@@ -25,7 +25,7 @@ function categorizeModel(modelId: string): string {
return 'other' return 'other'
} }
function inferSettingCategory(key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' | 'FEATURE_FLAGS' { function inferSettingCategory(key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' | 'FEATURE_FLAGS' | 'ANALYTICS' {
if (key.startsWith('openai') || key.startsWith('ai_') || key.startsWith('anthropic')) return 'AI' if (key.startsWith('openai') || key.startsWith('ai_') || key.startsWith('anthropic')) return 'AI'
if (key.startsWith('smtp_') || key.startsWith('email_')) return 'EMAIL' if (key.startsWith('smtp_') || key.startsWith('email_')) return 'EMAIL'
if (key.startsWith('storage_') || key.startsWith('local_storage') || key.startsWith('max_file') || key.startsWith('avatar_') || key.startsWith('allowed_file')) return 'STORAGE' if (key.startsWith('storage_') || key.startsWith('local_storage') || key.startsWith('max_file') || key.startsWith('avatar_') || key.startsWith('allowed_file')) return 'STORAGE'
@@ -33,6 +33,7 @@ function inferSettingCategory(key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORA
if (key.startsWith('whatsapp_')) return 'WHATSAPP' if (key.startsWith('whatsapp_')) return 'WHATSAPP'
if (key.startsWith('security_') || key.startsWith('session_')) return 'SECURITY' if (key.startsWith('security_') || key.startsWith('session_')) return 'SECURITY'
if (key.startsWith('learning_hub_') || key.startsWith('jury_compare_') || key.startsWith('support_')) return 'FEATURE_FLAGS' if (key.startsWith('learning_hub_') || key.startsWith('jury_compare_') || key.startsWith('support_')) return 'FEATURE_FLAGS'
if (key.startsWith('applicant_') || key.startsWith('observer_') || key.startsWith('analytics_')) return 'ANALYTICS'
return 'DEFAULTS' return 'DEFAULTS'
} }
@@ -42,34 +43,40 @@ export const settingsRouter = router({
* These are non-sensitive settings that can be exposed to any user * These are non-sensitive settings that can be exposed to any user
*/ */
getFeatureFlags: protectedProcedure.query(async ({ ctx }) => { getFeatureFlags: protectedProcedure.query(async ({ ctx }) => {
const [whatsappEnabled, juryCompareEnabled, learningHubExternal, learningHubExternalUrl, supportEmail, accountReminderDays] = await Promise.all([ const keys = [
ctx.prisma.systemSettings.findUnique({ 'whatsapp_enabled', 'jury_compare_enabled', 'learning_hub_external',
where: { key: 'whatsapp_enabled' }, 'learning_hub_external_url', 'support_email', 'account_reminder_days',
}), 'observer_show_team_tab',
ctx.prisma.systemSettings.findUnique({ 'applicant_show_evaluation_feedback', 'applicant_show_evaluation_scores',
where: { key: 'jury_compare_enabled' }, 'applicant_show_evaluation_criteria', 'applicant_show_evaluation_text',
}), 'applicant_show_livefinal_feedback', 'applicant_show_livefinal_scores',
ctx.prisma.systemSettings.findUnique({ 'applicant_show_deliberation_feedback',
where: { key: 'learning_hub_external' }, 'applicant_hide_feedback_from_rejected',
}), ]
ctx.prisma.systemSettings.findUnique({ const settings = await ctx.prisma.systemSettings.findMany({
where: { key: 'learning_hub_external_url' }, where: { key: { in: keys } },
}), })
ctx.prisma.systemSettings.findUnique({ const map = new Map(settings.map((s) => [s.key, s.value]))
where: { key: 'support_email' }, const flag = (k: string, def = 'false') => (map.get(k) ?? def) === 'true'
}),
ctx.prisma.systemSettings.findUnique({
where: { key: 'account_reminder_days' },
}),
])
return { return {
whatsappEnabled: whatsappEnabled?.value === 'true', whatsappEnabled: flag('whatsapp_enabled'),
juryCompareEnabled: juryCompareEnabled?.value === 'true', juryCompareEnabled: flag('jury_compare_enabled'),
learningHubExternal: learningHubExternal?.value === 'true', learningHubExternal: flag('learning_hub_external'),
learningHubExternalUrl: learningHubExternalUrl?.value || '', learningHubExternalUrl: map.get('learning_hub_external_url') || '',
supportEmail: supportEmail?.value || '', supportEmail: map.get('support_email') || '',
accountReminderDays: parseInt(accountReminderDays?.value || '3', 10), accountReminderDays: parseInt(map.get('account_reminder_days') || '3', 10),
observerShowTeamTab: flag('observer_show_team_tab', 'true'),
applicantFeedback: {
evaluationEnabled: flag('applicant_show_evaluation_feedback'),
evaluationShowScores: flag('applicant_show_evaluation_scores'),
evaluationShowCriteria: flag('applicant_show_evaluation_criteria'),
evaluationShowText: flag('applicant_show_evaluation_text'),
liveFinalEnabled: flag('applicant_show_livefinal_feedback'),
liveFinalShowScores: flag('applicant_show_livefinal_scores'),
deliberationEnabled: flag('applicant_show_deliberation_feedback'),
hideFromRejected: flag('applicant_hide_feedback_from_rejected'),
},
} }
}), }),