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

269 lines
8.0 KiB
TypeScript
Raw Normal View History

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, protectedProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
export const competitionRouter = router({
/**
* Create a new competition for a program
*/
create: adminProcedure
.input(
z.object({
programId: z.string(),
name: z.string().min(1).max(255),
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
categoryMode: z.string().default('SHARED'),
startupFinalistCount: z.number().int().positive().default(3),
conceptFinalistCount: z.number().int().positive().default(3),
notifyOnRoundAdvance: z.boolean().default(true),
notifyOnDeadlineApproach: z.boolean().default(true),
deadlineReminderDays: z.array(z.number().int().positive()).default([7, 3, 1]),
})
)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.competition.findUnique({
where: { slug: input.slug },
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: `A competition with slug "${input.slug}" already exists`,
})
}
await ctx.prisma.program.findUniqueOrThrow({
where: { id: input.programId },
})
const competition = await ctx.prisma.competition.create({
data: input,
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Competition',
entityId: competition.id,
detailsJson: { name: input.name, programId: input.programId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return competition
}),
/**
* Get competition by ID with rounds, jury groups, and submission windows
*/
getById: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const competition = await ctx.prisma.competition.findUnique({
where: { id: input.id },
include: {
rounds: {
orderBy: { sortOrder: 'asc' },
select: {
id: true,
name: true,
slug: true,
roundType: true,
status: true,
sortOrder: true,
windowOpenAt: true,
windowCloseAt: true,
specialAwardId: true,
juryGroup: {
select: { id: true, name: true },
},
_count: {
select: {
projectRoundStates: true,
assignments: true,
},
},
},
},
juryGroups: {
orderBy: { sortOrder: 'asc' },
select: {
id: true,
name: true,
slug: true,
sortOrder: true,
defaultMaxAssignments: true,
defaultCapMode: true,
_count: { select: { members: true } },
},
},
submissionWindows: {
orderBy: { sortOrder: 'asc' },
select: {
id: true,
name: true,
slug: true,
roundNumber: true,
windowOpenAt: true,
windowCloseAt: true,
isLocked: true,
deadlinePolicy: true,
graceHours: true,
lockOnClose: true,
sortOrder: true,
_count: { select: { fileRequirements: true, projectFiles: true } },
},
},
},
})
if (!competition) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Competition not found' })
}
// Count distinct projects across all rounds (not sum of per-round states)
Admin UI audit round 2: fix 28 display bugs across 23 files HIGH fixes (broken features / wrong data): - H1: Fix roundAssignments → projectRoundStates in project router (7 occurrences) - H2: Fix deliberation results panel blank table (wrong field names) - H3: Fix deliberation participant names blank (wrong data path) - H4: Fix awards "Evaluated" stat duplicating "Eligible" count - H5: Fix cross-round comparison enabled at 1 round (backend requires 2) - H6: Fix setState during render anti-pattern (6 occurrences) - H7: Fix round detail jury member count always showing 0 - H8: Remove 4 invalid status values from observer dashboard filter - H9: Fix filtering progress bar always showing 100% MEDIUM fixes (misleading display): - M1: Filter special-award rounds from competition timeline - M2: Exclude special-award rounds from distinct project count - M3: Fix MENTORING pipeline node hardcoded "0 mentored" - M4: Fix DELIB_LOCKED badge using red for success state - M5: Add status label maps to deliberation session detail - M6: Humanize deliberation category + tie-break method displays - M8: Rename setStageId → setRoundId, "Select Stage" → "Select Round" - M9: Add missing INVITED/ACTIVE/SUSPENDED to members status labels - M10: Add ROUND_DRAFT/ACTIVE/CLOSED/ARCHIVED to StatusBadge - M11: Fix unsent messages showing "Scheduled" instead of "Draft" - M12: Rename misleading totalEvaluations → totalAssignments - M13: Rename "Stage" column to "Program" in projects page LOW fixes (cosmetic / edge-case): - L1: Use unfiltered rounds array for active round detection - L2: Use all rounds length for new round sort order - L3: Filter special-award rounds from header count - L4: Fix single-underscore replace in award status badges - L5: Fix score bucket boundary gaps (4.99 dropped between buckets) - L6: Title-case LIVE_FINAL pipeline metric status - L7: Fix roundType.replace only replacing first underscore - L8: Remove duplicate severity sort in smart-actions component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:11:00 +01:00
const roundIds = competition.rounds.filter((r) => !r.specialAwardId).map((r) => r.id)
const distinctProjectCount = roundIds.length > 0
? await ctx.prisma.projectRoundState.findMany({
where: { roundId: { in: roundIds } },
select: { projectId: true },
distinct: ['projectId'],
}).then((rows) => rows.length)
: 0
return { ...competition, distinctProjectCount }
}),
/**
* List competitions for a program
*/
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
list: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.competition.findMany({
where: { programId: input.programId },
orderBy: { createdAt: 'desc' },
include: {
_count: {
select: {
rounds: { where: { specialAwardId: null } },
juryGroups: true,
submissionWindows: true,
},
},
},
})
}),
/**
* Update competition settings
*/
update: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(255).optional(),
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
status: z.enum(['DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED']).optional(),
categoryMode: z.string().optional(),
startupFinalistCount: z.number().int().positive().optional(),
conceptFinalistCount: z.number().int().positive().optional(),
notifyOnRoundAdvance: z.boolean().optional(),
notifyOnDeadlineApproach: z.boolean().optional(),
deadlineReminderDays: z.array(z.number().int().positive()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
if (data.slug) {
const existing = await ctx.prisma.competition.findFirst({
where: { slug: data.slug, NOT: { id } },
})
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: `A competition with slug "${data.slug}" already exists`,
})
}
}
const previous = await ctx.prisma.competition.findUniqueOrThrow({ where: { id } })
const competition = await ctx.prisma.competition.update({
where: { id },
data,
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Competition',
entityId: id,
detailsJson: {
changes: data,
previous: {
name: previous.name,
status: previous.status,
slug: previous.slug,
},
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return competition
}),
/**
* Delete (archive) a competition
*/
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const competition = await ctx.prisma.competition.update({
where: { id: input.id },
data: { status: 'ARCHIVED' },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Competition',
entityId: input.id,
detailsJson: { action: 'archived' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return competition
}),
/**
* Get competitions where the current user is a jury group member
*/
getMyCompetitions: protectedProcedure.query(async ({ ctx }) => {
// Find competitions where the user is a jury group member
const memberships = await ctx.prisma.juryGroupMember.findMany({
where: { userId: ctx.user.id },
select: { juryGroup: { select: { competitionId: true } } },
})
const competitionIds = [...new Set(memberships.map((m) => m.juryGroup.competitionId))]
if (competitionIds.length === 0) return []
return ctx.prisma.competition.findMany({
where: { id: { in: competitionIds }, status: { not: 'ARCHIVED' } },
include: {
rounds: {
orderBy: { sortOrder: 'asc' },
select: { id: true, name: true, roundType: true, status: true },
},
_count: { select: { rounds: { where: { specialAwardId: null } }, juryGroups: true } },
},
orderBy: { createdAt: 'desc' },
})
}),
})