feat: round finalization with ranking-based outcomes + award pool notifications
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m0s
- processRoundClose EVALUATION uses ranking scores + advanceMode config (threshold vs count) to auto-set proposedOutcome instead of defaulting all to PASSED - Advancement emails generate invite tokens for passwordless users with "Create Your Account" CTA; rejection emails have no link - Finalization UI shows account stats (invite vs dashboard link counts) - Fixed getFinalizationSummary ranking query (was using non-existent rankingsJson) - New award pool notification system: getAwardSelectionNotificationTemplate email, notifyEligibleProjects mutation with invite token generation, "Notify Pool" button on award detail page with custom message dialog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,11 @@ import {
|
||||
getProjectRoundStates,
|
||||
getProjectRoundState,
|
||||
} from '../services/round-engine'
|
||||
import {
|
||||
processRoundClose,
|
||||
getFinalizationSummary,
|
||||
confirmFinalization,
|
||||
} from '../services/round-finalization'
|
||||
|
||||
const projectRoundStateEnum = z.enum([
|
||||
'PENDING',
|
||||
@@ -318,4 +323,110 @@ export const roundEngineRouter = router({
|
||||
projectIds: result.projectIds,
|
||||
}
|
||||
}),
|
||||
|
||||
// ─── Finalization Procedures ────────────────────────────────────────────
|
||||
|
||||
getFinalizationSummary: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getFinalizationSummary(input.roundId, ctx.prisma)
|
||||
}),
|
||||
|
||||
updateProposedOutcome: adminProcedure
|
||||
.input(z.object({
|
||||
roundId: z.string(),
|
||||
projectId: z.string(),
|
||||
proposedOutcome: projectRoundStateEnum,
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const prs = await ctx.prisma.projectRoundState.findUnique({
|
||||
where: { projectId_roundId: { projectId: input.projectId, roundId: input.roundId } },
|
||||
})
|
||||
if (!prs) throw new TRPCError({ code: 'NOT_FOUND', message: 'Project round state not found' })
|
||||
|
||||
return ctx.prisma.projectRoundState.update({
|
||||
where: { id: prs.id },
|
||||
data: { proposedOutcome: input.proposedOutcome },
|
||||
})
|
||||
}),
|
||||
|
||||
batchUpdateProposedOutcomes: adminProcedure
|
||||
.input(z.object({
|
||||
roundId: z.string(),
|
||||
outcomes: z.record(z.string(), projectRoundStateEnum),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
let updated = 0
|
||||
for (const [projectId, outcome] of Object.entries(input.outcomes)) {
|
||||
await ctx.prisma.projectRoundState.updateMany({
|
||||
where: { projectId, roundId: input.roundId },
|
||||
data: { proposedOutcome: outcome },
|
||||
})
|
||||
updated++
|
||||
}
|
||||
return { updated }
|
||||
}),
|
||||
|
||||
confirmFinalization: adminProcedure
|
||||
.input(z.object({
|
||||
roundId: z.string(),
|
||||
targetRoundId: z.string().optional(),
|
||||
advancementMessage: z.string().optional(),
|
||||
rejectionMessage: z.string().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return confirmFinalization(
|
||||
input.roundId,
|
||||
{
|
||||
targetRoundId: input.targetRoundId,
|
||||
advancementMessage: input.advancementMessage,
|
||||
rejectionMessage: input.rejectionMessage,
|
||||
},
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
)
|
||||
}),
|
||||
|
||||
endGracePeriod: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId } })
|
||||
|
||||
if (round.status !== 'ROUND_CLOSED') {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Round must be ROUND_CLOSED' })
|
||||
}
|
||||
if (!round.gracePeriodEndsAt) {
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Round has no grace period set' })
|
||||
}
|
||||
|
||||
// Clear grace period and process
|
||||
await ctx.prisma.round.update({
|
||||
where: { id: input.roundId },
|
||||
data: { gracePeriodEndsAt: new Date() },
|
||||
})
|
||||
|
||||
const result = await processRoundClose(input.roundId, ctx.user.id, ctx.prisma)
|
||||
return { processed: result.processed }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Manually trigger processRoundClose for already-closed rounds.
|
||||
* Used when a round was closed before the finalization system existed,
|
||||
* or when processRoundClose failed silently on close.
|
||||
*/
|
||||
processRoundProjects: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: input.roundId } })
|
||||
|
||||
if (round.status !== 'ROUND_CLOSED' && round.status !== 'ROUND_ARCHIVED') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Round must be ROUND_CLOSED or ROUND_ARCHIVED, got ${round.status}`,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await processRoundClose(input.roundId, ctx.user.id, ctx.prisma)
|
||||
return { processed: result.processed }
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user