fix: pipeline progress, message variables, jury invite flow, accept-invite UX

- Pipeline: SUBMISSION rounds count IN_PROGRESS + COMPLETED for progress %
- Round engine: remove phantom SubmissionFileRequirement check blocking auto-transition
- Messages: implement {{userName}}, {{projectName}}, {{roundName}}, {{programName}}, {{deadline}} substitution
- Email preview: show greeting, CTA button, and footer matching actual sent email
- Message composer: add green dot indicator for active rounds in round selector
- User create: generate invite token atomically (prevents stuck INVITED state on email failure)
- Jury invites: use jury-specific email template mentioning round context
- Bulk invite: animated progress bar, batch size hint, success/failure counts
- Accept invite: distinguish server errors (retry button) from expired tokens (redirect)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-03-31 13:47:42 -04:00
parent 6b40fe7726
commit 7ead21114e
8 changed files with 269 additions and 120 deletions

View File

@@ -86,9 +86,61 @@ export const messageRouter = router({
if (!isScheduled && input.deliveryChannels.includes('EMAIL')) {
const users = await ctx.prisma.user.findMany({
where: { id: { in: recipientUserIds } },
select: { id: true, name: true, email: true, passwordHash: true, inviteToken: true },
select: {
id: true, name: true, email: true, passwordHash: true, inviteToken: true,
teamMemberships: {
select: { project: { select: { title: true } } },
take: 1,
},
},
})
// Fetch round & program context for template variable substitution
let roundName = ''
let programName = ''
let deadline = ''
if (effectiveRoundIds.length > 0) {
const rounds = await ctx.prisma.round.findMany({
where: { id: { in: effectiveRoundIds } },
select: {
name: true,
windowCloseAt: true,
competition: {
select: {
program: { select: { name: true } },
},
},
},
})
if (rounds.length > 0) {
roundName = rounds.map((r) => r.name).join(', ')
programName = rounds[0].competition?.program?.name ?? ''
// Use the earliest upcoming deadline across selected rounds
const deadlines = rounds
.map((r) => r.windowCloseAt)
.filter((d): d is Date => d !== null)
.sort((a, b) => a.getTime() - b.getTime())
if (deadlines.length > 0) {
deadline = deadlines[0].toLocaleDateString('en-GB', {
day: 'numeric', month: 'long', year: 'numeric',
})
}
}
}
/** Substitute template variables in a string for a specific user */
function substituteVariables(
text: string,
user: { name: string | null; teamMemberships: { project: { title: string } }[] }
): string {
return text
.replace(/\{\{userName\}\}/g, user.name || '')
.replace(/\{\{projectName\}\}/g, user.teamMemberships[0]?.project?.title || '')
.replace(/\{\{roundName\}\}/g, roundName)
.replace(/\{\{programName\}\}/g, programName)
.replace(/\{\{deadline\}\}/g, deadline)
}
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
function getLinkUrl(user: { id: string; passwordHash: string | null; inviteToken: string | null }): string | undefined {
@@ -117,8 +169,8 @@ export const messageRouter = router({
userId: user.id,
context: {
name: user.name || undefined,
title: input.subject,
message: input.body,
title: substituteVariables(input.subject, user),
message: substituteVariables(input.body, user),
linkUrl: getLinkUrl(user),
},
}))
@@ -651,13 +703,24 @@ export const messageRouter = router({
sendTest: adminProcedure
.input(z.object({ subject: z.string(), body: z.string() }))
.mutation(async ({ ctx, input }) => {
const userName = ctx.user.name || ''
/** Substitute template variables with admin name + placeholder values for test emails */
function substituteTestVariables(text: string): string {
return text
.replace(/\{\{userName\}\}/g, userName)
.replace(/\{\{projectName\}\}/g, '[Project Name]')
.replace(/\{\{roundName\}\}/g, '[Round Name]')
.replace(/\{\{programName\}\}/g, '[Program Name]')
.replace(/\{\{deadline\}\}/g, '[Deadline]')
}
await sendStyledNotificationEmail(
ctx.user.email,
ctx.user.name || '',
userName,
'MESSAGE',
{
title: input.subject,
message: input.body,
title: substituteTestVariables(input.subject),
message: substituteTestVariables(input.body),
linkUrl: '/admin/messages',
}
)

View File

@@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server'
import type { Prisma } from '@prisma/client'
import { UserRole } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
import { sendInvitationEmail, sendMagicLinkEmail, sendPasswordResetEmail } from '@/lib/email'
import { sendInvitationEmail, sendJuryInvitationEmail, sendMagicLinkEmail, sendPasswordResetEmail } from '@/lib/email'
import { hashPassword, validatePassword } from '@/lib/password'
import { attachAvatarUrls, getUserAvatarUrl } from '@/server/utils/avatar-url'
import { logAudit } from '@/server/utils/audit'
@@ -507,10 +507,18 @@ export const userRouter = router({
})
}
// Generate invite token upfront so the user can accept even if the
// subsequent invitation email fails to send. Re-sending from the
// members table will just overwrite the token.
const inviteToken = generateInviteToken()
const expiryHours = await getInviteExpiryHours(ctx.prisma)
const user = await ctx.prisma.user.create({
data: {
...input,
status: 'INVITED',
inviteToken,
inviteTokenExpiresAt: new Date(Date.now() + expiryHours * 60 * 60 * 1000),
},
})
@@ -979,7 +987,13 @@ export const userRouter = router({
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
// Use jury-specific template for jury members
if (user.role === 'JURY_MEMBER') {
await sendJuryInvitationEmail(user.email, user.name, inviteUrl, 'the evaluation round')
} else {
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
}
await ctx.prisma.notificationLog.create({
data: {
@@ -1121,8 +1135,23 @@ export const userRouter = router({
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
// Send invitation email
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
// Send invitation email — use jury-specific template for jury members
if (user.role === 'JURY_MEMBER') {
// Try to resolve a round name for the jury invitation email
let roundName = 'the evaluation round'
if (input.juryGroupId) {
const juryGroup = await ctx.prisma.juryGroup.findUnique({
where: { id: input.juryGroupId },
select: { rounds: { select: { name: true }, take: 1, orderBy: { sortOrder: 'asc' } } },
})
if (juryGroup?.rounds[0]?.name) {
roundName = juryGroup.rounds[0].name
}
}
await sendJuryInvitationEmail(user.email, user.name, inviteUrl, roundName)
} else {
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
}
// Log notification
await ctx.prisma.notificationLog.create({
@@ -1187,7 +1216,13 @@ export const userRouter = router({
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
// Use jury-specific template for jury members
if (user.role === 'JURY_MEMBER') {
await sendJuryInvitationEmail(user.email, user.name, inviteUrl, 'the evaluation round')
} else {
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
}
await ctx.prisma.notificationLog.create({
data: {

View File

@@ -826,76 +826,36 @@ export async function checkRequirementsAndTransition(
prisma: PrismaClient,
): Promise<{ transitioned: boolean; newState?: string }> {
try {
// Get all required FileRequirements for this round (legacy model)
// Get all required FileRequirements for this round
// Note: only FileRequirement (admin-managed via UI) is checked.
// SubmissionFileRequirement (on SubmissionWindow) has no admin UI and is not checked.
const requirements = await prisma.fileRequirement.findMany({
where: { roundId, isRequired: true },
select: { id: true },
})
// Also check SubmissionFileRequirement via the round's submissionWindow
const round = await prisma.round.findUnique({
where: { id: roundId },
select: { submissionWindowId: true },
})
let submissionRequirements: Array<{ id: string }> = []
if (round?.submissionWindowId) {
submissionRequirements = await prisma.submissionFileRequirement.findMany({
where: { submissionWindowId: round.submissionWindowId, required: true },
select: { id: true },
})
}
// If the round has no file requirements at all, nothing to check
if (requirements.length === 0 && submissionRequirements.length === 0) {
// If the round has no file requirements, nothing to check
if (requirements.length === 0) {
return { transitioned: false }
}
// Check which legacy requirements this project has satisfied
let legacyAllMet = true
if (requirements.length > 0) {
const fulfilledFiles = await prisma.projectFile.findMany({
where: {
projectId,
roundId,
requirementId: { in: requirements.map((r: { id: string }) => r.id) },
},
select: { requirementId: true },
})
// Check which requirements this project has satisfied
const fulfilledFiles = await prisma.projectFile.findMany({
where: {
projectId,
roundId,
requirementId: { in: requirements.map((r: { id: string }) => r.id) },
},
select: { requirementId: true },
})
const fulfilledIds = new Set(
fulfilledFiles
.map((f: { requirementId: string | null }) => f.requirementId)
.filter(Boolean)
)
const fulfilledIds = new Set(
fulfilledFiles
.map((f: { requirementId: string | null }) => f.requirementId)
.filter(Boolean)
)
legacyAllMet = requirements.every((r: { id: string }) => fulfilledIds.has(r.id))
}
// Check which SubmissionFileRequirements this project has satisfied
let submissionAllMet = true
if (submissionRequirements.length > 0) {
const fulfilledSubmissionFiles = await prisma.projectFile.findMany({
where: {
projectId,
submissionFileRequirementId: { in: submissionRequirements.map((r: { id: string }) => r.id) },
},
select: { submissionFileRequirementId: true },
})
const fulfilledSubIds = new Set(
fulfilledSubmissionFiles
.map((f: { submissionFileRequirementId: string | null }) => f.submissionFileRequirementId)
.filter(Boolean)
)
submissionAllMet = submissionRequirements.every((r: { id: string }) => fulfilledSubIds.has(r.id))
}
// All requirements from both models must be met
const allMet = legacyAllMet && submissionAllMet
if (!allMet) {
if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) {
return { transitioned: false }
}
@@ -919,7 +879,7 @@ export async function checkRequirementsAndTransition(
const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
if (result.success) {
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to COMPLETED in round ${roundId} (all ${requirements.length + submissionRequirements.length} requirements met)`)
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to COMPLETED in round ${roundId} (all ${requirements.length} requirements met)`)
return { transitioned: true, newState: 'COMPLETED' }
}
@@ -944,32 +904,20 @@ export async function batchCheckRequirementsAndTransition(
if (projectIds.length === 0) return { transitionedCount: 0, projectIds: [] }
// Pre-load all requirements for this round in batch (avoids per-project queries)
const [requirements, round] = await Promise.all([
prisma.fileRequirement.findMany({
where: { roundId, isRequired: true },
select: { id: true },
}),
prisma.round.findUnique({
where: { id: roundId },
select: { submissionWindowId: true },
}),
])
// Note: only FileRequirement (admin-managed via UI) is checked.
// SubmissionFileRequirement (on SubmissionWindow) has no admin UI and is not checked.
const requirements = await prisma.fileRequirement.findMany({
where: { roundId, isRequired: true },
select: { id: true },
})
let submissionRequirements: Array<{ id: string }> = []
if (round?.submissionWindowId) {
submissionRequirements = await prisma.submissionFileRequirement.findMany({
where: { submissionWindowId: round.submissionWindowId, required: true },
select: { id: true },
})
}
// If no requirements at all, nothing to check
if (requirements.length === 0 && submissionRequirements.length === 0) {
// If no requirements, nothing to check
if (requirements.length === 0) {
return { transitionedCount: 0, projectIds: [] }
}
// Pre-load all project files and current states in batch
type FileRow = { projectId: string; requirementId: string | null; submissionFileRequirementId: string | null }
type FileRow = { projectId: string; requirementId: string | null }
type StateRow = { projectId: string; state: string }
const [allFiles, allStates] = await Promise.all([
@@ -978,7 +926,7 @@ export async function batchCheckRequirementsAndTransition(
projectId: { in: projectIds },
roundId,
},
select: { projectId: true, requirementId: true, submissionFileRequirementId: true },
select: { projectId: true, requirementId: true },
}) as Promise<FileRow[]>,
prisma.projectRoundState.findMany({
where: { roundId, projectId: { in: projectIds } },
@@ -1004,18 +952,8 @@ export async function batchCheckRequirementsAndTransition(
if (!currentState || !eligibleStates.includes(currentState)) continue
const files = filesByProject.get(projectId) ?? []
// Check legacy requirements
if (requirements.length > 0) {
const fulfilledIds = new Set(files.map((f) => f.requirementId).filter(Boolean))
if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) continue
}
// Check submission requirements
if (submissionRequirements.length > 0) {
const fulfilledSubIds = new Set(files.map((f) => f.submissionFileRequirementId).filter(Boolean))
if (!submissionRequirements.every((r: { id: string }) => fulfilledSubIds.has(r.id))) continue
}
const fulfilledIds = new Set(files.map((f) => f.requirementId).filter(Boolean))
if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) continue
toTransition.push(projectId)
}