diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index 2e12351..74ca42f 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -85,6 +85,7 @@ import { ShieldAlert, Eye, Pencil, + Mail, } from 'lucide-react' import { Command, @@ -1784,9 +1785,10 @@ export default function RoundDetailPage() { - {/* Actions: Send Reminders + Export */} + {/* Actions: Send Reminders + Notify + Export */}
+ + + + + Notify jurors of their assignments? + + This will send an email to every juror assigned to this round, reminding them of how many projects they need to evaluate. + + + + Cancel + mutation.mutate({ roundId })} + disabled={mutation.isPending} + > + {mutation.isPending && } + Notify Jurors + + + + + + ) +} + // ── Export Evaluations Dialog ───────────────────────────────────────────── function ExportEvaluationsDialog({ @@ -2640,7 +2684,7 @@ function IndividualAssignmentsTable({ if (!open) resetDialog() else setAddDialogOpen(true) }}> - + Add Assignment @@ -2674,7 +2718,7 @@ function IndividualAssignmentsTable({ - + @@ -2775,7 +2819,7 @@ function IndividualAssignmentsTable({
{/* Project checklist */} - +
{!selectedJurorId ? (

diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index 0e5db6c..b759488 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -13,7 +13,7 @@ const Checkbox = React.forwardRef< { + const round = await ctx.prisma.round.findUniqueOrThrow({ + where: { id: input.roundId }, + select: { name: true, windowCloseAt: true }, + }) + + // Get all assignments grouped by user + const assignments = await ctx.prisma.assignment.findMany({ + where: { roundId: input.roundId }, + select: { userId: true }, + }) + + if (assignments.length === 0) { + return { sent: 0, jurorCount: 0, emailsSent: 0 } + } + + // Count assignments per user + const userCounts: Record = {} + for (const a of assignments) { + userCounts[a.userId] = (userCounts[a.userId] || 0) + 1 + } + + const deadline = round.windowCloseAt + ? new Date(round.windowCloseAt).toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }) + : undefined + + // Create in-app notifications grouped by project count + const usersByProjectCount = new Map() + for (const [userId, projectCount] of Object.entries(userCounts)) { + const existing = usersByProjectCount.get(projectCount) || [] + existing.push(userId) + usersByProjectCount.set(projectCount, existing) + } + + let totalSent = 0 + for (const [projectCount, userIds] of usersByProjectCount) { + if (userIds.length === 0) continue + await createBulkNotifications({ + userIds, + type: NotificationTypes.BATCH_ASSIGNED, + title: `${projectCount} Projects Assigned`, + message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round.name || 'this round'}.`, + linkUrl: `/jury/competitions`, + linkLabel: 'View Assignments', + metadata: { projectCount, stageName: round.name, deadline }, + }) + totalSent += userIds.length + } + + // Send direct emails to every juror (regardless of notification email settings) + const allUserIds = Object.keys(userCounts) + const users = await ctx.prisma.user.findMany({ + where: { id: { in: allUserIds } }, + select: { id: true, name: true, email: true }, + }) + + const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com' + let emailsSent = 0 + + for (const user of users) { + const projectCount = userCounts[user.id] || 0 + if (projectCount === 0) continue + + try { + await sendStyledNotificationEmail( + user.email, + user.name || '', + 'BATCH_ASSIGNED', + { + name: user.name || undefined, + title: `Projects Assigned - ${round.name}`, + message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round.name}.`, + linkUrl: `${baseUrl}/jury/competitions`, + metadata: { projectCount, roundName: round.name, deadline }, + } + ) + emailsSent++ + } catch (error) { + console.error(`Failed to send assignment email to ${user.email}:`, error) + } + } + + await logAudit({ + prisma: ctx.prisma, + userId: ctx.user.id, + action: 'NOTIFY_JURORS_OF_ASSIGNMENTS', + entityType: 'Round', + entityId: input.roundId, + detailsJson: { + jurorCount: Object.keys(userCounts).length, + totalAssignments: assignments.length, + emailsSent, + }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }) + + return { sent: totalSent, jurorCount: Object.keys(userCounts).length, emailsSent } + }), }) diff --git a/src/server/routers/evaluation.ts b/src/server/routers/evaluation.ts index c1933dc..a7961d4 100644 --- a/src/server/routers/evaluation.ts +++ b/src/server/routers/evaluation.ts @@ -3,7 +3,7 @@ import { TRPCError } from '@trpc/server' import { router, protectedProcedure, adminProcedure, juryProcedure } from '../trpc' import { logAudit } from '@/server/utils/audit' import { notifyAdmins, NotificationTypes } from '../services/in-app-notification' -import { processEvaluationReminders } from '../services/evaluation-reminders' +import { sendManualReminders } from '../services/evaluation-reminders' import { generateSummary } from '@/server/services/ai-evaluation-summary' export const evaluationRouter = router({ @@ -564,7 +564,7 @@ export const evaluationRouter = router({ triggerReminders: adminProcedure .input(z.object({ roundId: z.string() })) .mutation(async ({ ctx, input }) => { - const result = await processEvaluationReminders(input.roundId) + const result = await sendManualReminders(input.roundId) await logAudit({ prisma: ctx.prisma, diff --git a/src/server/services/evaluation-reminders.ts b/src/server/services/evaluation-reminders.ts index ac1632e..22dcbdb 100644 --- a/src/server/services/evaluation-reminders.ts +++ b/src/server/services/evaluation-reminders.ts @@ -14,9 +14,113 @@ interface ReminderResult { errors: number } +/** + * Manually send reminders to all jurors with incomplete assignments for a round. + * Bypasses window/deadline checks — the admin explicitly chose to send now. + * Uses 'MANUAL' type so it doesn't interfere with automated cron deduplication, + * but still deduplicates within manual sends (one manual reminder per juror per round). + */ +export async function sendManualReminders(roundId: string): Promise { + let sent = 0 + let errors = 0 + + const round = await prisma.round.findUnique({ + where: { id: roundId }, + select: { + id: true, + name: true, + windowCloseAt: true, + competition: { select: { name: true } }, + }, + }) + + if (!round) return { sent, errors } + + // Find jurors with incomplete assignments + const incompleteAssignments = await prisma.assignment.findMany({ + where: { roundId, isCompleted: false }, + select: { userId: true }, + }) + + const userIds = [...new Set(incompleteAssignments.map((a) => a.userId))] + if (userIds.length === 0) return { sent, errors } + + // Deduplicate: only one MANUAL reminder per juror per round + const existingManual = await prisma.reminderLog.findMany({ + where: { roundId, type: 'MANUAL', userId: { in: userIds } }, + select: { userId: true }, + }) + const alreadySent = new Set(existingManual.map((r) => r.userId)) + const usersToNotify = userIds.filter((id) => !alreadySent.has(id)) + + if (usersToNotify.length === 0) return { sent, errors } + + const users = await prisma.user.findMany({ + where: { id: { in: usersToNotify } }, + select: { id: true, name: true, email: true }, + }) + + const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com' + const deadlineStr = round.windowCloseAt + ? round.windowCloseAt.toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + timeZoneName: 'short', + }) + : undefined + + const pendingCounts = new Map() + for (const a of incompleteAssignments) { + pendingCounts.set(a.userId, (pendingCounts.get(a.userId) || 0) + 1) + } + + for (const user of users) { + const pendingCount = pendingCounts.get(user.id) || 0 + if (pendingCount === 0) continue + + try { + const deadlineNote = deadlineStr ? ` The deadline is ${deadlineStr}.` : '' + await sendStyledNotificationEmail( + user.email, + user.name || '', + 'REMINDER_24H', + { + name: user.name || undefined, + title: `Evaluation Reminder - ${round.name}`, + message: `You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''} for ${round.name}.${deadlineNote}`, + linkUrl: `${baseUrl}/jury/rounds/${round.id}/assignments`, + metadata: { + pendingCount, + roundName: round.name, + ...(deadlineStr && { deadline: deadlineStr }), + }, + } + ) + + await prisma.reminderLog.create({ + data: { roundId, userId: user.id, type: 'MANUAL' }, + }) + + sent++ + } catch (error) { + console.error( + `Failed to send manual reminder to ${user.email} for round ${round.name}:`, + error + ) + errors++ + } + } + + return { sent, errors } +} + /** * Find active stages with approaching deadlines and send reminders - * to jurors who have incomplete assignments. + * to jurors who have incomplete assignments. (Used by cron job) */ export async function processEvaluationReminders(roundId?: string): Promise { const now = new Date()