feat: round finalization with ranking-based outcomes + award pool notifications
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:
2026-03-03 19:14:41 +01:00
parent 7735f3ecdf
commit cfee3bc8a9
48 changed files with 5294 additions and 676 deletions

View File

@@ -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 }
}),
})