Compare commits
2 Commits
67670472f7
...
94814bd505
| Author | SHA1 | Date | |
|---|---|---|---|
| 94814bd505 | |||
| 6b6f5e33f5 |
@@ -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' },
|
||||||
|
|||||||
@@ -171,6 +171,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const updateTeamMemberRole = trpc.project.updateTeamMemberRole.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Role updated')
|
||||||
|
utils.project.getFullDetail.invalidate({ id: projectId })
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(err.message || 'Failed to update role')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const removeTeamMember = trpc.project.removeTeamMember.useMutation({
|
const removeTeamMember = trpc.project.removeTeamMember.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Team member removed')
|
toast.success('Team member removed')
|
||||||
@@ -538,9 +548,25 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<Link href={`/admin/members/${member.user.id}`} className="font-medium text-sm truncate hover:underline text-primary">
|
<Link href={`/admin/members/${member.user.id}`} className="font-medium text-sm truncate hover:underline text-primary">
|
||||||
{member.user.name || 'Unnamed'}
|
{member.user.name || 'Unnamed'}
|
||||||
</Link>
|
</Link>
|
||||||
<Badge variant="outline" className="text-xs">
|
<Select
|
||||||
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
value={member.role}
|
||||||
</Badge>
|
onValueChange={(value) =>
|
||||||
|
updateTeamMemberRole.mutate({
|
||||||
|
projectId: project.id,
|
||||||
|
userId: member.user.id,
|
||||||
|
role: value as 'LEAD' | 'MEMBER' | 'ADVISOR',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-auto text-xs px-2 py-0 border-dashed gap-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="LEAD">Lead</SelectItem>
|
||||||
|
<SelectItem value="MEMBER">Member</SelectItem>
|
||||||
|
<SelectItem value="ADVISOR">Advisor</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{member.user.email}
|
{member.user.email}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -124,17 +124,17 @@ export default function ApplicantDashboardPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between flex-wrap gap-4">
|
<div className="flex items-start justify-between flex-wrap gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Project logo — clickable for team leads to change */}
|
{/* Project logo — clickable for any team member to change */}
|
||||||
{project.isTeamLead ? (
|
<ProjectLogoUpload
|
||||||
<ProjectLogoUpload
|
projectId={project.id}
|
||||||
projectId={project.id}
|
currentLogoUrl={data.logoUrl}
|
||||||
currentLogoUrl={data.logoUrl}
|
onUploadComplete={() => utils.applicant.getMyDashboard.invalidate()}
|
||||||
onUploadComplete={() => utils.applicant.getMyDashboard.invalidate()}
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group relative shrink-0 flex flex-col items-center gap-1 cursor-pointer"
|
||||||
>
|
>
|
||||||
<button
|
<div className="relative h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden hover:ring-2 hover:ring-primary/30 transition-all">
|
||||||
type="button"
|
|
||||||
className="group relative shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary/30 transition-all"
|
|
||||||
>
|
|
||||||
{data.logoUrl ? (
|
{data.logoUrl ? (
|
||||||
<img src={data.logoUrl} alt={project.title} className="h-full w-full object-cover" />
|
<img src={data.logoUrl} alt={project.title} className="h-full w-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
@@ -143,17 +143,12 @@ export default function ApplicantDashboardPage() {
|
|||||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
|
||||||
<Pencil className="h-4 w-4 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
<Pencil className="h-4 w-4 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</ProjectLogoUpload>
|
<span className="text-[10px] text-primary/70 group-hover:text-primary transition-colors">
|
||||||
) : (
|
{data.logoUrl ? 'Change' : 'Add logo'}
|
||||||
<div className="shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden">
|
</span>
|
||||||
{data.logoUrl ? (
|
</button>
|
||||||
<img src={data.logoUrl} alt={project.title} className="h-full w-full object-cover" />
|
</ProjectLogoUpload>
|
||||||
) : (
|
|
||||||
<FileText className="h-7 w-7 text-muted-foreground/60" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{project.title}</h1>
|
||||||
|
|||||||
@@ -244,17 +244,17 @@ export default function ApplicantProjectPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Project logo — clickable for team leads */}
|
{/* Project logo — clickable for any team member to change */}
|
||||||
{isTeamLead ? (
|
<ProjectLogoUpload
|
||||||
<ProjectLogoUpload
|
projectId={projectId}
|
||||||
projectId={projectId}
|
currentLogoUrl={logoUrl}
|
||||||
currentLogoUrl={logoUrl}
|
onUploadComplete={() => refetchLogo()}
|
||||||
onUploadComplete={() => refetchLogo()}
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="group relative shrink-0 flex flex-col items-center gap-1 cursor-pointer"
|
||||||
>
|
>
|
||||||
<button
|
<div className="relative h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden hover:ring-2 hover:ring-primary/30 transition-all">
|
||||||
type="button"
|
|
||||||
className="group relative shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden cursor-pointer hover:ring-2 hover:ring-primary/30 transition-all"
|
|
||||||
>
|
|
||||||
{logoUrl ? (
|
{logoUrl ? (
|
||||||
<img src={logoUrl} alt={project.title} className="h-full w-full object-cover" />
|
<img src={logoUrl} alt={project.title} className="h-full w-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
@@ -263,17 +263,12 @@ export default function ApplicantProjectPage() {
|
|||||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
|
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
|
||||||
<Pencil className="h-4 w-4 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
<Pencil className="h-4 w-4 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</ProjectLogoUpload>
|
<span className="text-[10px] text-primary/70 group-hover:text-primary transition-colors">
|
||||||
) : (
|
{logoUrl ? 'Change' : 'Add logo'}
|
||||||
<div className="shrink-0 h-14 w-14 rounded-xl border bg-muted/50 flex items-center justify-center overflow-hidden">
|
</span>
|
||||||
{logoUrl ? (
|
</button>
|
||||||
<img src={logoUrl} alt={project.title} className="h-full w-full object-cover" />
|
</ProjectLogoUpload>
|
||||||
) : (
|
|
||||||
<FolderOpen className="h-7 w-7 text-muted-foreground/60" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
{project.title}
|
{project.title}
|
||||||
@@ -388,7 +383,7 @@ export default function ApplicantProjectPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Project Logo */}
|
{/* Project Logo */}
|
||||||
{isTeamLead && projectId && (
|
{projectId && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -69,6 +69,19 @@ export default function LoginPage() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Pre-check: does this email exist?
|
||||||
|
const checkRes = await fetch('/api/auth/check-email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
})
|
||||||
|
const checkData = await checkRes.json()
|
||||||
|
if (!checkData.exists) {
|
||||||
|
setError('No account found with this email address. Please check the email you used to sign up, or contact the administrator.')
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Get CSRF token first
|
// Get CSRF token first
|
||||||
const csrfRes = await fetch('/api/auth/csrf')
|
const csrfRes = await fetch('/api/auth/csrf')
|
||||||
const { csrfToken } = await csrfRes.json()
|
const { csrfToken } = await csrfRes.json()
|
||||||
@@ -300,6 +313,12 @@ export default function LoginPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<p className="mt-3 text-xs text-muted-foreground/70 text-center">
|
||||||
|
Don't remember which email you used?{' '}
|
||||||
|
<a href="mailto:contact@monaco-opc.com" className="underline hover:text-primary transition-colors">
|
||||||
|
Contact the MOPC team
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
26
src/app/api/auth/check-email/route.ts
Normal file
26
src/app/api/auth/check-email/route.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-check whether an email exists before sending a magic link.
|
||||||
|
* This is a closed platform (no self-registration) so revealing
|
||||||
|
* email existence is acceptable and helps users who mistype.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { email } = await req.json()
|
||||||
|
if (!email || typeof email !== 'string') {
|
||||||
|
return NextResponse.json({ exists: false }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: email.toLowerCase().trim() },
|
||||||
|
select: { status: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const exists = !!user && user.status !== 'SUSPENDED'
|
||||||
|
return NextResponse.json({ exists })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ exists: false }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1611,6 +1611,58 @@ export const projectRouter = router({
|
|||||||
return { success: true }
|
return { success: true }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a team member's role (admin only).
|
||||||
|
* Prevents removing the last LEAD.
|
||||||
|
*/
|
||||||
|
updateTeamMemberRole: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
userId: z.string(),
|
||||||
|
role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const { projectId, userId, role } = input
|
||||||
|
|
||||||
|
const member = await ctx.prisma.teamMember.findUniqueOrThrow({
|
||||||
|
where: { projectId_userId: { projectId, userId } },
|
||||||
|
select: { role: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Prevent removing the last LEAD
|
||||||
|
if (member.role === 'LEAD' && role !== 'LEAD') {
|
||||||
|
const leadCount = await ctx.prisma.teamMember.count({
|
||||||
|
where: { projectId, role: 'LEAD' },
|
||||||
|
})
|
||||||
|
if (leadCount <= 1) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Cannot change the role of the last team lead',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.prisma.teamMember.update({
|
||||||
|
where: { projectId_userId: { projectId, userId } },
|
||||||
|
data: { role },
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'UPDATE_TEAM_MEMBER_ROLE',
|
||||||
|
entityType: 'Project',
|
||||||
|
entityId: projectId,
|
||||||
|
detailsJson: { targetUserId: userId, oldRole: member.role, newRole: role },
|
||||||
|
ipAddress: ctx.ip,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
}),
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// BULK NOTIFICATION ENDPOINTS
|
// BULK NOTIFICATION ENDPOINTS
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user