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:
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from 'react'
|
import { useState, useCallback, useMemo, useEffect, useRef } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import Papa from 'papaparse'
|
import Papa from 'papaparse'
|
||||||
@@ -270,6 +270,8 @@ export default function MemberInvitePage() {
|
|||||||
skipped: number
|
skipped: number
|
||||||
assignmentsCreated?: number
|
assignmentsCreated?: number
|
||||||
invitationSent?: boolean
|
invitationSent?: boolean
|
||||||
|
emailsSent?: number
|
||||||
|
emailErrors?: string[]
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
// Pre-assignment state
|
// Pre-assignment state
|
||||||
@@ -511,10 +513,34 @@ export default function MemberInvitePage() {
|
|||||||
setParsedUsers(parsedUsers.filter((u) => u.isValid))
|
setParsedUsers(parsedUsers.filter((u) => u.isValid))
|
||||||
|
|
||||||
// --- Send ---
|
// --- Send ---
|
||||||
|
// Simulated progress: ramps up gradually while waiting for the backend
|
||||||
|
const progressIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
const stopProgressSimulation = useCallback(() => {
|
||||||
|
if (progressIntervalRef.current) {
|
||||||
|
clearInterval(progressIntervalRef.current)
|
||||||
|
progressIntervalRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => stopProgressSimulation, [stopProgressSimulation])
|
||||||
|
|
||||||
const handleSendInvites = async () => {
|
const handleSendInvites = async () => {
|
||||||
if (summary.valid === 0) return
|
if (summary.valid === 0) return
|
||||||
setStep('sending')
|
setStep('sending')
|
||||||
setSendProgress(0)
|
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 {
|
try {
|
||||||
const result = await bulkCreate.mutateAsync({
|
const result = await bulkCreate.mutateAsync({
|
||||||
users: summary.validUsers.map((u) => ({
|
users: summary.validUsers.map((u) => ({
|
||||||
@@ -526,10 +552,12 @@ export default function MemberInvitePage() {
|
|||||||
})),
|
})),
|
||||||
sendInvitation,
|
sendInvitation,
|
||||||
})
|
})
|
||||||
|
stopProgressSimulation()
|
||||||
setSendProgress(100)
|
setSendProgress(100)
|
||||||
setResult(result)
|
setResult(result)
|
||||||
setStep('complete')
|
setStep('complete')
|
||||||
} catch {
|
} catch {
|
||||||
|
stopProgressSimulation()
|
||||||
setStep('preview')
|
setStep('preview')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -999,9 +1027,16 @@ export default function MemberInvitePage() {
|
|||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||||
<p className="mt-4 font-medium">
|
<p className="mt-4 font-medium">
|
||||||
{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' : ''}...`}
|
||||||
</p>
|
</p>
|
||||||
<Progress value={sendProgress} className="mt-4 w-48" />
|
<Progress value={sendProgress} className="mt-4 w-64" />
|
||||||
|
{sendInvitation && summary.valid > 3 && (
|
||||||
|
<p className="text-muted-foreground text-sm mt-3">
|
||||||
|
This may take a moment for large batches
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
@@ -1019,6 +1054,16 @@ export default function MemberInvitePage() {
|
|||||||
<p className="text-muted-foreground text-center max-w-sm mt-2">
|
<p className="text-muted-foreground text-center max-w-sm mt-2">
|
||||||
{result?.created} member{result?.created !== 1 ? 's' : ''}{' '}
|
{result?.created} member{result?.created !== 1 ? 's' : ''}{' '}
|
||||||
{result?.invitationSent ? 'created and invited' : 'created'}.
|
{result?.invitationSent ? 'created and invited' : 'created'}.
|
||||||
|
{result?.invitationSent && result.emailsSent != null && (
|
||||||
|
<span className="block mt-1">
|
||||||
|
{result.emailsSent} invitation{result.emailsSent !== 1 ? 's' : ''} sent successfully.
|
||||||
|
{result.emailErrors && result.emailErrors.length > 0 && (
|
||||||
|
<span className="text-destructive">
|
||||||
|
{' '}{result.emailErrors.length} failed to send.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{result?.skipped
|
{result?.skipped
|
||||||
? ` ${result.skipped} skipped (already exist).`
|
? ` ${result.skipped} skipped (already exist).`
|
||||||
: ''}
|
: ''}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export default function MessagesPage() {
|
|||||||
// Fetch supporting data
|
// Fetch supporting data
|
||||||
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
|
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
|
||||||
const rounds = programs?.flatMap((p) =>
|
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: templates } = trpc.message.listTemplates.useQuery()
|
||||||
const { data: users } = trpc.user.list.useQuery(
|
const { data: users } = trpc.user.list.useQuery(
|
||||||
@@ -465,8 +465,9 @@ export default function MessagesPage() {
|
|||||||
{rounds?.map((round) => {
|
{rounds?.map((round) => {
|
||||||
const label = round.program ? `${round.program.name} - ${round.name}` : round.name
|
const label = round.program ? `${round.program.name} - ${round.name}` : round.name
|
||||||
const isChecked = roundIds.includes(round.id)
|
const isChecked = roundIds.includes(round.id)
|
||||||
|
const isActive = round.status === 'ROUND_ACTIVE'
|
||||||
return (
|
return (
|
||||||
<div key={round.id} className="flex items-center gap-2">
|
<div key={round.id} className={`flex items-center gap-2 ${!isActive ? 'opacity-60' : ''}`}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`round-${round.id}`}
|
id={`round-${round.id}`}
|
||||||
checked={isChecked}
|
checked={isChecked}
|
||||||
@@ -478,7 +479,8 @@ export default function MessagesPage() {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<label htmlFor={`round-${round.id}`} className="text-sm cursor-pointer">
|
<label htmlFor={`round-${round.id}`} className="text-sm cursor-pointer flex items-center gap-1.5">
|
||||||
|
{isActive && <span className="inline-block h-2 w-2 rounded-full bg-green-500 shrink-0" />}
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} 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 Image from 'next/image'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
@@ -64,6 +64,48 @@ function ErrorRedirectCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NetworkErrorCard({ onRetry, isRetrying }: { onRetry: () => void; isRetrying: boolean }) {
|
||||||
|
return (
|
||||||
|
<AnimatedCard>
|
||||||
|
<Card className="w-full max-w-md overflow-hidden">
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100">
|
||||||
|
<AlertCircle className="h-6 w-6 text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl">Something Went Wrong</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
We couldn't verify your invitation due to a server or network issue.
|
||||||
|
This is not a problem with your invitation link.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={onRetry}
|
||||||
|
disabled={isRetrying}
|
||||||
|
>
|
||||||
|
{isRetrying ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Retrying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
Try Again
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-center text-muted-foreground">
|
||||||
|
If the problem persists, please contact your administrator.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function AcceptInviteContent() {
|
function AcceptInviteContent() {
|
||||||
const [state, setState] = useState<InviteState>('loading')
|
const [state, setState] = useState<InviteState>('loading')
|
||||||
const [errorType, setErrorType] = useState<string | null>(null)
|
const [errorType, setErrorType] = useState<string | null>(null)
|
||||||
@@ -72,7 +114,7 @@ function AcceptInviteContent() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const token = searchParams.get('token') || ''
|
const token = searchParams.get('token') || ''
|
||||||
|
|
||||||
const { data, isLoading, error } = trpc.user.validateInviteToken.useQuery(
|
const { data, isLoading, error, refetch, isRefetching } = trpc.user.validateInviteToken.useQuery(
|
||||||
{ token },
|
{ token },
|
||||||
{ enabled: !!token, retry: false }
|
{ 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') {
|
if (state === 'error') {
|
||||||
|
// Network/server errors get a retry button instead of auto-redirect
|
||||||
|
if (errorType === 'NETWORK_ERROR') {
|
||||||
|
return (
|
||||||
|
<NetworkErrorCard
|
||||||
|
onRetry={() => {
|
||||||
|
setState('loading')
|
||||||
|
setErrorType(null)
|
||||||
|
refetch()
|
||||||
|
}}
|
||||||
|
isRetrying={isRefetching}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token validation errors auto-redirect to login after 4 seconds
|
||||||
const errorContent = getErrorContent()
|
const errorContent = getErrorContent()
|
||||||
const redirectTarget = errorContent.redirect || '/login'
|
const redirectTarget = errorContent.redirect || '/login'
|
||||||
|
|
||||||
|
|||||||
@@ -70,8 +70,12 @@ function getMetric(round: PipelineRound): string {
|
|||||||
return evalTotal > 0
|
return evalTotal > 0
|
||||||
? `${evalSubmitted}/${evalTotal} evaluated`
|
? `${evalSubmitted}/${evalTotal} evaluated`
|
||||||
: `${assignmentCount} assignments`
|
: `${assignmentCount} assignments`
|
||||||
case 'SUBMISSION':
|
case 'SUBMISSION': {
|
||||||
return `${projectStates.COMPLETED} submitted`
|
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':
|
case 'MENTORING':
|
||||||
return `${projectStates.COMPLETED ?? 0} mentored`
|
return `${projectStates.COMPLETED ?? 0} mentored`
|
||||||
case 'LIVE_FINAL': {
|
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
|
return round.evalTotal > 0 ? Math.round((round.evalSubmitted / round.evalTotal) * 100) : 0
|
||||||
case 'SUBMISSION': {
|
case 'SUBMISSION': {
|
||||||
const total = round.projectStates.total
|
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': {
|
case 'MENTORING': {
|
||||||
const total = round.projectStates.total
|
const total = round.projectStates.total
|
||||||
|
|||||||
@@ -1993,10 +1993,14 @@ Together for a healthier ocean.
|
|||||||
export function getEmailPreviewHtml(subject: string, body: string): string {
|
export function getEmailPreviewHtml(subject: string, body: string): string {
|
||||||
const formattedBody = escapeHtml(body).replace(/\n/g, '<br>')
|
const formattedBody = escapeHtml(body).replace(/\n/g, '<br>')
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(subject)}
|
${sectionTitle('Hello [Name],')}
|
||||||
<div style="color: #1f2937; font-size: 15px; line-height: 1.7; margin: 20px 0;">
|
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">
|
||||||
${formattedBody}
|
${formattedBody}
|
||||||
</div>
|
</div>
|
||||||
|
${ctaButton('#', 'View Details')}
|
||||||
|
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
|
||||||
|
You received this email because of your notification preferences on the MOPC Portal.
|
||||||
|
</p>
|
||||||
`
|
`
|
||||||
return getEmailWrapper(content)
|
return getEmailWrapper(content)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,9 +86,61 @@ export const messageRouter = router({
|
|||||||
if (!isScheduled && input.deliveryChannels.includes('EMAIL')) {
|
if (!isScheduled && input.deliveryChannels.includes('EMAIL')) {
|
||||||
const users = await ctx.prisma.user.findMany({
|
const users = await ctx.prisma.user.findMany({
|
||||||
where: { id: { in: recipientUserIds } },
|
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'
|
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
|
||||||
|
|
||||||
function getLinkUrl(user: { id: string; passwordHash: string | null; inviteToken: string | null }): string | undefined {
|
function getLinkUrl(user: { id: string; passwordHash: string | null; inviteToken: string | null }): string | undefined {
|
||||||
@@ -117,8 +169,8 @@ export const messageRouter = router({
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
context: {
|
context: {
|
||||||
name: user.name || undefined,
|
name: user.name || undefined,
|
||||||
title: input.subject,
|
title: substituteVariables(input.subject, user),
|
||||||
message: input.body,
|
message: substituteVariables(input.body, user),
|
||||||
linkUrl: getLinkUrl(user),
|
linkUrl: getLinkUrl(user),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@@ -651,13 +703,24 @@ export const messageRouter = router({
|
|||||||
sendTest: adminProcedure
|
sendTest: adminProcedure
|
||||||
.input(z.object({ subject: z.string(), body: z.string() }))
|
.input(z.object({ subject: z.string(), body: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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(
|
await sendStyledNotificationEmail(
|
||||||
ctx.user.email,
|
ctx.user.email,
|
||||||
ctx.user.name || '',
|
userName,
|
||||||
'MESSAGE',
|
'MESSAGE',
|
||||||
{
|
{
|
||||||
title: input.subject,
|
title: substituteTestVariables(input.subject),
|
||||||
message: input.body,
|
message: substituteTestVariables(input.body),
|
||||||
linkUrl: '/admin/messages',
|
linkUrl: '/admin/messages',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server'
|
|||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
import { UserRole } from '@prisma/client'
|
import { UserRole } from '@prisma/client'
|
||||||
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
|
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 { hashPassword, validatePassword } from '@/lib/password'
|
||||||
import { attachAvatarUrls, getUserAvatarUrl } from '@/server/utils/avatar-url'
|
import { attachAvatarUrls, getUserAvatarUrl } from '@/server/utils/avatar-url'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
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({
|
const user = await ctx.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
...input,
|
...input,
|
||||||
status: 'INVITED',
|
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}`
|
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({
|
await ctx.prisma.notificationLog.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -1121,8 +1135,23 @@ export const userRouter = router({
|
|||||||
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
|
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
|
||||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
||||||
|
|
||||||
// Send invitation email
|
// Send invitation email — use jury-specific template for jury members
|
||||||
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role, expiryHours)
|
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
|
// Log notification
|
||||||
await ctx.prisma.notificationLog.create({
|
await ctx.prisma.notificationLog.create({
|
||||||
@@ -1187,7 +1216,13 @@ export const userRouter = router({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
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({
|
await ctx.prisma.notificationLog.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -826,76 +826,36 @@ export async function checkRequirementsAndTransition(
|
|||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
): Promise<{ transitioned: boolean; newState?: string }> {
|
): Promise<{ transitioned: boolean; newState?: string }> {
|
||||||
try {
|
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({
|
const requirements = await prisma.fileRequirement.findMany({
|
||||||
where: { roundId, isRequired: true },
|
where: { roundId, isRequired: true },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Also check SubmissionFileRequirement via the round's submissionWindow
|
// If the round has no file requirements, nothing to check
|
||||||
const round = await prisma.round.findUnique({
|
if (requirements.length === 0) {
|
||||||
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) {
|
|
||||||
return { transitioned: false }
|
return { transitioned: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check which legacy requirements this project has satisfied
|
// Check which requirements this project has satisfied
|
||||||
let legacyAllMet = true
|
const fulfilledFiles = await prisma.projectFile.findMany({
|
||||||
if (requirements.length > 0) {
|
where: {
|
||||||
const fulfilledFiles = await prisma.projectFile.findMany({
|
projectId,
|
||||||
where: {
|
roundId,
|
||||||
projectId,
|
requirementId: { in: requirements.map((r: { id: string }) => r.id) },
|
||||||
roundId,
|
},
|
||||||
requirementId: { in: requirements.map((r: { id: string }) => r.id) },
|
select: { requirementId: true },
|
||||||
},
|
})
|
||||||
select: { requirementId: true },
|
|
||||||
})
|
|
||||||
|
|
||||||
const fulfilledIds = new Set(
|
const fulfilledIds = new Set(
|
||||||
fulfilledFiles
|
fulfilledFiles
|
||||||
.map((f: { requirementId: string | null }) => f.requirementId)
|
.map((f: { requirementId: string | null }) => f.requirementId)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
)
|
)
|
||||||
|
|
||||||
legacyAllMet = requirements.every((r: { id: string }) => fulfilledIds.has(r.id))
|
if (!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) {
|
|
||||||
return { transitioned: false }
|
return { transitioned: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -919,7 +879,7 @@ export async function checkRequirementsAndTransition(
|
|||||||
const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
|
const result = await transitionProject(projectId, roundId, 'COMPLETED' as ProjectRoundStateValue, actorId, prisma)
|
||||||
|
|
||||||
if (result.success) {
|
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' }
|
return { transitioned: true, newState: 'COMPLETED' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -944,32 +904,20 @@ export async function batchCheckRequirementsAndTransition(
|
|||||||
if (projectIds.length === 0) return { transitionedCount: 0, projectIds: [] }
|
if (projectIds.length === 0) return { transitionedCount: 0, projectIds: [] }
|
||||||
|
|
||||||
// Pre-load all requirements for this round in batch (avoids per-project queries)
|
// Pre-load all requirements for this round in batch (avoids per-project queries)
|
||||||
const [requirements, round] = await Promise.all([
|
// Note: only FileRequirement (admin-managed via UI) is checked.
|
||||||
prisma.fileRequirement.findMany({
|
// SubmissionFileRequirement (on SubmissionWindow) has no admin UI and is not checked.
|
||||||
where: { roundId, isRequired: true },
|
const requirements = await prisma.fileRequirement.findMany({
|
||||||
select: { id: true },
|
where: { roundId, isRequired: true },
|
||||||
}),
|
select: { id: true },
|
||||||
prisma.round.findUnique({
|
})
|
||||||
where: { id: roundId },
|
|
||||||
select: { submissionWindowId: true },
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
let submissionRequirements: Array<{ id: string }> = []
|
// If no requirements, nothing to check
|
||||||
if (round?.submissionWindowId) {
|
if (requirements.length === 0) {
|
||||||
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) {
|
|
||||||
return { transitionedCount: 0, projectIds: [] }
|
return { transitionedCount: 0, projectIds: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-load all project files and current states in batch
|
// 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 }
|
type StateRow = { projectId: string; state: string }
|
||||||
|
|
||||||
const [allFiles, allStates] = await Promise.all([
|
const [allFiles, allStates] = await Promise.all([
|
||||||
@@ -978,7 +926,7 @@ export async function batchCheckRequirementsAndTransition(
|
|||||||
projectId: { in: projectIds },
|
projectId: { in: projectIds },
|
||||||
roundId,
|
roundId,
|
||||||
},
|
},
|
||||||
select: { projectId: true, requirementId: true, submissionFileRequirementId: true },
|
select: { projectId: true, requirementId: true },
|
||||||
}) as Promise<FileRow[]>,
|
}) as Promise<FileRow[]>,
|
||||||
prisma.projectRoundState.findMany({
|
prisma.projectRoundState.findMany({
|
||||||
where: { roundId, projectId: { in: projectIds } },
|
where: { roundId, projectId: { in: projectIds } },
|
||||||
@@ -1004,18 +952,8 @@ export async function batchCheckRequirementsAndTransition(
|
|||||||
if (!currentState || !eligibleStates.includes(currentState)) continue
|
if (!currentState || !eligibleStates.includes(currentState)) continue
|
||||||
|
|
||||||
const files = filesByProject.get(projectId) ?? []
|
const files = filesByProject.get(projectId) ?? []
|
||||||
|
const fulfilledIds = new Set(files.map((f) => f.requirementId).filter(Boolean))
|
||||||
// Check legacy requirements
|
if (!requirements.every((r: { id: string }) => fulfilledIds.has(r.id))) continue
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
toTransition.push(projectId)
|
toTransition.push(projectId)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user