All checks were successful
Build and Push Docker Image / build (push) Successful in 8m5s
The AWARD_MASTER role split sponsor jurors into a parallel UI that hid project files (only showed when the award was anchored to an evaluation round) and duplicated the jury voting path with no real difference in authority — tie-break and finalize were already governed by AwardJuror.isChair regardless of the user's global role. Inviting a juror via the award page defaulted to AWARD_MASTER, randomly fragmenting jury panels. This collapses the role into JURY_MEMBER + isChair: - specialAward.getMyAwardDetail now returns evaluation scores, chair visibility into other jurors' votes, and juror roster - specialAward.submitVote accepts an optional justification per vote - specialAward.confirmWinner moves from awardMasterProcedure to protectedProcedure (juror+chair check inside) - bulkInviteJurors creates JURY_MEMBER accounts and, when the award has a juryGroupId, also adds them to that JuryGroup so they appear on the round-page jury panel - jury award page renders justification, eval-score badges, and a chair tools panel with vote tally + finalize-winner CTA - juryGroup.list includes attached SpecialAwards; the jury-list UI shows a trophy pill alongside round pills - (award-master) route group, awardMasterProcedure, AWARD_MASTER role enum value, and AWARD_MASTER_DECISION decisionMode are deleted - migration demotes any residual AWARD_MASTER users to JURY_MEMBER and recreates the UserRole enum without the value Coup de Coeur on prod: Didier (the sponsor juror added today as AWARD_MASTER by the buggy invite form) was migrated to JURY_MEMBER and attached to the existing "Coup de Coeur" JuryGroup; the SpecialAward itself was linked to that group (juryGroupId was NULL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2095 lines
66 KiB
TypeScript
2095 lines
66 KiB
TypeScript
import { z } from 'zod'
|
|
import { TRPCError } from '@trpc/server'
|
|
import { Prisma } from '@prisma/client'
|
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
|
import { getUserAvatarUrl } from '../utils/avatar-url'
|
|
import { logAudit } from '../utils/audit'
|
|
import { processEligibilityJob } from '../services/award-eligibility-job'
|
|
import { resolveAwardWinner } from '../services/award-winner-resolver'
|
|
import {
|
|
getAwardSelectionNotificationTemplate,
|
|
sendJuryInvitationEmail,
|
|
sendAwardJurorNotificationEmail,
|
|
} from '@/lib/email'
|
|
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
|
import { attachProjectLogoUrls } from '../utils/project-logo-url'
|
|
import { sendBatchNotifications } from '../services/notification-sender'
|
|
import type { NotificationItem } from '../services/notification-sender'
|
|
import type { PrismaClient } from '@prisma/client'
|
|
|
|
/**
|
|
* Verify the current session user exists in the database.
|
|
* Guards against stale JWT sessions (e.g., after database reseed).
|
|
*/
|
|
async function ensureUserExists(db: PrismaClient, userId: string): Promise<string> {
|
|
const user = await db.user.findUnique({
|
|
where: { id: userId },
|
|
select: { id: true },
|
|
})
|
|
if (!user) {
|
|
throw new TRPCError({
|
|
code: 'UNAUTHORIZED',
|
|
message: 'Your session refers to a user that no longer exists. Please log out and log back in.',
|
|
})
|
|
}
|
|
return user.id
|
|
}
|
|
|
|
/**
|
|
* Send the "you've been assigned to vote on this award" email to a set of
|
|
* jurors. Used by addJuror / bulkInviteJurors (auto-send on assignment) and
|
|
* by the explicit notifyJurors admin reminder. Errors per recipient are
|
|
* caught so a single SMTP failure doesn't break the bulk operation.
|
|
*/
|
|
async function sendAwardJurorEmails(
|
|
db: PrismaClient,
|
|
awardId: string,
|
|
userIds: string[],
|
|
options: { customMessage?: string; isReminder?: boolean } = {},
|
|
): Promise<{ sent: number; failed: number }> {
|
|
if (userIds.length === 0) return { sent: 0, failed: 0 }
|
|
|
|
const award = await db.specialAward.findUniqueOrThrow({
|
|
where: { id: awardId },
|
|
select: { id: true, name: true, votingEndAt: true },
|
|
})
|
|
const eligibleCount = await db.awardEligibility.count({
|
|
where: { awardId, eligible: true },
|
|
})
|
|
const users = await db.user.findMany({
|
|
where: { id: { in: userIds } },
|
|
select: { id: true, email: true, name: true },
|
|
})
|
|
|
|
const baseUrl = process.env.NEXTAUTH_URL || ''
|
|
const url = `${baseUrl}/jury/awards/${awardId}`
|
|
|
|
let sent = 0
|
|
let failed = 0
|
|
for (const u of users) {
|
|
try {
|
|
await sendAwardJurorNotificationEmail({
|
|
email: u.email,
|
|
name: u.name,
|
|
awardName: award.name,
|
|
url,
|
|
eligibleCount,
|
|
votingEndAt: award.votingEndAt,
|
|
customMessage: options.customMessage,
|
|
isReminder: options.isReminder,
|
|
})
|
|
sent++
|
|
} catch {
|
|
// Email failure shouldn't break the calling operation.
|
|
failed++
|
|
}
|
|
}
|
|
return { sent, failed }
|
|
}
|
|
|
|
export const specialAwardRouter = router({
|
|
// ─── Admin Queries ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* List awards for a program
|
|
*/
|
|
list: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
programId: z.string().optional(),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
return ctx.prisma.specialAward.findMany({
|
|
where: input.programId ? { programId: input.programId } : {},
|
|
orderBy: { sortOrder: 'asc' },
|
|
include: {
|
|
_count: {
|
|
select: {
|
|
eligibilities: { where: { eligible: true } },
|
|
jurors: true,
|
|
votes: true,
|
|
},
|
|
},
|
|
winnerProject: {
|
|
select: { id: true, title: true, teamName: true },
|
|
},
|
|
},
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* Get award detail with stats
|
|
*/
|
|
get: protectedProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
where: { id: input.id },
|
|
include: {
|
|
_count: {
|
|
select: {
|
|
eligibilities: { where: { eligible: true } },
|
|
jurors: true,
|
|
votes: true,
|
|
},
|
|
},
|
|
winnerProject: {
|
|
select: { id: true, title: true, teamName: true },
|
|
},
|
|
program: {
|
|
select: { id: true, name: true, year: true },
|
|
},
|
|
competition: {
|
|
select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } },
|
|
},
|
|
evaluationRound: {
|
|
select: { id: true, name: true, roundType: true },
|
|
},
|
|
awardJuryGroup: {
|
|
select: { id: true, name: true },
|
|
},
|
|
},
|
|
})
|
|
|
|
// Auto-resolve competition if missing (legacy awards created without competitionId)
|
|
let { competition } = award
|
|
if (!competition && award.programId) {
|
|
const comp = await ctx.prisma.competition.findFirst({
|
|
where: { programId: award.programId },
|
|
orderBy: { createdAt: 'desc' },
|
|
select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' as const } } },
|
|
})
|
|
if (comp) {
|
|
competition = comp
|
|
}
|
|
}
|
|
|
|
// Count eligible projects and total assessed
|
|
const [eligibleCount, totalAssessed] = await Promise.all([
|
|
ctx.prisma.awardEligibility.count({
|
|
where: { awardId: input.id, eligible: true },
|
|
}),
|
|
ctx.prisma.awardEligibility.count({
|
|
where: { awardId: input.id },
|
|
}),
|
|
])
|
|
|
|
return { ...award, competition, eligibleCount, totalAssessed }
|
|
}),
|
|
|
|
// ─── Admin Mutations ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Create award
|
|
*/
|
|
create: adminProcedure
|
|
.input(
|
|
z.object({
|
|
programId: z.string(),
|
|
name: z.string().min(1),
|
|
description: z.string().optional(),
|
|
criteriaText: z.string().optional(),
|
|
useAiEligibility: z.boolean().optional(),
|
|
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']),
|
|
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
|
competitionId: z.string().optional(),
|
|
evaluationRoundId: z.string().optional(),
|
|
juryGroupId: z.string().optional(),
|
|
eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).optional(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
// Auto-resolve competitionId from program if not provided
|
|
let competitionId = input.competitionId
|
|
if (!competitionId) {
|
|
const comp = await ctx.prisma.competition.findFirst({
|
|
where: { programId: input.programId },
|
|
orderBy: { createdAt: 'desc' },
|
|
select: { id: true },
|
|
})
|
|
competitionId = comp?.id ?? undefined
|
|
}
|
|
|
|
const maxOrder = await ctx.prisma.specialAward.aggregate({
|
|
where: { programId: input.programId },
|
|
_max: { sortOrder: true },
|
|
})
|
|
|
|
const award = await ctx.prisma.specialAward.create({
|
|
data: {
|
|
programId: input.programId,
|
|
name: input.name,
|
|
description: input.description,
|
|
criteriaText: input.criteriaText,
|
|
useAiEligibility: input.useAiEligibility ?? true,
|
|
scoringMode: input.scoringMode,
|
|
maxRankedPicks: input.maxRankedPicks,
|
|
competitionId,
|
|
evaluationRoundId: input.evaluationRoundId,
|
|
juryGroupId: input.juryGroupId,
|
|
eligibilityMode: input.eligibilityMode,
|
|
sortOrder: (maxOrder._max.sortOrder || 0) + 1,
|
|
},
|
|
})
|
|
|
|
// Audit outside transaction so failures don't roll back the create
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'CREATE',
|
|
entityType: 'SpecialAward',
|
|
entityId: award.id,
|
|
detailsJson: { name: input.name, scoringMode: input.scoringMode },
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return award
|
|
}),
|
|
|
|
/**
|
|
* Update award config
|
|
*/
|
|
update: adminProcedure
|
|
.input(
|
|
z.object({
|
|
id: z.string(),
|
|
name: z.string().min(1).optional(),
|
|
description: z.string().optional(),
|
|
criteriaText: z.string().optional(),
|
|
useAiEligibility: z.boolean().optional(),
|
|
scoringMode: z.enum(['PICK_WINNER', 'RANKED', 'SCORED']).optional(),
|
|
maxRankedPicks: z.number().int().min(1).max(20).optional(),
|
|
votingStartAt: z.date().optional(),
|
|
votingEndAt: z.date().optional(),
|
|
competitionId: z.string().nullable().optional(),
|
|
evaluationRoundId: z.string().nullable().optional(),
|
|
juryGroupId: z.string().nullable().optional(),
|
|
eligibilityMode: z.enum(['STAY_IN_MAIN', 'SEPARATE_POOL']).optional(),
|
|
decisionMode: z.enum(['JURY_VOTE', 'ADMIN_DECISION']).nullable().optional(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const { id, ...rest } = input
|
|
|
|
// Auto-resolve competitionId if missing on existing award
|
|
if (rest.competitionId === undefined) {
|
|
const existing = await ctx.prisma.specialAward.findUnique({
|
|
where: { id },
|
|
select: { competitionId: true, programId: true },
|
|
})
|
|
if (existing && !existing.competitionId) {
|
|
const comp = await ctx.prisma.competition.findFirst({
|
|
where: { programId: existing.programId },
|
|
orderBy: { createdAt: 'desc' },
|
|
select: { id: true },
|
|
})
|
|
if (comp) rest.competitionId = comp.id
|
|
}
|
|
}
|
|
|
|
const award = await ctx.prisma.specialAward.update({
|
|
where: { id },
|
|
data: rest,
|
|
})
|
|
|
|
await logAudit({
|
|
userId: ctx.user.id,
|
|
action: 'UPDATE',
|
|
entityType: 'SpecialAward',
|
|
entityId: id,
|
|
})
|
|
|
|
return award
|
|
}),
|
|
|
|
/**
|
|
* Delete award
|
|
*/
|
|
delete: adminProcedure
|
|
.input(z.object({ id: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
await ctx.prisma.specialAward.delete({ where: { id: input.id } })
|
|
|
|
// Audit outside transaction so failures don't break the delete
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'DELETE',
|
|
entityType: 'SpecialAward',
|
|
entityId: input.id,
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* Update award status
|
|
*/
|
|
updateStatus: adminProcedure
|
|
.input(
|
|
z.object({
|
|
id: z.string(),
|
|
status: z.enum([
|
|
'DRAFT',
|
|
'NOMINATIONS_OPEN',
|
|
'VOTING_OPEN',
|
|
'CLOSED',
|
|
'ARCHIVED',
|
|
]),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const current = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
where: { id: input.id },
|
|
select: { status: true, votingStartAt: true, votingEndAt: true },
|
|
})
|
|
|
|
const now = new Date()
|
|
|
|
// When opening voting, auto-set votingStartAt to now if it's in the future or not set
|
|
let votingStartAtUpdated = false
|
|
const updateData: Parameters<typeof ctx.prisma.specialAward.update>[0]['data'] = {
|
|
status: input.status,
|
|
}
|
|
|
|
if (input.status === 'VOTING_OPEN' && current.status !== 'VOTING_OPEN') {
|
|
// If no voting start date, or if it's in the future, set it to 1 minute ago
|
|
// to ensure voting is immediately open (avoids race condition with page render)
|
|
if (!current.votingStartAt || current.votingStartAt > now) {
|
|
const oneMinuteAgo = new Date(now.getTime() - 60 * 1000)
|
|
updateData.votingStartAt = oneMinuteAgo
|
|
votingStartAtUpdated = true
|
|
}
|
|
}
|
|
|
|
const award = await ctx.prisma.specialAward.update({
|
|
where: { id: input.id },
|
|
data: updateData,
|
|
})
|
|
|
|
// Audit outside transaction so failures don't break the status update
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'UPDATE_STATUS',
|
|
entityType: 'SpecialAward',
|
|
entityId: input.id,
|
|
detailsJson: {
|
|
previousStatus: current.status,
|
|
newStatus: input.status,
|
|
...(votingStartAtUpdated && {
|
|
votingStartAtUpdated: true,
|
|
previousVotingStartAt: current.votingStartAt,
|
|
newVotingStartAt: now,
|
|
}),
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return award
|
|
}),
|
|
|
|
// ─── Eligibility ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Run auto-tag + AI eligibility
|
|
*/
|
|
runEligibility: adminProcedure
|
|
.input(z.object({
|
|
awardId: z.string(),
|
|
includeSubmitted: z.boolean().optional(),
|
|
}))
|
|
.mutation(async ({ ctx, input }) => {
|
|
// Set job status to PENDING immediately
|
|
await ctx.prisma.specialAward.update({
|
|
where: { id: input.awardId },
|
|
data: {
|
|
eligibilityJobStatus: 'PENDING',
|
|
eligibilityJobTotal: null,
|
|
eligibilityJobDone: null,
|
|
eligibilityJobError: null,
|
|
eligibilityJobStarted: null,
|
|
},
|
|
})
|
|
|
|
await logAudit({
|
|
userId: ctx.user.id,
|
|
action: 'UPDATE',
|
|
entityType: 'SpecialAward',
|
|
entityId: input.awardId,
|
|
detailsJson: { action: 'RUN_ELIGIBILITY_STARTED' },
|
|
})
|
|
|
|
// Fire and forget - process in background
|
|
processEligibilityJob(
|
|
input.awardId,
|
|
input.includeSubmitted ?? false,
|
|
ctx.user.id
|
|
).catch((err) => {
|
|
console.error('[SpecialAward] processEligibilityJob failed:', err)
|
|
})
|
|
|
|
return { started: true }
|
|
}),
|
|
|
|
/**
|
|
* Get eligibility job status for polling
|
|
*/
|
|
getEligibilityJobStatus: protectedProcedure
|
|
.input(z.object({ awardId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
where: { id: input.awardId },
|
|
select: {
|
|
eligibilityJobStatus: true,
|
|
eligibilityJobTotal: true,
|
|
eligibilityJobDone: true,
|
|
eligibilityJobError: true,
|
|
eligibilityJobStarted: true,
|
|
},
|
|
})
|
|
return award
|
|
}),
|
|
|
|
/**
|
|
* List eligible projects
|
|
*/
|
|
listEligible: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
awardId: z.string(),
|
|
eligibleOnly: z.boolean().default(false),
|
|
page: z.number().int().min(1).default(1),
|
|
perPage: z.number().int().min(1).max(100).default(50),
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const { awardId, eligibleOnly, page, perPage } = input
|
|
const skip = (page - 1) * perPage
|
|
|
|
const where: Record<string, unknown> = { awardId }
|
|
if (eligibleOnly) where.eligible = true
|
|
|
|
const [eligibilities, total] = await Promise.all([
|
|
ctx.prisma.awardEligibility.findMany({
|
|
where,
|
|
skip,
|
|
take: perPage,
|
|
include: {
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
teamName: true,
|
|
competitionCategory: true,
|
|
country: true,
|
|
tags: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: { project: { title: 'asc' } },
|
|
}),
|
|
ctx.prisma.awardEligibility.count({ where }),
|
|
])
|
|
|
|
return { eligibilities, total, page, perPage, totalPages: Math.ceil(total / perPage) }
|
|
}),
|
|
|
|
/**
|
|
* Manual eligibility override
|
|
*/
|
|
setEligibility: adminProcedure
|
|
.input(
|
|
z.object({
|
|
awardId: z.string(),
|
|
projectId: z.string(),
|
|
eligible: z.boolean(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const verifiedUserId = await ensureUserExists(ctx.prisma, ctx.user.id)
|
|
|
|
await ctx.prisma.awardEligibility.upsert({
|
|
where: {
|
|
awardId_projectId: {
|
|
awardId: input.awardId,
|
|
projectId: input.projectId,
|
|
},
|
|
},
|
|
create: {
|
|
award: { connect: { id: input.awardId } },
|
|
project: { connect: { id: input.projectId } },
|
|
eligible: input.eligible,
|
|
method: 'MANUAL',
|
|
overriddenByUser: { connect: { id: verifiedUserId } },
|
|
overriddenAt: new Date(),
|
|
},
|
|
update: {
|
|
eligible: input.eligible,
|
|
overriddenByUser: { connect: { id: verifiedUserId } },
|
|
overriddenAt: new Date(),
|
|
},
|
|
})
|
|
}),
|
|
|
|
// ─── Jurors ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* List jurors for an award
|
|
*/
|
|
listJurors: protectedProcedure
|
|
.input(z.object({ awardId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const jurors = await ctx.prisma.awardJuror.findMany({
|
|
where: { awardId: input.awardId },
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
role: true,
|
|
profileImageKey: true,
|
|
profileImageProvider: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
return Promise.all(
|
|
jurors.map(async (j) => ({
|
|
...j,
|
|
user: {
|
|
...j.user,
|
|
avatarUrl: await getUserAvatarUrl(j.user.profileImageKey, j.user.profileImageProvider),
|
|
},
|
|
}))
|
|
)
|
|
}),
|
|
|
|
/**
|
|
* Add juror
|
|
*/
|
|
addJuror: adminProcedure
|
|
.input(
|
|
z.object({
|
|
awardId: z.string(),
|
|
userId: z.string(),
|
|
sendEmail: z.boolean().default(true),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const created = await ctx.prisma.awardJuror.create({
|
|
data: {
|
|
awardId: input.awardId,
|
|
userId: input.userId,
|
|
},
|
|
})
|
|
|
|
if (input.sendEmail) {
|
|
await sendAwardJurorEmails(ctx.prisma, input.awardId, [input.userId])
|
|
}
|
|
|
|
return created
|
|
}),
|
|
|
|
/**
|
|
* Remove juror
|
|
*/
|
|
removeJuror: adminProcedure
|
|
.input(
|
|
z.object({
|
|
awardId: z.string(),
|
|
userId: z.string(),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
await ctx.prisma.awardJuror.delete({
|
|
where: {
|
|
awardId_userId: {
|
|
awardId: input.awardId,
|
|
userId: input.userId,
|
|
},
|
|
},
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* Bulk add jurors
|
|
*/
|
|
bulkAddJurors: adminProcedure
|
|
.input(
|
|
z.object({
|
|
awardId: z.string(),
|
|
userIds: z.array(z.string()),
|
|
sendEmail: z.boolean().default(true),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const existingRows = await ctx.prisma.awardJuror.findMany({
|
|
where: { awardId: input.awardId, userId: { in: input.userIds } },
|
|
select: { userId: true },
|
|
})
|
|
const existing = new Set(existingRows.map((r) => r.userId))
|
|
const newlyAddedIds = input.userIds.filter((id) => !existing.has(id))
|
|
|
|
const data = newlyAddedIds.map((userId) => ({
|
|
awardId: input.awardId,
|
|
userId,
|
|
}))
|
|
|
|
if (data.length > 0) {
|
|
await ctx.prisma.awardJuror.createMany({
|
|
data,
|
|
skipDuplicates: true,
|
|
})
|
|
}
|
|
|
|
let emailStats = { sent: 0, failed: 0 }
|
|
if (input.sendEmail && newlyAddedIds.length > 0) {
|
|
emailStats = await sendAwardJurorEmails(ctx.prisma, input.awardId, newlyAddedIds)
|
|
}
|
|
|
|
return { added: newlyAddedIds.length, ...emailStats }
|
|
}),
|
|
|
|
/**
|
|
* Bulk invite new users as award jurors. Creates JURY_MEMBER accounts,
|
|
* attaches them as AwardJuror, and (if the award has an assigned jury
|
|
* group) also adds them as JuryGroupMember so they appear on the
|
|
* round-page jury panel alongside existing members.
|
|
*/
|
|
bulkInviteJurors: adminProcedure
|
|
.input(
|
|
z.object({
|
|
awardId: z.string(),
|
|
invitees: z.array(
|
|
z.object({
|
|
name: z.string().optional(),
|
|
email: z.string().email(),
|
|
})
|
|
).min(1).max(50),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
where: { id: input.awardId },
|
|
select: { id: true, name: true, juryGroupId: true },
|
|
})
|
|
|
|
const results: Array<{ email: string; status: 'created' | 'existing' | 'error'; error?: string }> = []
|
|
|
|
for (const invitee of input.invitees) {
|
|
try {
|
|
let user = await ctx.prisma.user.findUnique({
|
|
where: { email: invitee.email },
|
|
select: { id: true, status: true, role: true },
|
|
})
|
|
|
|
if (!user) {
|
|
const inviteToken = generateInviteToken()
|
|
const expiryMs = await getInviteExpiryMs(ctx.prisma)
|
|
|
|
user = await ctx.prisma.user.create({
|
|
data: {
|
|
email: invitee.email,
|
|
name: invitee.name || null,
|
|
role: 'JURY_MEMBER',
|
|
status: 'INVITED',
|
|
inviteToken,
|
|
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
|
},
|
|
select: { id: true, status: true, role: true },
|
|
})
|
|
|
|
const inviteUrl = `${process.env.NEXTAUTH_URL}/accept-invite?token=${inviteToken}`
|
|
try {
|
|
await sendJuryInvitationEmail(
|
|
invitee.email,
|
|
invitee.name || null,
|
|
inviteUrl,
|
|
award.name
|
|
)
|
|
} catch {
|
|
// Email failure shouldn't block the invite
|
|
}
|
|
|
|
results.push({ email: invitee.email, status: 'created' })
|
|
} else {
|
|
results.push({ email: invitee.email, status: 'existing' })
|
|
}
|
|
|
|
const priorAttachment = await ctx.prisma.awardJuror.findUnique({
|
|
where: {
|
|
awardId_userId: { awardId: input.awardId, userId: user.id },
|
|
},
|
|
select: { id: true },
|
|
})
|
|
|
|
await ctx.prisma.awardJuror.upsert({
|
|
where: {
|
|
awardId_userId: { awardId: input.awardId, userId: user.id },
|
|
},
|
|
update: {},
|
|
create: { awardId: input.awardId, userId: user.id },
|
|
})
|
|
|
|
if (award.juryGroupId) {
|
|
await ctx.prisma.juryGroupMember.upsert({
|
|
where: {
|
|
juryGroupId_userId: {
|
|
juryGroupId: award.juryGroupId,
|
|
userId: user.id,
|
|
},
|
|
},
|
|
update: {},
|
|
create: {
|
|
juryGroupId: award.juryGroupId,
|
|
userId: user.id,
|
|
role: 'MEMBER',
|
|
},
|
|
})
|
|
}
|
|
|
|
// For existing-user invitees the new-account invite email above
|
|
// never fired (no `created` branch). Send the juror-assignment
|
|
// notification so they know they were added — but only if this
|
|
// call actually attached them (skip duplicate "Bulk Invite" clicks
|
|
// to avoid spam).
|
|
const lastResult = results[results.length - 1]
|
|
if (lastResult?.status === 'existing' && !priorAttachment) {
|
|
await sendAwardJurorEmails(ctx.prisma, input.awardId, [user.id])
|
|
}
|
|
} catch (err) {
|
|
results.push({
|
|
email: invitee.email,
|
|
status: 'error',
|
|
error: err instanceof Error ? err.message : 'Unknown error',
|
|
})
|
|
}
|
|
}
|
|
|
|
await logAudit({
|
|
userId: ctx.user.id,
|
|
action: 'CREATE',
|
|
entityType: 'AwardJuror',
|
|
entityId: input.awardId,
|
|
detailsJson: {
|
|
action: 'BULK_INVITE',
|
|
awardName: award.name,
|
|
juryGroupId: award.juryGroupId,
|
|
count: input.invitees.length,
|
|
results,
|
|
},
|
|
})
|
|
|
|
return {
|
|
created: results.filter((r) => r.status === 'created').length,
|
|
existing: results.filter((r) => r.status === 'existing').length,
|
|
errors: results.filter((r) => r.status === 'error').length,
|
|
results,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Send a reminder email to currently-assigned jurors. Pass `userIds` to
|
|
* target a subset, omit to email every juror on the award. The email links
|
|
* the juror straight to the voting page.
|
|
*/
|
|
notifyJurors: adminProcedure
|
|
.input(
|
|
z.object({
|
|
awardId: z.string(),
|
|
userIds: z.array(z.string()).optional(),
|
|
customMessage: z.string().max(1000).optional(),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const jurors = await ctx.prisma.awardJuror.findMany({
|
|
where: {
|
|
awardId: input.awardId,
|
|
...(input.userIds && input.userIds.length > 0
|
|
? { userId: { in: input.userIds } }
|
|
: {}),
|
|
},
|
|
select: { userId: true },
|
|
})
|
|
|
|
const targetIds = jurors.map((j) => j.userId)
|
|
const stats = await sendAwardJurorEmails(ctx.prisma, input.awardId, targetIds, {
|
|
customMessage: input.customMessage,
|
|
isReminder: true,
|
|
})
|
|
|
|
await logAudit({
|
|
userId: ctx.user.id,
|
|
action: 'NOTIFY',
|
|
entityType: 'AwardJuror',
|
|
entityId: input.awardId,
|
|
detailsJson: {
|
|
action: 'JUROR_REMINDER',
|
|
targetUserIds: targetIds,
|
|
...stats,
|
|
},
|
|
})
|
|
|
|
return { targeted: targetIds.length, ...stats }
|
|
}),
|
|
|
|
// ─── Jury Queries ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get awards where current user is a juror
|
|
*/
|
|
getMyAwards: protectedProcedure.query(async ({ ctx }) => {
|
|
const jurorships = await ctx.prisma.awardJuror.findMany({
|
|
where: { userId: ctx.user.id },
|
|
include: {
|
|
award: {
|
|
include: {
|
|
_count: {
|
|
select: { eligibilities: { where: { eligible: true } } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
return jurorships.map((j) => j.award)
|
|
}),
|
|
|
|
/**
|
|
* Get award detail for voting (jury view). Includes upstream evaluation
|
|
* scores per project (when the award is anchored to an evaluation round)
|
|
* and — for the chair — the votes cast by other jurors so they can
|
|
* tie-break and finalize the award.
|
|
*/
|
|
getMyAwardDetail: protectedProcedure
|
|
.input(z.object({ awardId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const juror = await ctx.prisma.awardJuror.findUnique({
|
|
where: {
|
|
awardId_userId: {
|
|
awardId: input.awardId,
|
|
userId: ctx.user.id,
|
|
},
|
|
},
|
|
})
|
|
|
|
if (!juror) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'You are not a juror for this award',
|
|
})
|
|
}
|
|
|
|
const [award, eligibleProjects, myVotes, allJurors] = await Promise.all([
|
|
ctx.prisma.specialAward.findUniqueOrThrow({
|
|
where: { id: input.awardId },
|
|
}),
|
|
ctx.prisma.awardEligibility.findMany({
|
|
where: { awardId: input.awardId, eligible: true },
|
|
include: {
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
teamName: true,
|
|
description: true,
|
|
competitionCategory: true,
|
|
country: true,
|
|
tags: true,
|
|
logoKey: true,
|
|
logoProvider: true,
|
|
files: {
|
|
where: { replacedById: null },
|
|
select: {
|
|
id: true,
|
|
fileName: true,
|
|
fileType: true,
|
|
mimeType: true,
|
|
size: true,
|
|
bucket: true,
|
|
objectKey: true,
|
|
version: true,
|
|
isLate: true,
|
|
pageCount: true,
|
|
detectedLang: true,
|
|
langConfidence: true,
|
|
analyzedAt: true,
|
|
createdAt: true,
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
},
|
|
teamMembers: {
|
|
select: {
|
|
id: true,
|
|
role: true,
|
|
user: { select: { name: true, email: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
ctx.prisma.awardVote.findMany({
|
|
where: { awardId: input.awardId, userId: ctx.user.id },
|
|
}),
|
|
ctx.prisma.awardJuror.findMany({
|
|
where: { awardId: input.awardId },
|
|
select: { userId: true, isChair: true, user: { select: { name: true } } },
|
|
}),
|
|
])
|
|
|
|
const projectIds = eligibleProjects.map((e) => e.project.id)
|
|
const projectScores: Record<string, { avg: number; count: number }> = {}
|
|
|
|
if (award.evaluationRoundId) {
|
|
const evaluations = await ctx.prisma.evaluation.findMany({
|
|
where: {
|
|
status: 'SUBMITTED',
|
|
assignment: {
|
|
roundId: award.evaluationRoundId,
|
|
projectId: { in: projectIds },
|
|
},
|
|
},
|
|
select: {
|
|
globalScore: true,
|
|
assignment: { select: { projectId: true } },
|
|
},
|
|
})
|
|
|
|
const scoreMap = new Map<string, number[]>()
|
|
for (const ev of evaluations) {
|
|
if (ev.globalScore !== null) {
|
|
const pid = ev.assignment.projectId
|
|
if (!scoreMap.has(pid)) scoreMap.set(pid, [])
|
|
scoreMap.get(pid)!.push(ev.globalScore)
|
|
}
|
|
}
|
|
for (const [pid, scores] of scoreMap) {
|
|
projectScores[pid] = {
|
|
avg: scores.reduce((a, b) => a + b, 0) / scores.length,
|
|
count: scores.length,
|
|
}
|
|
}
|
|
}
|
|
|
|
const isSolo = allJurors.length === 1
|
|
const isChair = juror.isChair || isSolo
|
|
let otherVotes: Array<{ userId: string; userName: string | null; projectId: string; justification: string | null }> = []
|
|
if (isChair && !isSolo) {
|
|
const votes = await ctx.prisma.awardVote.findMany({
|
|
where: { awardId: input.awardId, userId: { not: ctx.user.id } },
|
|
select: {
|
|
userId: true, projectId: true, justification: true,
|
|
user: { select: { name: true } },
|
|
},
|
|
})
|
|
otherVotes = votes.map((v) => ({
|
|
userId: v.userId,
|
|
userName: v.user.name,
|
|
projectId: v.projectId,
|
|
justification: v.justification,
|
|
}))
|
|
}
|
|
|
|
const projectsWithScores = eligibleProjects.map((e) => ({
|
|
...e.project,
|
|
evaluationScore: projectScores[e.project.id] ?? null,
|
|
}))
|
|
const projectsWithLogos = await attachProjectLogoUrls(projectsWithScores)
|
|
|
|
return {
|
|
award,
|
|
projects: projectsWithLogos,
|
|
myVotes,
|
|
isChair,
|
|
otherVotes,
|
|
totalJurors: allJurors.length,
|
|
jurors: allJurors.map((j) => ({ userId: j.userId, name: j.user.name, isChair: j.isChair })),
|
|
}
|
|
}),
|
|
|
|
// ─── Voting ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Submit vote (PICK_WINNER or RANKED). For PICK_WINNER, jurors may attach
|
|
* an optional justification — visible to the chair when they review votes.
|
|
*/
|
|
submitVote: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
awardId: z.string(),
|
|
votes: z.array(
|
|
z.object({
|
|
projectId: z.string(),
|
|
rank: z.number().int().min(1).optional(),
|
|
justification: z.string().max(2000).optional(),
|
|
})
|
|
),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const juror = await ctx.prisma.awardJuror.findUnique({
|
|
where: {
|
|
awardId_userId: {
|
|
awardId: input.awardId,
|
|
userId: ctx.user.id,
|
|
},
|
|
},
|
|
})
|
|
|
|
if (!juror) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'You are not a juror for this award',
|
|
})
|
|
}
|
|
|
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
where: { id: input.awardId },
|
|
})
|
|
|
|
if (award.status !== 'VOTING_OPEN') {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Voting is not currently open for this award',
|
|
})
|
|
}
|
|
|
|
await ctx.prisma.$transaction([
|
|
ctx.prisma.awardVote.deleteMany({
|
|
where: { awardId: input.awardId, userId: ctx.user.id },
|
|
}),
|
|
...input.votes.map((vote) =>
|
|
ctx.prisma.awardVote.create({
|
|
data: {
|
|
awardId: input.awardId,
|
|
userId: ctx.user.id,
|
|
projectId: vote.projectId,
|
|
rank: vote.rank,
|
|
justification: vote.justification || null,
|
|
},
|
|
})
|
|
),
|
|
])
|
|
|
|
await logAudit({
|
|
userId: ctx.user.id,
|
|
action: 'CREATE',
|
|
entityType: 'AwardVote',
|
|
entityId: input.awardId,
|
|
detailsJson: {
|
|
awardId: input.awardId,
|
|
voteCount: input.votes.length,
|
|
scoringMode: award.scoringMode,
|
|
},
|
|
})
|
|
|
|
return { submitted: input.votes.length }
|
|
}),
|
|
|
|
// ─── Results ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get aggregated vote results
|
|
*/
|
|
getVoteResults: adminProcedure
|
|
.input(z.object({ awardId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const [award, votes, jurorCount] = await Promise.all([
|
|
ctx.prisma.specialAward.findUniqueOrThrow({
|
|
where: { id: input.awardId },
|
|
}),
|
|
ctx.prisma.awardVote.findMany({
|
|
where: { awardId: input.awardId },
|
|
include: {
|
|
project: {
|
|
select: { id: true, title: true, teamName: true },
|
|
},
|
|
user: {
|
|
select: { id: true, name: true, email: true },
|
|
},
|
|
},
|
|
}),
|
|
ctx.prisma.awardJuror.count({
|
|
where: { awardId: input.awardId },
|
|
}),
|
|
])
|
|
|
|
const votedJurorCount = new Set(votes.map((v) => v.userId)).size
|
|
|
|
// Tally by scoring mode
|
|
const projectTallies = new Map<
|
|
string,
|
|
{ project: { id: string; title: string; teamName: string | null }; votes: number; points: number }
|
|
>()
|
|
|
|
for (const vote of votes) {
|
|
const existing = projectTallies.get(vote.projectId) || {
|
|
project: vote.project,
|
|
votes: 0,
|
|
points: 0,
|
|
}
|
|
existing.votes += 1
|
|
if (award.scoringMode === 'RANKED' && vote.rank) {
|
|
existing.points += (award.maxRankedPicks || 5) - vote.rank + 1
|
|
} else {
|
|
existing.points += 1
|
|
}
|
|
projectTallies.set(vote.projectId, existing)
|
|
}
|
|
|
|
const ranked = Array.from(projectTallies.values()).sort(
|
|
(a, b) => b.points - a.points
|
|
)
|
|
|
|
return {
|
|
scoringMode: award.scoringMode,
|
|
jurorCount,
|
|
votedJurorCount,
|
|
results: ranked,
|
|
winnerId: award.winnerProjectId,
|
|
winnerOverridden: award.winnerOverridden,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Set/override winner
|
|
*/
|
|
setWinner: adminProcedure
|
|
.input(
|
|
z.object({
|
|
awardId: z.string(),
|
|
projectId: z.string(),
|
|
overridden: z.boolean().default(false),
|
|
})
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
const previous = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
where: { id: input.awardId },
|
|
select: { winnerProjectId: true },
|
|
})
|
|
|
|
const award = await ctx.prisma.specialAward.update({
|
|
where: { id: input.awardId },
|
|
data: {
|
|
winnerProjectId: input.projectId,
|
|
winnerOverridden: input.overridden,
|
|
winnerOverriddenBy: input.overridden ? ctx.user.id : null,
|
|
},
|
|
})
|
|
|
|
// Audit outside transaction so failures don't break the winner update
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'UPDATE',
|
|
entityType: 'SpecialAward',
|
|
entityId: input.awardId,
|
|
detailsJson: {
|
|
action: 'SET_AWARD_WINNER',
|
|
previousWinner: previous.winnerProjectId,
|
|
newWinner: input.projectId,
|
|
overridden: input.overridden,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return award
|
|
}),
|
|
|
|
/**
|
|
* Chair confirms the winner — resolves tiebreaks, sets winner, closes the award
|
|
*/
|
|
confirmWinner: protectedProcedure
|
|
.input(z.object({ awardId: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const allJurors = await ctx.prisma.awardJuror.findMany({
|
|
where: { awardId: input.awardId },
|
|
select: { userId: true, isChair: true },
|
|
})
|
|
const myJuror = allJurors.find((j) => j.userId === ctx.user.id)
|
|
if (!myJuror) {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Not assigned to this award' })
|
|
}
|
|
|
|
const isSolo = allJurors.length === 1
|
|
if (!myJuror.isChair && !isSolo) {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Only the chair can confirm the winner' })
|
|
}
|
|
|
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
where: { id: input.awardId },
|
|
})
|
|
if (award.status !== 'VOTING_OPEN') {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Award must be in VOTING_OPEN status' })
|
|
}
|
|
|
|
const chairVote = await ctx.prisma.awardVote.findFirst({
|
|
where: { awardId: input.awardId, userId: ctx.user.id },
|
|
})
|
|
if (!chairVote) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'You must vote before confirming' })
|
|
}
|
|
|
|
const allVotes = await ctx.prisma.awardVote.findMany({
|
|
where: { awardId: input.awardId },
|
|
select: { projectId: true, userId: true },
|
|
})
|
|
|
|
const winnerId = resolveAwardWinner(allVotes, ctx.user.id)
|
|
|
|
await ctx.prisma.specialAward.update({
|
|
where: { id: input.awardId },
|
|
data: {
|
|
winnerProjectId: winnerId,
|
|
status: 'CLOSED',
|
|
winnerOverridden: false,
|
|
winnerOverriddenBy: null,
|
|
},
|
|
})
|
|
|
|
await logAudit({
|
|
userId: ctx.user.id,
|
|
action: 'UPDATE',
|
|
entityType: 'SpecialAward',
|
|
entityId: input.awardId,
|
|
detailsJson: {
|
|
action: 'CONFIRM_WINNER',
|
|
winnerId,
|
|
totalVotes: allVotes.length,
|
|
confirmedBy: ctx.user.id,
|
|
},
|
|
})
|
|
|
|
return { winnerId, closed: true }
|
|
}),
|
|
|
|
/**
|
|
* Admin: set/unset chair status for an award juror (only one chair per award)
|
|
*/
|
|
setChair: adminProcedure
|
|
.input(z.object({
|
|
awardId: z.string(),
|
|
userId: z.string(),
|
|
isChair: z.boolean(),
|
|
}))
|
|
.mutation(async ({ ctx, input }) => {
|
|
if (input.isChair) {
|
|
await ctx.prisma.awardJuror.updateMany({
|
|
where: { awardId: input.awardId, isChair: true },
|
|
data: { isChair: false },
|
|
})
|
|
}
|
|
|
|
await ctx.prisma.awardJuror.update({
|
|
where: { awardId_userId: { awardId: input.awardId, userId: input.userId } },
|
|
data: { isChair: input.isChair },
|
|
})
|
|
|
|
await logAudit({
|
|
userId: ctx.user.id,
|
|
action: 'UPDATE',
|
|
entityType: 'AwardJuror',
|
|
entityId: `${input.awardId}:${input.userId}`,
|
|
detailsJson: { action: 'SET_CHAIR', isChair: input.isChair },
|
|
})
|
|
|
|
return { success: true }
|
|
}),
|
|
|
|
// ─── Round-Scoped Eligibility & Shortlists ──────────────────────────────
|
|
|
|
/**
|
|
* List awards for a competition (from a filtering round context)
|
|
*/
|
|
listForRound: protectedProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
// Get competition from round
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
select: { competitionId: true },
|
|
})
|
|
|
|
const awards = await ctx.prisma.specialAward.findMany({
|
|
where: { competitionId: round.competitionId },
|
|
orderBy: { sortOrder: 'asc' },
|
|
include: {
|
|
_count: {
|
|
select: {
|
|
eligibilities: { where: { eligible: true } },
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Get shortlisted counts
|
|
const shortlistedCounts = await ctx.prisma.awardEligibility.groupBy({
|
|
by: ['awardId'],
|
|
where: {
|
|
awardId: { in: awards.map((a) => a.id) },
|
|
shortlisted: true,
|
|
},
|
|
_count: true,
|
|
})
|
|
const shortlistMap = new Map(shortlistedCounts.map((s) => [s.awardId, s._count]))
|
|
|
|
return awards.map((a) => ({
|
|
...a,
|
|
shortlistedCount: shortlistMap.get(a.id) ?? 0,
|
|
}))
|
|
}),
|
|
|
|
/**
|
|
* Run eligibility scoped to a filtering round's PASSED projects
|
|
*/
|
|
runEligibilityForRound: adminProcedure
|
|
.input(z.object({
|
|
awardId: z.string(),
|
|
roundId: z.string(),
|
|
}))
|
|
.mutation(async ({ ctx, input }) => {
|
|
// Set job status to PENDING
|
|
await ctx.prisma.specialAward.update({
|
|
where: { id: input.awardId },
|
|
data: {
|
|
eligibilityJobStatus: 'PENDING',
|
|
eligibilityJobTotal: null,
|
|
eligibilityJobDone: null,
|
|
eligibilityJobError: null,
|
|
eligibilityJobStarted: null,
|
|
},
|
|
})
|
|
|
|
await logAudit({
|
|
userId: ctx.user.id,
|
|
action: 'UPDATE',
|
|
entityType: 'SpecialAward',
|
|
entityId: input.awardId,
|
|
detailsJson: { action: 'RUN_ELIGIBILITY_FOR_ROUND', roundId: input.roundId },
|
|
})
|
|
|
|
// Fire and forget - process in background with round scoping
|
|
processEligibilityJob(
|
|
input.awardId,
|
|
true, // include submitted
|
|
ctx.user.id,
|
|
input.roundId
|
|
).catch((err) => {
|
|
console.error('[SpecialAward] processEligibilityJob (round) failed:', err)
|
|
})
|
|
|
|
return { started: true }
|
|
}),
|
|
|
|
/**
|
|
* Get ranked shortlist for an award
|
|
*/
|
|
listShortlist: protectedProcedure
|
|
.input(z.object({
|
|
awardId: z.string(),
|
|
page: z.number().int().min(1).default(1),
|
|
perPage: z.number().int().min(1).max(100).default(50),
|
|
}))
|
|
.query(async ({ ctx, input }) => {
|
|
const { awardId, page, perPage } = input
|
|
const skip = (page - 1) * perPage
|
|
|
|
const [eligibilities, total, award] = await Promise.all([
|
|
ctx.prisma.awardEligibility.findMany({
|
|
where: { awardId, eligible: true },
|
|
skip,
|
|
take: perPage,
|
|
include: {
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
teamName: true,
|
|
competitionCategory: true,
|
|
country: true,
|
|
tags: true,
|
|
description: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: { qualityScore: 'desc' },
|
|
}),
|
|
ctx.prisma.awardEligibility.count({ where: { awardId, eligible: true } }),
|
|
ctx.prisma.specialAward.findUniqueOrThrow({
|
|
where: { id: awardId },
|
|
select: { shortlistSize: true, eligibilityMode: true, name: true },
|
|
}),
|
|
])
|
|
|
|
return {
|
|
eligibilities,
|
|
total,
|
|
page,
|
|
perPage,
|
|
totalPages: Math.ceil(total / perPage),
|
|
shortlistSize: award.shortlistSize,
|
|
eligibilityMode: award.eligibilityMode,
|
|
awardName: award.name,
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* Toggle shortlisted status for a project-award pair
|
|
*/
|
|
toggleShortlisted: adminProcedure
|
|
.input(z.object({
|
|
awardId: z.string(),
|
|
projectId: z.string(),
|
|
}))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const current = await ctx.prisma.awardEligibility.findUniqueOrThrow({
|
|
where: {
|
|
awardId_projectId: {
|
|
awardId: input.awardId,
|
|
projectId: input.projectId,
|
|
},
|
|
},
|
|
select: { shortlisted: true },
|
|
})
|
|
|
|
const updated = await ctx.prisma.awardEligibility.update({
|
|
where: {
|
|
awardId_projectId: {
|
|
awardId: input.awardId,
|
|
projectId: input.projectId,
|
|
},
|
|
},
|
|
data: { shortlisted: !current.shortlisted },
|
|
})
|
|
|
|
return { shortlisted: updated.shortlisted }
|
|
}),
|
|
|
|
/**
|
|
* Bulk toggle shortlisted status for multiple projects
|
|
*/
|
|
bulkToggleShortlisted: adminProcedure
|
|
.input(z.object({
|
|
awardId: z.string(),
|
|
projectIds: z.array(z.string()),
|
|
shortlisted: z.boolean(),
|
|
}))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const result = await ctx.prisma.awardEligibility.updateMany({
|
|
where: {
|
|
awardId: input.awardId,
|
|
projectId: { in: input.projectIds },
|
|
eligible: true,
|
|
},
|
|
data: { shortlisted: input.shortlisted },
|
|
})
|
|
|
|
return { updated: result.count, shortlisted: input.shortlisted }
|
|
}),
|
|
|
|
/**
|
|
* Confirm shortlist — for SEPARATE_POOL awards, creates ProjectRoundState entries
|
|
*/
|
|
confirmShortlist: adminProcedure
|
|
.input(z.object({ awardId: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
where: { id: input.awardId },
|
|
select: { eligibilityMode: true, name: true, competitionId: true },
|
|
})
|
|
|
|
// Get shortlisted projects
|
|
const shortlisted = await ctx.prisma.awardEligibility.findMany({
|
|
where: { awardId: input.awardId, shortlisted: true, eligible: true },
|
|
select: { projectId: true },
|
|
})
|
|
|
|
if (shortlisted.length === 0) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'No shortlisted projects to confirm',
|
|
})
|
|
}
|
|
|
|
const verifiedUserId = await ensureUserExists(ctx.prisma, ctx.user.id)
|
|
|
|
// Mark all as confirmed
|
|
await ctx.prisma.awardEligibility.updateMany({
|
|
where: { awardId: input.awardId, shortlisted: true, eligible: true },
|
|
data: {
|
|
confirmedAt: new Date(),
|
|
confirmedBy: verifiedUserId,
|
|
},
|
|
})
|
|
|
|
// For SEPARATE_POOL: create ProjectRoundState entries in award rounds (if any exist)
|
|
let routedCount = 0
|
|
if (award.eligibilityMode === 'SEPARATE_POOL') {
|
|
const awardRounds = await ctx.prisma.round.findMany({
|
|
where: { specialAwardId: input.awardId },
|
|
select: { id: true },
|
|
orderBy: { sortOrder: 'asc' },
|
|
})
|
|
|
|
if (awardRounds.length > 0) {
|
|
const firstRound = awardRounds[0]
|
|
const projectIds = shortlisted.map((s) => s.projectId)
|
|
|
|
// Create ProjectRoundState entries for confirmed projects in the first award round
|
|
await ctx.prisma.projectRoundState.createMany({
|
|
data: projectIds.map((projectId) => ({
|
|
projectId,
|
|
roundId: firstRound.id,
|
|
state: 'PENDING' as const,
|
|
})),
|
|
skipDuplicates: true,
|
|
})
|
|
routedCount = projectIds.length
|
|
}
|
|
}
|
|
|
|
await logAudit({
|
|
userId: ctx.user.id,
|
|
action: 'UPDATE',
|
|
entityType: 'SpecialAward',
|
|
entityId: input.awardId,
|
|
detailsJson: {
|
|
action: 'CONFIRM_SHORTLIST',
|
|
confirmedCount: shortlisted.length,
|
|
eligibilityMode: award.eligibilityMode,
|
|
routedCount,
|
|
},
|
|
})
|
|
|
|
return {
|
|
confirmedCount: shortlisted.length,
|
|
routedCount,
|
|
eligibilityMode: award.eligibilityMode,
|
|
}
|
|
}),
|
|
|
|
// ─── Award Rounds ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* List rounds belonging to an award
|
|
*/
|
|
listRounds: protectedProcedure
|
|
.input(z.object({ awardId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
return ctx.prisma.round.findMany({
|
|
where: { specialAwardId: input.awardId },
|
|
orderBy: { sortOrder: 'asc' },
|
|
include: {
|
|
juryGroup: { select: { id: true, name: true } },
|
|
_count: {
|
|
select: {
|
|
projectRoundStates: true,
|
|
assignments: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* Create a round linked to an award
|
|
*/
|
|
createRound: adminProcedure
|
|
.input(z.object({
|
|
awardId: z.string(),
|
|
name: z.string().min(1),
|
|
roundType: z.enum(['INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING', 'LIVE_FINAL', 'DELIBERATION']).default('EVALUATION'),
|
|
}))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
where: { id: input.awardId },
|
|
select: { competitionId: true, name: true },
|
|
})
|
|
|
|
if (!award.competitionId) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Award must be linked to a competition before creating rounds',
|
|
})
|
|
}
|
|
|
|
// Get max sort order for this award's rounds
|
|
const maxOrder = await ctx.prisma.round.aggregate({
|
|
where: { specialAwardId: input.awardId },
|
|
_max: { sortOrder: true },
|
|
})
|
|
|
|
// Also need max sortOrder across the competition to avoid unique constraint conflicts
|
|
const maxCompOrder = await ctx.prisma.round.aggregate({
|
|
where: { competitionId: award.competitionId },
|
|
_max: { sortOrder: true },
|
|
})
|
|
|
|
const sortOrder = Math.max(
|
|
(maxOrder._max.sortOrder ?? 0) + 1,
|
|
(maxCompOrder._max.sortOrder ?? 0) + 1
|
|
)
|
|
|
|
const slug = `${award.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${input.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`
|
|
|
|
const round = await ctx.prisma.round.create({
|
|
data: {
|
|
competitionId: award.competitionId,
|
|
specialAwardId: input.awardId,
|
|
name: input.name,
|
|
slug,
|
|
roundType: input.roundType,
|
|
sortOrder,
|
|
},
|
|
})
|
|
|
|
await logAudit({
|
|
userId: ctx.user.id,
|
|
action: 'CREATE',
|
|
entityType: 'Round',
|
|
entityId: round.id,
|
|
detailsJson: { awardId: input.awardId, awardName: award.name, roundType: input.roundType },
|
|
})
|
|
|
|
return round
|
|
}),
|
|
|
|
// ─── Pool Notifications ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get account stats for eligible projects (how many need invite vs have account)
|
|
*/
|
|
getNotificationStats: adminProcedure
|
|
.input(z.object({ awardId: z.string() }))
|
|
.query(async ({ ctx, input }) => {
|
|
const eligibilities = await ctx.prisma.awardEligibility.findMany({
|
|
where: { awardId: input.awardId, eligible: true },
|
|
select: {
|
|
project: {
|
|
select: {
|
|
submittedBy: { select: { id: true, passwordHash: true } },
|
|
teamMembers: {
|
|
select: { user: { select: { id: true, passwordHash: true } } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
const seen = new Set<string>()
|
|
let needsInvite = 0
|
|
let hasAccount = 0
|
|
|
|
for (const e of eligibilities) {
|
|
const submitter = e.project.submittedBy
|
|
if (submitter && !seen.has(submitter.id)) {
|
|
seen.add(submitter.id)
|
|
if (submitter.passwordHash) hasAccount++
|
|
else needsInvite++
|
|
}
|
|
for (const tm of e.project.teamMembers) {
|
|
if (!seen.has(tm.user.id)) {
|
|
seen.add(tm.user.id)
|
|
if (tm.user.passwordHash) hasAccount++
|
|
else needsInvite++
|
|
}
|
|
}
|
|
}
|
|
|
|
return { needsInvite, hasAccount, totalProjects: eligibilities.length }
|
|
}),
|
|
|
|
previewAwardSelectionEmail: adminProcedure
|
|
.input(z.object({
|
|
awardId: z.string(),
|
|
customMessage: z.string().optional(),
|
|
}))
|
|
.query(async ({ ctx, input }) => {
|
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
where: { id: input.awardId },
|
|
select: { name: true },
|
|
})
|
|
|
|
const eligibleCount = await ctx.prisma.awardEligibility.count({
|
|
where: { awardId: input.awardId, eligible: true },
|
|
})
|
|
|
|
const template = getAwardSelectionNotificationTemplate(
|
|
'Team Member',
|
|
'Your Project',
|
|
award.name,
|
|
input.customMessage || undefined,
|
|
)
|
|
|
|
return { html: template.html, subject: template.subject, recipientCount: eligibleCount }
|
|
}),
|
|
|
|
/**
|
|
* Notify eligible projects that they've been selected for an award.
|
|
* Generates invite tokens for passwordless users.
|
|
*/
|
|
notifyEligibleProjects: adminProcedure
|
|
.input(z.object({
|
|
awardId: z.string(),
|
|
customMessage: z.string().optional(),
|
|
}))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
where: { id: input.awardId },
|
|
select: { id: true, name: true, description: true, status: true },
|
|
})
|
|
|
|
// Get eligible projects that haven't been notified yet
|
|
// Exclude projects that have been rejected at any stage
|
|
const eligibilities = await ctx.prisma.awardEligibility.findMany({
|
|
where: {
|
|
awardId: input.awardId,
|
|
eligible: true,
|
|
notifiedAt: null,
|
|
project: {
|
|
projectRoundStates: {
|
|
none: { state: 'REJECTED' },
|
|
},
|
|
},
|
|
},
|
|
select: {
|
|
id: true,
|
|
projectId: true,
|
|
project: {
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
submittedBy: {
|
|
select: { id: true, email: true, name: true, passwordHash: true },
|
|
},
|
|
teamMembers: {
|
|
select: {
|
|
user: {
|
|
select: { id: true, email: true, name: true, passwordHash: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if (eligibilities.length === 0) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'No eligible projects to notify',
|
|
})
|
|
}
|
|
|
|
// Pre-generate invite tokens for passwordless users
|
|
const expiryMs = await getInviteExpiryMs(ctx.prisma)
|
|
const expiresAt = new Date(Date.now() + expiryMs)
|
|
const tokenMap = new Map<string, string>() // userId -> token
|
|
|
|
const allUsers: Array<{ id: string; passwordHash: string | null }> = []
|
|
for (const e of eligibilities) {
|
|
if (e.project.submittedBy) allUsers.push(e.project.submittedBy)
|
|
for (const tm of e.project.teamMembers) allUsers.push(tm.user)
|
|
}
|
|
|
|
const passwordlessUsers = allUsers.filter((u) => !u.passwordHash)
|
|
const uniquePasswordless = [...new Map(passwordlessUsers.map((u) => [u.id, u])).values()]
|
|
|
|
for (const user of uniquePasswordless) {
|
|
const token = generateInviteToken()
|
|
tokenMap.set(user.id, token)
|
|
await ctx.prisma.user.update({
|
|
where: { id: user.id },
|
|
data: { inviteToken: token, inviteTokenExpiresAt: expiresAt, status: 'INVITED' },
|
|
})
|
|
}
|
|
|
|
// Build notification items — track which eligibility each email belongs to
|
|
const items: NotificationItem[] = []
|
|
const eligibilityEmailMap = new Map<string, Set<string>>() // eligibilityId → Set<email>
|
|
|
|
for (const e of eligibilities) {
|
|
const recipients: Array<{ id: string; email: string; name: string | null }> = []
|
|
if (e.project.submittedBy) recipients.push(e.project.submittedBy)
|
|
for (const tm of e.project.teamMembers) {
|
|
if (!recipients.some((r) => r.id === tm.user.id)) {
|
|
recipients.push(tm.user)
|
|
}
|
|
}
|
|
|
|
const emails = new Set<string>()
|
|
for (const recipient of recipients) {
|
|
const token = tokenMap.get(recipient.id)
|
|
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
|
|
emails.add(recipient.email)
|
|
|
|
items.push({
|
|
email: recipient.email,
|
|
name: recipient.name || '',
|
|
type: 'AWARD_SELECTION_NOTIFICATION',
|
|
context: {
|
|
title: `Under consideration for ${award.name}`,
|
|
message: input.customMessage || '',
|
|
metadata: {
|
|
projectName: e.project.title,
|
|
awardName: award.name,
|
|
customMessage: input.customMessage,
|
|
accountUrl,
|
|
},
|
|
},
|
|
projectId: e.projectId,
|
|
userId: recipient.id,
|
|
})
|
|
}
|
|
eligibilityEmailMap.set(e.id, emails)
|
|
}
|
|
|
|
const result = await sendBatchNotifications(items)
|
|
|
|
// Determine which eligibilities had zero failures
|
|
const failedEmails = new Set(result.errors.map((e) => e.email))
|
|
const successfulEligibilityIds: string[] = []
|
|
for (const [eligId, emails] of eligibilityEmailMap) {
|
|
const hasFailure = [...emails].some((email) => failedEmails.has(email))
|
|
if (!hasFailure) successfulEligibilityIds.push(eligId)
|
|
}
|
|
|
|
if (successfulEligibilityIds.length > 0) {
|
|
await ctx.prisma.awardEligibility.updateMany({
|
|
where: { id: { in: successfulEligibilityIds } },
|
|
data: { notifiedAt: new Date() },
|
|
})
|
|
}
|
|
|
|
await logAudit({
|
|
prisma: ctx.prisma,
|
|
userId: ctx.user.id,
|
|
action: 'UPDATE',
|
|
entityType: 'SpecialAward',
|
|
entityId: input.awardId,
|
|
detailsJson: {
|
|
action: 'NOTIFY_ELIGIBLE_PROJECTS',
|
|
eligibleCount: eligibilities.length,
|
|
emailsSent: result.sent,
|
|
emailsFailed: result.failed,
|
|
failedRecipients: result.errors.length > 0 ? result.errors.map((e) => e.email) : undefined,
|
|
},
|
|
ipAddress: ctx.ip,
|
|
userAgent: ctx.userAgent,
|
|
})
|
|
|
|
return { notified: successfulEligibilityIds.length, emailsSent: result.sent, emailsFailed: result.failed }
|
|
}),
|
|
|
|
/**
|
|
* Delete an award round (only if DRAFT)
|
|
*/
|
|
deleteRound: adminProcedure
|
|
.input(z.object({ roundId: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
|
where: { id: input.roundId },
|
|
select: { status: true, specialAwardId: true },
|
|
})
|
|
|
|
if (!round.specialAwardId) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'This round is not an award round',
|
|
})
|
|
}
|
|
|
|
if (round.status !== 'ROUND_DRAFT') {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Only draft rounds can be deleted',
|
|
})
|
|
}
|
|
|
|
await ctx.prisma.round.delete({ where: { id: input.roundId } })
|
|
|
|
await logAudit({
|
|
userId: ctx.user.id,
|
|
action: 'DELETE',
|
|
entityType: 'Round',
|
|
entityId: input.roundId,
|
|
detailsJson: { awardId: round.specialAwardId },
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* Reorder award rounds via drag-and-drop.
|
|
* Uses a two-phase transaction: first set all to negative temps (avoid unique constraint),
|
|
* then set to final values.
|
|
*/
|
|
reorderAwardRounds: adminProcedure
|
|
.input(z.object({
|
|
awardId: z.string(),
|
|
roundIds: z.array(z.string()).min(1),
|
|
}))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const existingRounds = await ctx.prisma.round.findMany({
|
|
where: { specialAwardId: input.awardId },
|
|
select: { id: true, competitionId: true, sortOrder: true },
|
|
orderBy: { sortOrder: 'asc' },
|
|
})
|
|
|
|
if (existingRounds.length !== input.roundIds.length) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Round list does not match existing award rounds',
|
|
})
|
|
}
|
|
|
|
const existingIds = new Set(existingRounds.map((r) => r.id))
|
|
for (const id of input.roundIds) {
|
|
if (!existingIds.has(id)) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: `Round ${id} does not belong to this award`,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Collect the existing sortOrder values (in ascending order) and reassign them
|
|
// to the new ordering. This keeps the same sortOrder slots, just remapped.
|
|
const sortSlots = existingRounds.map((r) => r.sortOrder).sort((a, b) => a - b)
|
|
const competitionId = existingRounds[0].competitionId
|
|
|
|
await ctx.prisma.$transaction(async (tx) => {
|
|
// Phase 1: set all to negative temps to avoid unique constraint
|
|
for (let i = 0; i < existingRounds.length; i++) {
|
|
await tx.round.update({
|
|
where: { id: existingRounds[i].id },
|
|
data: { sortOrder: -(i + 1000) },
|
|
})
|
|
}
|
|
|
|
// Phase 2: assign final sort orders based on new ordering
|
|
for (let i = 0; i < input.roundIds.length; i++) {
|
|
await tx.round.update({
|
|
where: { id: input.roundIds[i] },
|
|
data: { sortOrder: sortSlots[i] },
|
|
})
|
|
}
|
|
})
|
|
|
|
await logAudit({
|
|
userId: ctx.user.id,
|
|
action: 'UPDATE',
|
|
entityType: 'SpecialAward',
|
|
entityId: input.awardId,
|
|
detailsJson: { action: 'REORDER_ROUNDS', newOrder: input.roundIds },
|
|
})
|
|
}),
|
|
|
|
/**
|
|
* Assign (or reassign) eligible projects to the first award round.
|
|
* Re-runnable: moves existing ProjectRoundState entries from other award rounds
|
|
* to the first, and creates new PENDING entries for unassigned projects.
|
|
*/
|
|
assignToFirstRound: adminProcedure
|
|
.input(z.object({ awardId: z.string() }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
|
where: { id: input.awardId },
|
|
select: { eligibilityMode: true, name: true },
|
|
})
|
|
|
|
if (award.eligibilityMode !== 'SEPARATE_POOL') {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Assign to first round is only available for Separate Pool awards',
|
|
})
|
|
}
|
|
|
|
const awardRounds = await ctx.prisma.round.findMany({
|
|
where: { specialAwardId: input.awardId },
|
|
select: { id: true },
|
|
orderBy: { sortOrder: 'asc' },
|
|
})
|
|
|
|
if (awardRounds.length === 0) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Create at least one round before assigning projects',
|
|
})
|
|
}
|
|
|
|
const firstRound = awardRounds[0]
|
|
const otherRoundIds = awardRounds.slice(1).map((r) => r.id)
|
|
|
|
// Get all eligible projects (confirmed or not — any eligible project)
|
|
const eligible = await ctx.prisma.awardEligibility.findMany({
|
|
where: { awardId: input.awardId, eligible: true },
|
|
select: { projectId: true },
|
|
})
|
|
|
|
if (eligible.length === 0) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'No eligible projects to assign',
|
|
})
|
|
}
|
|
|
|
const projectIds = eligible.map((e) => e.projectId)
|
|
|
|
// Move existing entries from other award rounds to the first round
|
|
let movedCount = 0
|
|
if (otherRoundIds.length > 0) {
|
|
const moved = await ctx.prisma.projectRoundState.updateMany({
|
|
where: {
|
|
roundId: { in: otherRoundIds },
|
|
projectId: { in: projectIds },
|
|
},
|
|
data: { roundId: firstRound.id, state: 'PENDING' },
|
|
})
|
|
movedCount = moved.count
|
|
}
|
|
|
|
// Create PENDING entries for projects not yet in the first round
|
|
const existing = await ctx.prisma.projectRoundState.findMany({
|
|
where: { roundId: firstRound.id, projectId: { in: projectIds } },
|
|
select: { projectId: true },
|
|
})
|
|
const existingSet = new Set(existing.map((e) => e.projectId))
|
|
const newProjectIds = projectIds.filter((id) => !existingSet.has(id))
|
|
|
|
let createdCount = 0
|
|
if (newProjectIds.length > 0) {
|
|
await ctx.prisma.projectRoundState.createMany({
|
|
data: newProjectIds.map((projectId) => ({
|
|
projectId,
|
|
roundId: firstRound.id,
|
|
state: 'PENDING' as const,
|
|
})),
|
|
skipDuplicates: true,
|
|
})
|
|
createdCount = newProjectIds.length
|
|
}
|
|
|
|
await logAudit({
|
|
userId: ctx.user.id,
|
|
action: 'UPDATE',
|
|
entityType: 'SpecialAward',
|
|
entityId: input.awardId,
|
|
detailsJson: {
|
|
action: 'ASSIGN_TO_FIRST_ROUND',
|
|
firstRoundId: firstRound.id,
|
|
movedCount,
|
|
createdCount,
|
|
totalEligible: projectIds.length,
|
|
},
|
|
})
|
|
|
|
return { movedCount, createdCount, totalAssigned: existingSet.size + createdCount }
|
|
}),
|
|
})
|