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:
@@ -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',
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user