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:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user