HIGH fixes: - H1: Competition detail project count no longer double-counts across rounds - H2: Rounds page header stats use unfiltered round set - H3: Rounds page "eval" label corrected to "asgn" (assignment count) - H4: Observer reports project count uses distinct analytics count - H5: Awards eligibility count filters to only eligible=true (backend) - H6: Round detail projectCount derived from projectStates for consistency - H7: Deliberation hasVoted derived from votes array (was always undefined) MEDIUM fixes: - M1: Reports page round status badges use correct ROUND_ACTIVE/ROUND_CLOSED enums - M2: Observer reports badges use ROUND_ prefix instead of stale STAGE_ prefix - M3: Deliberation list status badges use correct VOTING/TALLYING/RUNOFF enums - M4: Competition list/detail round count excludes special-award rounds (backend) - M5: Messages page shows actual recipient count instead of hardcoded "1 user" LOW fixes: - L2: Observer analytics jurorCount scoped to round when roundId provided - L3: Analytics round-scoped project count uses ProjectRoundState not assignments - L4: JuryGroup delete audit log reports member count (not assignment count) - L5: Project rankings include unevaluated projects at bottom instead of hiding Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
269 lines
8.0 KiB
TypeScript
269 lines
8.0 KiB
TypeScript
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)
|
|
const roundIds = competition.rounds.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
|
|
*/
|
|
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' },
|
|
})
|
|
}),
|
|
})
|