feat: observer team tab, admin-controlled applicant feedback visibility
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m13s
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:
@@ -10,7 +10,7 @@ import { logAudit } from '@/server/utils/audit'
|
||||
import { createNotification } from '../services/in-app-notification'
|
||||
import { checkRequirementsAndTransition, triggerInProgressOnActivity, transitionProject, isTerminalState } from '../services/round-engine'
|
||||
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).
|
||||
// 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.
|
||||
* 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 }) => {
|
||||
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
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user