-
-
- Evaluator #{idx + 1}
-
- {ev.submittedAt && (
-
- {new Date(ev.submittedAt).toLocaleDateString()}
+ {rounds.map((round) => {
+ const roundIcon = round.roundType === 'LIVE_FINAL'
+ ?
+ : round.roundType === 'DELIBERATION'
+ ?
+ :
+
+ return (
+
+
+
+
+ {roundIcon}
+ {round.roundName}
+
+
+ {round.evaluationCount} {round.roundType === 'DELIBERATION' ? 'vote' : 'evaluation'}{round.evaluationCount !== 1 ? 's' : ''}
+
+
+
+
+ {round.evaluations.map((ev, idx) => (
+
+
+
+ {round.roundType === 'DELIBERATION' ? `Juror #${idx + 1}` : `Evaluator #${idx + 1}`}
+ {ev.submittedAt && (
+
+ {new Date(ev.submittedAt).toLocaleDateString()}
+
+ )}
+
+
+ {ev.globalScore !== null && round.roundType !== 'DELIBERATION' && (
+
+
+ {ev.globalScore}
+
+ / {round.roundType === 'LIVE_FINAL' ? '10' : '100'}
+
+
+ )}
+
+ {ev.criterionScores && ev.criteria && (
+
+
Criterion Scores
+
+ {(() => {
+ const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }>
+ const scores = ev.criterionScores as Record
+ return criteria
+ .filter((c) => c.id || c.label || c.name)
+ .map((c, ci) => {
+ const key = c.id || String(ci)
+ const score = scores[key]
+ return (
+
+ {c.label || c.name || `Criterion ${ci + 1}`}
+
+ {score !== undefined ? score : '—'}
+ {c.maxScore ? ` / ${c.maxScore}` : ''}
+
+
+ )
+ })
+ })()}
+
+
+ )}
+
+ {ev.feedbackText && (
+
+
+
+ {round.roundType === 'DELIBERATION' ? 'Result' : 'Written Feedback'}
+
+
+ {ev.feedbackText}
+
+
)}
-
- {ev.globalScore !== null && (
-
-
- {ev.globalScore}
- / 100
-
- )}
-
- {ev.criterionScores && ev.criteria && (
-
-
Criterion Scores
-
- {(() => {
- const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }>
- const scores = ev.criterionScores as Record
- return criteria
- .filter((c) => c.id || c.label || c.name)
- .map((c, ci) => {
- const key = c.id || String(ci)
- const score = scores[key]
- return (
-
- {c.label || c.name || `Criterion ${ci + 1}`}
-
- {score !== undefined ? score : '—'}
- {c.maxScore ? ` / ${c.maxScore}` : ''}
-
-
- )
- })
- })()}
-
-
- )}
-
- {ev.feedbackText && (
-
-
-
- Written Feedback
-
-
- {ev.feedbackText}
-
-
- )}
-
- ))}
-
-
- ))}
+ ))}
+
+
+ )
+ })}
Evaluator identities are kept confidential.
diff --git a/src/components/observer/observer-project-detail.tsx b/src/components/observer/observer-project-detail.tsx
index c5f721d..e4eeeba 100644
--- a/src/components/observer/observer-project-detail.tsx
+++ b/src/components/observer/observer-project-detail.tsx
@@ -47,6 +47,7 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
{ id: projectId },
{ refetchInterval: 30_000 },
)
+ const { data: flags } = trpc.settings.getFeatureFlags.useQuery()
const roundId = data?.assignments?.[0]?.roundId as string | undefined
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
@@ -242,6 +243,14 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
)}
Files
+ {flags?.observerShowTeamTab && project.teamMembers.length > 0 && (
+
+ Team
+
+ {project.teamMembers.length}
+
+
+ )}
{/* ── Overview Tab ── */}
@@ -854,6 +863,48 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
)}
+ {/* ── Team Tab ── */}
+ {flags?.observerShowTeamTab && project.teamMembers.length > 0 && (
+
+
+
+
+
+
+
+
+ Team Members
+
+
+
+
+ {project.teamMembers.map((member) => (
+
+
+
+
+ {member.user.name || 'Unnamed'}
+
+
+ {member.user.email}
+
+
+
+ {member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
+
+
+ ))}
+
+
+
+
+
+ )}
+
{/* ── Files Tab ── */}
diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx
index f37a69d..e059bc2 100644
--- a/src/components/settings/settings-content.tsx
+++ b/src/components/settings/settings-content.tsx
@@ -145,6 +145,15 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
'analytics_observer_comparison_tab',
'analytics_pdf_enabled',
'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([
@@ -785,6 +794,66 @@ function AnalyticsSettingsSection({ settings }: { settings: Record
+
+
+
+
+ Control what anonymous jury feedback applicants can see on their dashboard
+
+
+
+
+
+
+
+
+
+
{
if (ctx.user.role !== 'APPLICANT') {
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({
where: {
OR: [
@@ -1804,34 +1821,18 @@ export const applicantRouter = router({
if (!project?.programId) return []
- // Get closed/archived EVALUATION rounds — only ones this project participated in
const projectRoundIds = new Set(
(await ctx.prisma.projectRoundState.findMany({
where: { projectId: project.id },
select: { roundId: true },
})).map((prs) => prs.roundId)
)
-
if (projectRoundIds.size === 0) return []
- const evalRounds = await ctx.prisma.round.findMany({
- 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<{
+ type ResultItem = {
roundId: string
roundName: string
+ roundType: string
evaluationCount: number
evaluations: Array<{
id: string
@@ -1841,56 +1842,203 @@ export const applicantRouter = router({
feedbackText: string | null
criteria: Prisma.JsonValue | null
}>
- }> = []
+ }
+ const results: ResultItem[] = []
- 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
-
- // 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({
+ // --- Backwards compatibility: if no admin settings exist yet, fall back to
+ // the old per-round applicantVisibility config for EVALUATION rounds ---
+ if (!adminSettingsExist) {
+ const evalRounds = await ctx.prisma.round.findMany({
where: {
- assignment: {
- projectId: project.id,
- roundId: round.id,
+ 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 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,
- globalScore: vis.showGlobalScore,
- criterionScoresJson: vis.showCriterionScores,
- feedbackText: vis.showFeedbackText,
- form: vis.showCriterionScores ? { select: { criteriaJson: true } } : false,
- },
- orderBy: { submittedAt: 'asc' },
- })
+ select: {
+ id: true, submittedAt: true,
+ globalScore: vis.showGlobalScore,
+ criterionScoresJson: vis.showCriterionScores,
+ feedbackText: vis.showFeedbackText,
+ form: vis.showCriterionScores ? { select: { criteriaJson: true } } : false,
+ },
+ orderBy: { submittedAt: 'asc' },
+ })
+ if (evaluations.length === 0) continue
- // Mask round names: "Evaluation Round 1", "Evaluation Round 2", etc.
- const maskedName = `Evaluation Round ${i + 1}`
+ results.push({
+ 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({
- roundId: round.id,
- roundName: maskedName,
- 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,
- })),
- })
+ // --- New admin settings flow ---
+ const evalEnabled = flag('applicant_show_evaluation_feedback')
+ const evalShowScores = flag('applicant_show_evaluation_scores')
+ const evalShowCriteria = flag('applicant_show_evaluation_criteria')
+ const evalShowText = flag('applicant_show_evaluation_text')
+ const liveFinalEnabled = flag('applicant_show_livefinal_feedback')
+ const liveFinalShowScores = flag('applicant_show_livefinal_scores')
+ const deliberationEnabled = flag('applicant_show_deliberation_feedback')
+ const hideFromRejected = flag('applicant_hide_feedback_from_rejected')
+
+ 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
diff --git a/src/server/routers/settings.ts b/src/server/routers/settings.ts
index c9c0947..24d1d0c 100644
--- a/src/server/routers/settings.ts
+++ b/src/server/routers/settings.ts
@@ -25,7 +25,7 @@ function categorizeModel(modelId: string): string {
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('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'
@@ -33,6 +33,7 @@ function inferSettingCategory(key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORA
if (key.startsWith('whatsapp_')) return 'WHATSAPP'
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('applicant_') || key.startsWith('observer_') || key.startsWith('analytics_')) return 'ANALYTICS'
return 'DEFAULTS'
}
@@ -42,34 +43,40 @@ export const settingsRouter = router({
* These are non-sensitive settings that can be exposed to any user
*/
getFeatureFlags: protectedProcedure.query(async ({ ctx }) => {
- const [whatsappEnabled, juryCompareEnabled, learningHubExternal, learningHubExternalUrl, supportEmail, accountReminderDays] = await Promise.all([
- ctx.prisma.systemSettings.findUnique({
- where: { key: 'whatsapp_enabled' },
- }),
- ctx.prisma.systemSettings.findUnique({
- where: { key: 'jury_compare_enabled' },
- }),
- ctx.prisma.systemSettings.findUnique({
- where: { key: 'learning_hub_external' },
- }),
- ctx.prisma.systemSettings.findUnique({
- where: { key: 'learning_hub_external_url' },
- }),
- ctx.prisma.systemSettings.findUnique({
- where: { key: 'support_email' },
- }),
- ctx.prisma.systemSettings.findUnique({
- where: { key: 'account_reminder_days' },
- }),
- ])
+ const keys = [
+ 'whatsapp_enabled', 'jury_compare_enabled', 'learning_hub_external',
+ 'learning_hub_external_url', 'support_email', 'account_reminder_days',
+ '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 settings = await ctx.prisma.systemSettings.findMany({
+ where: { key: { in: keys } },
+ })
+ const map = new Map(settings.map((s) => [s.key, s.value]))
+ const flag = (k: string, def = 'false') => (map.get(k) ?? def) === 'true'
return {
- whatsappEnabled: whatsappEnabled?.value === 'true',
- juryCompareEnabled: juryCompareEnabled?.value === 'true',
- learningHubExternal: learningHubExternal?.value === 'true',
- learningHubExternalUrl: learningHubExternalUrl?.value || '',
- supportEmail: supportEmail?.value || '',
- accountReminderDays: parseInt(accountReminderDays?.value || '3', 10),
+ whatsappEnabled: flag('whatsapp_enabled'),
+ juryCompareEnabled: flag('jury_compare_enabled'),
+ learningHubExternal: flag('learning_hub_external'),
+ learningHubExternalUrl: map.get('learning_hub_external_url') || '',
+ supportEmail: map.get('support_email') || '',
+ 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'),
+ },
}
}),