From 7ead21114ecaa8bd0dbd0999474ab1fc9756365f Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 31 Mar 2026 13:47:42 -0400 Subject: [PATCH] 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) --- src/app/(admin)/admin/members/invite/page.tsx | 51 ++++++- src/app/(admin)/admin/messages/page.tsx | 8 +- src/app/(auth)/accept-invite/page.tsx | 63 ++++++++- .../dashboard/pipeline-round-node.tsx | 11 +- src/lib/email.ts | 8 +- src/server/routers/message.ts | 75 +++++++++- src/server/routers/user.ts | 45 +++++- src/server/services/round-engine.ts | 128 +++++------------- 8 files changed, 269 insertions(+), 120 deletions(-) diff --git a/src/app/(admin)/admin/members/invite/page.tsx b/src/app/(admin)/admin/members/invite/page.tsx index d643fc2..2a1a5fe 100644 --- a/src/app/(admin)/admin/members/invite/page.tsx +++ b/src/app/(admin)/admin/members/invite/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useCallback, useMemo } from 'react' +import { useState, useCallback, useMemo, useEffect, useRef } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import Papa from 'papaparse' @@ -270,6 +270,8 @@ export default function MemberInvitePage() { skipped: number assignmentsCreated?: number invitationSent?: boolean + emailsSent?: number + emailErrors?: string[] } | null>(null) // Pre-assignment state @@ -511,10 +513,34 @@ export default function MemberInvitePage() { setParsedUsers(parsedUsers.filter((u) => u.isValid)) // --- Send --- + // Simulated progress: ramps up gradually while waiting for the backend + const progressIntervalRef = useRef | null>(null) + + const stopProgressSimulation = useCallback(() => { + if (progressIntervalRef.current) { + clearInterval(progressIntervalRef.current) + progressIntervalRef.current = null + } + }, []) + + // Cleanup on unmount + useEffect(() => stopProgressSimulation, [stopProgressSimulation]) + const handleSendInvites = async () => { if (summary.valid === 0) return setStep('sending') setSendProgress(0) + + // Simulate progress: advance quickly at first, then slow down (never reaches 100) + progressIntervalRef.current = setInterval(() => { + setSendProgress((prev) => { + if (prev >= 90) return prev // Cap at 90 — real completion sets 100 + // Slow down as we approach 90 + const increment = Math.max(0.5, (90 - prev) * 0.04) + return Math.min(90, prev + increment) + }) + }, 300) + try { const result = await bulkCreate.mutateAsync({ users: summary.validUsers.map((u) => ({ @@ -526,10 +552,12 @@ export default function MemberInvitePage() { })), sendInvitation, }) + stopProgressSimulation() setSendProgress(100) setResult(result) setStep('complete') } catch { + stopProgressSimulation() setStep('preview') } } @@ -999,9 +1027,16 @@ export default function MemberInvitePage() {

- {sendInvitation ? 'Creating members and sending invitations...' : 'Creating members...'} + {sendInvitation + ? `Creating ${summary.valid} member${summary.valid !== 1 ? 's' : ''} and sending invitations...` + : `Creating ${summary.valid} member${summary.valid !== 1 ? 's' : ''}...`}

- + + {sendInvitation && summary.valid > 3 && ( +

+ This may take a moment for large batches +

+ )}
) @@ -1019,6 +1054,16 @@ export default function MemberInvitePage() {

{result?.created} member{result?.created !== 1 ? 's' : ''}{' '} {result?.invitationSent ? 'created and invited' : 'created'}. + {result?.invitationSent && result.emailsSent != null && ( + + {result.emailsSent} invitation{result.emailsSent !== 1 ? 's' : ''} sent successfully. + {result.emailErrors && result.emailErrors.length > 0 && ( + + {' '}{result.emailErrors.length} failed to send. + + )} + + )} {result?.skipped ? ` ${result.skipped} skipped (already exist).` : ''} diff --git a/src/app/(admin)/admin/messages/page.tsx b/src/app/(admin)/admin/messages/page.tsx index c3422a2..2bd652f 100644 --- a/src/app/(admin)/admin/messages/page.tsx +++ b/src/app/(admin)/admin/messages/page.tsx @@ -128,7 +128,7 @@ export default function MessagesPage() { // Fetch supporting data const { data: programs } = trpc.program.list.useQuery({ includeStages: true }) const rounds = programs?.flatMap((p) => - ((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ ...s, program: { name: p.name } })) + ((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ ...s, program: { name: p.name } })) ) || [] const { data: templates } = trpc.message.listTemplates.useQuery() const { data: users } = trpc.user.list.useQuery( @@ -465,8 +465,9 @@ export default function MessagesPage() { {rounds?.map((round) => { const label = round.program ? `${round.program.name} - ${round.name}` : round.name const isChecked = roundIds.includes(round.id) + const isActive = round.status === 'ROUND_ACTIVE' return ( -

+
-
diff --git a/src/app/(auth)/accept-invite/page.tsx b/src/app/(auth)/accept-invite/page.tsx index 132955f..0ee4baa 100644 --- a/src/app/(auth)/accept-invite/page.tsx +++ b/src/app/(auth)/accept-invite/page.tsx @@ -11,7 +11,7 @@ import { CardHeader, CardTitle, } from '@/components/ui/card' -import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock } from 'lucide-react' +import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock, RefreshCw } from 'lucide-react' import Image from 'next/image' import { trpc } from '@/lib/trpc/client' import { AnimatedCard } from '@/components/shared/animated-container' @@ -64,6 +64,48 @@ function ErrorRedirectCard({ ) } +function NetworkErrorCard({ onRetry, isRetrying }: { onRetry: () => void; isRetrying: boolean }) { + return ( + + +
+ +
+ +
+ Something Went Wrong + + We couldn't verify your invitation due to a server or network issue. + This is not a problem with your invitation link. + +
+ + +

+ If the problem persists, please contact your administrator. +

+
+ + + ) +} + function AcceptInviteContent() { const [state, setState] = useState('loading') const [errorType, setErrorType] = useState(null) @@ -72,7 +114,7 @@ function AcceptInviteContent() { const router = useRouter() const token = searchParams.get('token') || '' - const { data, isLoading, error } = trpc.user.validateInviteToken.useQuery( + const { data, isLoading, error, refetch, isRefetching } = trpc.user.validateInviteToken.useQuery( { token }, { enabled: !!token, retry: false } ) @@ -197,8 +239,23 @@ function AcceptInviteContent() { ) } - // Error state — auto-redirect to login after 4 seconds for known errors + // Error state if (state === 'error') { + // Network/server errors get a retry button instead of auto-redirect + if (errorType === 'NETWORK_ERROR') { + return ( + { + setState('loading') + setErrorType(null) + refetch() + }} + isRetrying={isRefetching} + /> + ) + } + + // Token validation errors auto-redirect to login after 4 seconds const errorContent = getErrorContent() const redirectTarget = errorContent.redirect || '/login' diff --git a/src/components/dashboard/pipeline-round-node.tsx b/src/components/dashboard/pipeline-round-node.tsx index 6d17cf5..a0b60a7 100644 --- a/src/components/dashboard/pipeline-round-node.tsx +++ b/src/components/dashboard/pipeline-round-node.tsx @@ -70,8 +70,12 @@ function getMetric(round: PipelineRound): string { return evalTotal > 0 ? `${evalSubmitted}/${evalTotal} evaluated` : `${assignmentCount} assignments` - case 'SUBMISSION': - return `${projectStates.COMPLETED} submitted` + case 'SUBMISSION': { + const active = projectStates.IN_PROGRESS + projectStates.COMPLETED + return active > 0 + ? `${projectStates.COMPLETED}/${projectStates.total} submitted${projectStates.IN_PROGRESS > 0 ? ` (${projectStates.IN_PROGRESS} in progress)` : ''}` + : `${projectStates.total} awaiting` + } case 'MENTORING': return `${projectStates.COMPLETED ?? 0} mentored` case 'LIVE_FINAL': { @@ -100,7 +104,8 @@ function getProgressPct(round: PipelineRound): number | null { return round.evalTotal > 0 ? Math.round((round.evalSubmitted / round.evalTotal) * 100) : 0 case 'SUBMISSION': { const total = round.projectStates.total - return total > 0 ? Math.round((round.projectStates.COMPLETED / total) * 100) : 0 + const active = round.projectStates.IN_PROGRESS + round.projectStates.COMPLETED + return total > 0 ? Math.round((active / total) * 100) : 0 } case 'MENTORING': { const total = round.projectStates.total diff --git a/src/lib/email.ts b/src/lib/email.ts index 052ca3c..718b3ba 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -1993,10 +1993,14 @@ Together for a healthier ocean. export function getEmailPreviewHtml(subject: string, body: string): string { const formattedBody = escapeHtml(body).replace(/\n/g, '
') const content = ` - ${sectionTitle(subject)} -
+ ${sectionTitle('Hello [Name],')} +
${formattedBody}
+ ${ctaButton('#', 'View Details')} +

+ You received this email because of your notification preferences on the MOPC Portal. +

` return getEmailWrapper(content) } diff --git a/src/server/routers/message.ts b/src/server/routers/message.ts index f41ece5..1ab9a67 100644 --- a/src/server/routers/message.ts +++ b/src/server/routers/message.ts @@ -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', } ) diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index 1abdd3a..b4ecdab 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -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: { diff --git a/src/server/services/round-engine.ts b/src/server/services/round-engine.ts index 8cb7f58..28fb437 100644 --- a/src/server/services/round-engine.ts +++ b/src/server/services/round-engine.ts @@ -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, 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) }