Files
MOPC-Portal/src/server/routers/deliberation.ts

334 lines
8.9 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, juryProcedure, protectedProcedure } from '../trpc'
Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code Phase 1 — Critical bugs: - Fix deliberation participant selection (wire jury group query) - Fix reports "By Round" tab (inline content instead of 404 route) - Fix messages "Sent History" (add message.sent procedure, wire tab) - Add missing fields to competition award form (criteriaText, maxRankedPicks) - Wire LiveControlPanel buttons (cursor, voting, scores) - Fix ResultLockControls empty snapshot (fetch actual data before lock) - Fix SubmissionWindowManager losing fields on edit Phase 2 — Backend fixes: - Remove write-in-query from specialAward.get - Fix award eligibility job overwriting manual shortlist overrides - Fix filtering startJob deleting all prior results (defer cleanup to post-success) - Tighten access control: protectedProcedure → adminProcedure on 8 procedures - Add audit logging to deliberation mutations - Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete Phase 3 — Auto-refresh: - Add refetchInterval to 15+ admin pages/components (10s–30s) - Fix AI job polling: derive speed from job status for all viewers Phase 4 — Dead code cleanup: - Delete unused command-palette, pdf-report, admin-page-transition - Remove dead subItems sidebar code, unused GripVertical import - Replace redundant isGenerating state with mutation.isPending - Add Role column to jury members table - Remove misleading manual mentor assignment stub Phase 5 — UX improvements: - Fix rounds page single-competition assumption (add selector) - Remove raw UUID fallback in deliberation config - Fix programs page "Stage" → "Round" terminology Phase 6 — Backend hardening: - Complete logAudit calls (add prisma, ipAddress, userAgent) - Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear) - Batch user.bulkCreate writes (assignments, jury memberships, intents) - Remove any casts from deliberation service (typed PrismaClient + TransactionClient) - Fix stale DeliberationStatus enum values blocking build 40 files changed, 1010 insertions(+), 612 deletions(-) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:20:13 +01:00
import { logAudit } from '@/server/utils/audit'
import {
createSession,
openVoting,
closeVoting,
submitVote,
aggregateVotes,
initRunoff,
adminDecide,
finalizeResults,
updateParticipantStatus,
getSessionWithVotes,
} from '../services/deliberation'
const categoryEnum = z.enum([
'STARTUP',
'BUSINESS_CONCEPT',
])
const deliberationModeEnum = z.enum(['SINGLE_WINNER_VOTE', 'FULL_RANKING'])
const tieBreakMethodEnum = z.enum(['TIE_RUNOFF', 'TIE_ADMIN_DECIDES', 'SCORE_FALLBACK'])
const participantStatusEnum = z.enum([
'REQUIRED',
'ABSENT_EXCUSED',
'REPLACED',
'REPLACEMENT_ACTIVE',
])
export const deliberationRouter = router({
/**
* Create a new deliberation session with participants
*/
createSession: adminProcedure
.input(
z.object({
competitionId: z.string(),
roundId: z.string(),
category: categoryEnum,
mode: deliberationModeEnum,
tieBreakMethod: tieBreakMethodEnum,
showCollectiveRankings: z.boolean().default(false),
showPriorJuryData: z.boolean().default(false),
participantUserIds: z.array(z.string()).min(1),
})
)
.mutation(async ({ ctx, input }) => {
Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code Phase 1 — Critical bugs: - Fix deliberation participant selection (wire jury group query) - Fix reports "By Round" tab (inline content instead of 404 route) - Fix messages "Sent History" (add message.sent procedure, wire tab) - Add missing fields to competition award form (criteriaText, maxRankedPicks) - Wire LiveControlPanel buttons (cursor, voting, scores) - Fix ResultLockControls empty snapshot (fetch actual data before lock) - Fix SubmissionWindowManager losing fields on edit Phase 2 — Backend fixes: - Remove write-in-query from specialAward.get - Fix award eligibility job overwriting manual shortlist overrides - Fix filtering startJob deleting all prior results (defer cleanup to post-success) - Tighten access control: protectedProcedure → adminProcedure on 8 procedures - Add audit logging to deliberation mutations - Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete Phase 3 — Auto-refresh: - Add refetchInterval to 15+ admin pages/components (10s–30s) - Fix AI job polling: derive speed from job status for all viewers Phase 4 — Dead code cleanup: - Delete unused command-palette, pdf-report, admin-page-transition - Remove dead subItems sidebar code, unused GripVertical import - Replace redundant isGenerating state with mutation.isPending - Add Role column to jury members table - Remove misleading manual mentor assignment stub Phase 5 — UX improvements: - Fix rounds page single-competition assumption (add selector) - Remove raw UUID fallback in deliberation config - Fix programs page "Stage" → "Round" terminology Phase 6 — Backend hardening: - Complete logAudit calls (add prisma, ipAddress, userAgent) - Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear) - Batch user.bulkCreate writes (assignments, jury memberships, intents) - Remove any casts from deliberation service (typed PrismaClient + TransactionClient) - Fix stale DeliberationStatus enum values blocking build 40 files changed, 1010 insertions(+), 612 deletions(-) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:20:13 +01:00
const session = await createSession(input, ctx.prisma)
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'DeliberationSession',
entityId: session.id,
detailsJson: {
competitionId: input.competitionId,
roundId: input.roundId,
category: input.category,
mode: input.mode,
participantCount: input.participantUserIds.length,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return session
}),
/**
* Open voting: DELIB_OPEN VOTING
*/
openVoting: adminProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const result = await openVoting(input.sessionId, ctx.user.id, ctx.prisma)
if (!result.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: result.errors?.join('; ') ?? 'Failed to open voting',
})
}
return result
}),
/**
* Close voting: VOTING TALLYING
*/
closeVoting: adminProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const result = await closeVoting(input.sessionId, ctx.user.id, ctx.prisma)
if (!result.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: result.errors?.join('; ') ?? 'Failed to close voting',
})
}
return result
}),
/**
* Submit a vote (jury member)
*/
submitVote: juryProcedure
.input(
z.object({
sessionId: z.string(),
juryMemberId: z.string(),
projectId: z.string(),
rank: z.number().int().min(1).optional(),
isWinnerPick: z.boolean().optional(),
runoffRound: z.number().int().min(0).optional(),
})
)
.mutation(async ({ ctx, input }) => {
Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code Phase 1 — Critical bugs: - Fix deliberation participant selection (wire jury group query) - Fix reports "By Round" tab (inline content instead of 404 route) - Fix messages "Sent History" (add message.sent procedure, wire tab) - Add missing fields to competition award form (criteriaText, maxRankedPicks) - Wire LiveControlPanel buttons (cursor, voting, scores) - Fix ResultLockControls empty snapshot (fetch actual data before lock) - Fix SubmissionWindowManager losing fields on edit Phase 2 — Backend fixes: - Remove write-in-query from specialAward.get - Fix award eligibility job overwriting manual shortlist overrides - Fix filtering startJob deleting all prior results (defer cleanup to post-success) - Tighten access control: protectedProcedure → adminProcedure on 8 procedures - Add audit logging to deliberation mutations - Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete Phase 3 — Auto-refresh: - Add refetchInterval to 15+ admin pages/components (10s–30s) - Fix AI job polling: derive speed from job status for all viewers Phase 4 — Dead code cleanup: - Delete unused command-palette, pdf-report, admin-page-transition - Remove dead subItems sidebar code, unused GripVertical import - Replace redundant isGenerating state with mutation.isPending - Add Role column to jury members table - Remove misleading manual mentor assignment stub Phase 5 — UX improvements: - Fix rounds page single-competition assumption (add selector) - Remove raw UUID fallback in deliberation config - Fix programs page "Stage" → "Round" terminology Phase 6 — Backend hardening: - Complete logAudit calls (add prisma, ipAddress, userAgent) - Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear) - Batch user.bulkCreate writes (assignments, jury memberships, intents) - Remove any casts from deliberation service (typed PrismaClient + TransactionClient) - Fix stale DeliberationStatus enum values blocking build 40 files changed, 1010 insertions(+), 612 deletions(-) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:20:13 +01:00
const vote = await submitVote(input, ctx.prisma)
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'DeliberationVote',
entityId: input.sessionId,
detailsJson: {
sessionId: input.sessionId,
projectId: input.projectId,
rank: input.rank,
isWinnerPick: input.isWinnerPick,
runoffRound: input.runoffRound,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return vote
}),
/**
* Aggregate votes for a session
*/
aggregate: adminProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
return aggregateVotes(input.sessionId, ctx.prisma)
}),
/**
* Initiate a runoff: TALLYING RUNOFF
*/
initRunoff: adminProcedure
.input(
z.object({
sessionId: z.string(),
tiedProjectIds: z.array(z.string()).min(2),
})
)
.mutation(async ({ ctx, input }) => {
const result = await initRunoff(
input.sessionId,
input.tiedProjectIds,
ctx.user.id,
ctx.prisma,
)
if (!result.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: result.errors?.join('; ') ?? 'Failed to initiate runoff',
})
}
return result
}),
/**
* Admin override: directly set final rankings
*/
adminDecide: adminProcedure
.input(
z.object({
sessionId: z.string(),
rankings: z.array(
z.object({
projectId: z.string(),
rank: z.number().int().min(1),
})
).min(1),
reason: z.string().min(1).max(2000),
})
)
.mutation(async ({ ctx, input }) => {
const result = await adminDecide(
input.sessionId,
input.rankings,
input.reason,
ctx.user.id,
ctx.prisma,
)
if (!result.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: result.errors?.join('; ') ?? 'Failed to admin-decide',
})
}
return result
}),
/**
* Finalize results: TALLYING DELIB_LOCKED
*/
finalize: adminProcedure
.input(z.object({ sessionId: z.string() }))
.mutation(async ({ ctx, input }) => {
const result = await finalizeResults(input.sessionId, ctx.user.id, ctx.prisma)
if (!result.success) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: result.errors?.join('; ') ?? 'Failed to finalize results',
})
}
return result
}),
/**
* Get session with votes, results, and participants.
* Redacts juror identities for non-admin users when session flags are off.
*/
getSession: protectedProcedure
.input(z.object({ sessionId: z.string() }))
.query(async ({ ctx, input }) => {
const session = await getSessionWithVotes(input.sessionId, ctx.prisma)
if (!session) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Session not found' })
}
const isAdmin = ctx.user.role === 'SUPER_ADMIN' || ctx.user.role === 'PROGRAM_ADMIN'
if (isAdmin) return session
// Non-admin: enforce visibility flags
if (!session.showCollectiveRankings) {
// Anonymize juror identity on votes — only show own votes with identity
session.votes = session.votes.map((v: any, i: number) => {
const isOwn = v.juryMember?.user?.id === ctx.user.id
if (isOwn) return v
return {
...v,
juryMember: {
...v.juryMember,
user: { id: `anon-${i}`, name: `Juror ${i + 1}`, email: '' },
},
}
})
// Anonymize participants
session.participants = session.participants.map((p: any, i: number) => {
const isOwn = p.user?.user?.id === ctx.user.id
if (isOwn) return p
return {
...p,
user: p.user
? { ...p.user, user: { id: `anon-${i}`, name: `Juror ${i + 1}`, email: '' } }
: p.user,
}
})
}
return session
}),
/**
* List deliberation sessions for a competition
*/
listSessions: adminProcedure
.input(z.object({ competitionId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.deliberationSession.findMany({
where: {
round: { competitionId: input.competitionId },
},
include: {
round: { select: { id: true, name: true, roundType: true } },
_count: { select: { votes: true, participants: true } },
participants: {
select: { userId: true },
},
},
orderBy: { createdAt: 'desc' },
})
}),
/**
* Update participant status (mark absent, replace, etc.)
*/
updateParticipant: adminProcedure
.input(
z.object({
sessionId: z.string(),
userId: z.string(),
status: participantStatusEnum,
replacedById: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code Phase 1 — Critical bugs: - Fix deliberation participant selection (wire jury group query) - Fix reports "By Round" tab (inline content instead of 404 route) - Fix messages "Sent History" (add message.sent procedure, wire tab) - Add missing fields to competition award form (criteriaText, maxRankedPicks) - Wire LiveControlPanel buttons (cursor, voting, scores) - Fix ResultLockControls empty snapshot (fetch actual data before lock) - Fix SubmissionWindowManager losing fields on edit Phase 2 — Backend fixes: - Remove write-in-query from specialAward.get - Fix award eligibility job overwriting manual shortlist overrides - Fix filtering startJob deleting all prior results (defer cleanup to post-success) - Tighten access control: protectedProcedure → adminProcedure on 8 procedures - Add audit logging to deliberation mutations - Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete Phase 3 — Auto-refresh: - Add refetchInterval to 15+ admin pages/components (10s–30s) - Fix AI job polling: derive speed from job status for all viewers Phase 4 — Dead code cleanup: - Delete unused command-palette, pdf-report, admin-page-transition - Remove dead subItems sidebar code, unused GripVertical import - Replace redundant isGenerating state with mutation.isPending - Add Role column to jury members table - Remove misleading manual mentor assignment stub Phase 5 — UX improvements: - Fix rounds page single-competition assumption (add selector) - Remove raw UUID fallback in deliberation config - Fix programs page "Stage" → "Round" terminology Phase 6 — Backend hardening: - Complete logAudit calls (add prisma, ipAddress, userAgent) - Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear) - Batch user.bulkCreate writes (assignments, jury memberships, intents) - Remove any casts from deliberation service (typed PrismaClient + TransactionClient) - Fix stale DeliberationStatus enum values blocking build 40 files changed, 1010 insertions(+), 612 deletions(-) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:20:13 +01:00
const result = await updateParticipantStatus(
input.sessionId,
input.userId,
input.status,
input.replacedById,
ctx.user.id,
ctx.prisma,
)
Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code Phase 1 — Critical bugs: - Fix deliberation participant selection (wire jury group query) - Fix reports "By Round" tab (inline content instead of 404 route) - Fix messages "Sent History" (add message.sent procedure, wire tab) - Add missing fields to competition award form (criteriaText, maxRankedPicks) - Wire LiveControlPanel buttons (cursor, voting, scores) - Fix ResultLockControls empty snapshot (fetch actual data before lock) - Fix SubmissionWindowManager losing fields on edit Phase 2 — Backend fixes: - Remove write-in-query from specialAward.get - Fix award eligibility job overwriting manual shortlist overrides - Fix filtering startJob deleting all prior results (defer cleanup to post-success) - Tighten access control: protectedProcedure → adminProcedure on 8 procedures - Add audit logging to deliberation mutations - Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete Phase 3 — Auto-refresh: - Add refetchInterval to 15+ admin pages/components (10s–30s) - Fix AI job polling: derive speed from job status for all viewers Phase 4 — Dead code cleanup: - Delete unused command-palette, pdf-report, admin-page-transition - Remove dead subItems sidebar code, unused GripVertical import - Replace redundant isGenerating state with mutation.isPending - Add Role column to jury members table - Remove misleading manual mentor assignment stub Phase 5 — UX improvements: - Fix rounds page single-competition assumption (add selector) - Remove raw UUID fallback in deliberation config - Fix programs page "Stage" → "Round" terminology Phase 6 — Backend hardening: - Complete logAudit calls (add prisma, ipAddress, userAgent) - Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear) - Batch user.bulkCreate writes (assignments, jury memberships, intents) - Remove any casts from deliberation service (typed PrismaClient + TransactionClient) - Fix stale DeliberationStatus enum values blocking build 40 files changed, 1010 insertions(+), 612 deletions(-) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:20:13 +01:00
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'DeliberationParticipant',
entityId: input.sessionId,
detailsJson: {
sessionId: input.sessionId,
targetUserId: input.userId,
status: input.status,
replacedById: input.replacedById,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
}),
})