From 1103d424399ad9afa3a0d0b25117bd1ff892ce1b Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 4 Mar 2026 13:29:22 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20admin=20UX=20improvements=20=E2=80=94?= =?UTF-8?q?=20notify=20buttons,=20eval=20config,=20round=20finalization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom body support for advancement/rejection notification emails, evaluation config toggle fix, user actions improvements, round finalization with reorder support, project detail page enhancements, award pool duplicate prevention. Co-Authored-By: Claude Opus 4.6 --- src/app/(admin)/admin/projects/[id]/page.tsx | 310 +++++++++++++++--- src/components/admin/members-content.tsx | 2 + .../admin/round/notify-advanced-button.tsx | 42 ++- .../admin/round/notify-rejected-button.tsx | 42 ++- .../admin/rounds/config/evaluation-config.tsx | 14 +- src/components/admin/user-actions.tsx | 89 +++-- src/server/routers/round.ts | 191 ++++++----- src/server/routers/specialAward.ts | 86 +++-- src/server/services/round-finalization.ts | 91 ++--- src/server/utils/image-upload.ts | 2 +- src/types/competition-configs.ts | 2 + 11 files changed, 606 insertions(+), 265 deletions(-) diff --git a/src/app/(admin)/admin/projects/[id]/page.tsx b/src/app/(admin)/admin/projects/[id]/page.tsx index b821c1e..bcfff41 100644 --- a/src/app/(admin)/admin/projects/[id]/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/page.tsx @@ -23,6 +23,29 @@ import { TableHeader, TableRow, } from '@/components/ui/table' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' import { FileViewer } from '@/components/shared/file-viewer' import { FileUpload } from '@/components/shared/file-upload' import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url' @@ -37,7 +60,6 @@ import { Users, FileText, Calendar, - Clock, BarChart3, ThumbsUp, ThumbsDown, @@ -50,9 +72,11 @@ import { Loader2, ScanSearch, Eye, + Plus, + X, } from 'lucide-react' import { toast } from 'sonner' -import { formatDate, formatDateOnly } from '@/lib/utils' +import { formatDateOnly } from '@/lib/utils' interface PageProps { params: Promise<{ id: string }> @@ -121,6 +145,42 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const [selectedEvalAssignment, setSelectedEvalAssignment] = useState(null) + // State for add member dialog + const [addMemberOpen, setAddMemberOpen] = useState(false) + const [addMemberForm, setAddMemberForm] = useState({ + email: '', + name: '', + role: 'MEMBER' as 'LEAD' | 'MEMBER' | 'ADVISOR', + title: '', + sendInvite: true, + }) + + // State for remove member confirmation + const [removingMemberId, setRemovingMemberId] = useState(null) + + const addTeamMember = trpc.project.addTeamMember.useMutation({ + onSuccess: () => { + toast.success('Team member added') + setAddMemberOpen(false) + setAddMemberForm({ email: '', name: '', role: 'MEMBER', title: '', sendInvite: true }) + utils.project.getFullDetail.invalidate({ id: projectId }) + }, + onError: (err) => { + toast.error(err.message || 'Failed to add team member') + }, + }) + + const removeTeamMember = trpc.project.removeTeamMember.useMutation({ + onSuccess: () => { + toast.success('Team member removed') + setRemovingMemberId(null) + utils.project.getFullDetail.invalidate({ id: projectId }) + }, + onError: (err) => { + toast.error(err.message || 'Failed to remove team member') + }, + }) + if (isLoading) { return } @@ -184,9 +244,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {

{project.title}

- - {(project.status ?? 'SUBMITTED').replace('_', ' ')} - + {(() => { + const prs = (project as any).projectRoundStates ?? [] + if (!prs.length) return Submitted + if (prs.some((p: any) => p.state === 'REJECTED')) return Rejected + const latest = prs[0] + return {latest.round.name} + })()} {project.teamName && (

{project.teamName}

@@ -430,53 +494,203 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { {/* Team Members Section */} - {project.teamMembers && project.teamMembers.length > 0 && ( - - - -
- -
- -
- Team Members ({project.teamMembers.length}) -
-
-
- + + + +
+ +
+ +
+ Team Members ({project.teamMembers?.length ?? 0}) +
+ +
+
+ + {project.teamMembers && project.teamMembers.length > 0 ? (
- {project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null } }) => ( -
- {member.role === 'LEAD' ? ( -
- -
- ) : ( - - )} -
-
-

- {member.user.name || 'Unnamed'} -

- - {member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'} - -
-

- {member.user.email} -

- {member.title && ( -

{member.title}

+ {project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null } }) => { + const isLastLead = + member.role === 'LEAD' && + project.teamMembers.filter((m: { role: string }) => m.role === 'LEAD').length <= 1 + return ( +
+ {member.role === 'LEAD' ? ( +
+ +
+ ) : ( + )} +
+
+

+ {member.user.name || 'Unnamed'} +

+ + {member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'} + +
+

+ {member.user.email} +

+ {member.title && ( +

{member.title}

+ )} +
+ + + + + + + + {isLastLead && ( + + Cannot remove the last team lead + + )} + +
-
- ))} + ) + })}
- - - - )} + ) : ( +

No team members yet.

+ )} + + + + + {/* Add Member Dialog */} + + + + Add Team Member + +
+
+ + setAddMemberForm((f) => ({ ...f, email: e.target.value }))} + /> +
+
+ + setAddMemberForm((f) => ({ ...f, name: e.target.value }))} + /> +
+
+ + +
+
+ + setAddMemberForm((f) => ({ ...f, title: e.target.value }))} + /> +
+
+ + setAddMemberForm((f) => ({ ...f, sendInvite: checked === true })) + } + /> + +
+
+ + + + +
+
+ + {/* Remove Member Confirmation Dialog */} + { if (!open) setRemovingMemberId(null) }}> + + + Remove Team Member + +

+ Are you sure you want to remove this team member? This action cannot be undone. +

+ + + + +
+
{/* Mentor Assignment Section */} {project.wantsMentorship && ( diff --git a/src/components/admin/members-content.tsx b/src/components/admin/members-content.tsx index 8d4aa63..64d7b71 100644 --- a/src/components/admin/members-content.tsx +++ b/src/components/admin/members-content.tsx @@ -432,6 +432,7 @@ export function MembersContent() { userEmail={user.email} userStatus={user.status} userRole={user.role as RoleValue} + userRoles={(user as unknown as { roles?: RoleValue[] }).roles} currentUserRole={currentUserRole} /> @@ -524,6 +525,7 @@ export function MembersContent() { userEmail={user.email} userStatus={user.status} userRole={user.role as RoleValue} + userRoles={(user as unknown as { roles?: RoleValue[] }).roles} currentUserRole={currentUserRole} /> diff --git a/src/components/admin/round/notify-advanced-button.tsx b/src/components/admin/round/notify-advanced-button.tsx index 07dbc64..5849fb4 100644 --- a/src/components/admin/round/notify-advanced-button.tsx +++ b/src/components/admin/round/notify-advanced-button.tsx @@ -4,6 +4,8 @@ import { useState } from 'react' import { trpc } from '@/lib/trpc/client' import { toast } from 'sonner' import { Trophy } from 'lucide-react' +import { Switch } from '@/components/ui/switch' +import { Label } from '@/components/ui/label' import { EmailPreviewDialog } from './email-preview-dialog' interface NotifyAdvancedButtonProps { @@ -14,9 +16,10 @@ interface NotifyAdvancedButtonProps { export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedButtonProps) { const [open, setOpen] = useState(false) const [customMessage, setCustomMessage] = useState() + const [fullCustomBody, setFullCustomBody] = useState(false) const preview = trpc.round.previewAdvancementEmail.useQuery( - { roundId, targetRoundId, customMessage }, + { roundId, targetRoundId, customMessage, fullCustomBody }, { enabled: open } ) @@ -32,18 +35,31 @@ export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedB return ( <> - +
+ +
- +
sendMutation.mutate({ roundId, targetRoundId, customMessage: msg })} + onSend={(msg) => sendMutation.mutate({ roundId, targetRoundId, customMessage: msg, fullCustomBody })} isSending={sendMutation.isPending} onRefreshPreview={(msg) => setCustomMessage(msg)} /> diff --git a/src/components/admin/round/notify-rejected-button.tsx b/src/components/admin/round/notify-rejected-button.tsx index d19c3bc..8b806c1 100644 --- a/src/components/admin/round/notify-rejected-button.tsx +++ b/src/components/admin/round/notify-rejected-button.tsx @@ -4,6 +4,8 @@ import { useState } from 'react' import { trpc } from '@/lib/trpc/client' import { toast } from 'sonner' import { XCircle } from 'lucide-react' +import { Switch } from '@/components/ui/switch' +import { Label } from '@/components/ui/label' import { EmailPreviewDialog } from './email-preview-dialog' interface NotifyRejectedButtonProps { @@ -13,9 +15,10 @@ interface NotifyRejectedButtonProps { export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) { const [open, setOpen] = useState(false) const [customMessage, setCustomMessage] = useState() + const [fullCustomBody, setFullCustomBody] = useState(false) const preview = trpc.round.previewRejectionEmail.useQuery( - { roundId, customMessage }, + { roundId, customMessage, fullCustomBody }, { enabled: open } ) @@ -31,18 +34,31 @@ export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) { return ( <> - +
+ +
- + sendMutation.mutate({ roundId, customMessage: msg })} + onSend={(msg) => sendMutation.mutate({ roundId, customMessage: msg, fullCustomBody })} isSending={sendMutation.isPending} onRefreshPreview={(msg) => setCustomMessage(msg)} /> diff --git a/src/components/admin/rounds/config/evaluation-config.tsx b/src/components/admin/rounds/config/evaluation-config.tsx index 7362e57..489dfbc 100644 --- a/src/components/admin/rounds/config/evaluation-config.tsx +++ b/src/components/admin/rounds/config/evaluation-config.tsx @@ -26,7 +26,7 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) { } const visConfig = (config.applicantVisibility as { - enabled?: boolean; showGlobalScore?: boolean; showCriterionScores?: boolean; showFeedbackText?: boolean + enabled?: boolean; showGlobalScore?: boolean; showCriterionScores?: boolean; showFeedbackText?: boolean; hideFromRejected?: boolean }) ?? {} const updateVisibility = (key: string, value: unknown) => { @@ -293,6 +293,18 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) { /> +
+
+ +

Applicants whose project was rejected will not see evaluations from this round

+
+ updateVisibility('hideFromRejected', v)} + /> +
+

Evaluations are only visible to applicants after this round closes.

diff --git a/src/components/admin/user-actions.tsx b/src/components/admin/user-actions.tsx index 043fe26..55df33c 100644 --- a/src/components/admin/user-actions.tsx +++ b/src/components/admin/user-actions.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, + DropdownMenuCheckboxItem, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, @@ -32,7 +33,6 @@ import { Trash2, Loader2, Shield, - Check, } from 'lucide-react' type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' @@ -50,10 +50,11 @@ interface UserActionsProps { userEmail: string userStatus: string userRole: Role + userRoles?: Role[] currentUserRole?: Role } -export function UserActions({ userId, userEmail, userStatus, userRole, currentUserRole }: UserActionsProps) { +export function UserActions({ userId, userEmail, userStatus, userRole, userRoles, currentUserRole }: UserActionsProps) { const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [isSending, setIsSending] = useState(false) @@ -64,13 +65,13 @@ export function UserActions({ userId, userEmail, userStatus, userRole, currentUs utils.user.list.invalidate() }, }) - const updateUser = trpc.user.update.useMutation({ + const updateRoles = trpc.user.updateRoles.useMutation({ onSuccess: () => { utils.user.list.invalidate() - toast.success('Role updated successfully') + toast.success('Roles updated successfully') }, onError: (error) => { - toast.error(error.message || 'Failed to update role') + toast.error(error.message || 'Failed to update roles') }, }) @@ -88,9 +89,20 @@ export function UserActions({ userId, userEmail, userStatus, userRole, currentUs // Can this user's role be changed by the current user? const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole)) - const handleRoleChange = (newRole: Role) => { - if (newRole === userRole) return - updateUser.mutate({ id: userId, role: newRole }) + // Current roles for this user (array or fallback to single role) + const currentRoles: Role[] = userRoles?.length ? userRoles : [userRole] + + const handleToggleRole = (role: Role) => { + const has = currentRoles.includes(role) + let newRoles: Role[] + if (has) { + // Don't allow removing the last role + if (currentRoles.length <= 1) return + newRoles = currentRoles.filter(r => r !== role) + } else { + newRoles = [...currentRoles, role] + } + updateRoles.mutate({ userId, roles: newRoles }) } const handleSendInvitation = async () => { @@ -144,22 +156,20 @@ export function UserActions({ userId, userEmail, userStatus, userRole, currentUs {canChangeRole && ( - + - {updateUser.isPending ? 'Updating...' : 'Change Role'} + {updateRoles.isPending ? 'Updating...' : 'Roles'} {getAvailableRoles().map((role) => ( - handleRoleChange(role)} - disabled={role === userRole} + checked={currentRoles.includes(role)} + onCheckedChange={() => handleToggleRole(role)} + disabled={currentRoles.includes(role) && currentRoles.length <= 1} > - {role === userRole && } - - {ROLE_LABELS[role]} - - + {ROLE_LABELS[role]} + ))} @@ -214,6 +224,7 @@ interface UserMobileActionsProps { userEmail: string userStatus: string userRole: Role + userRoles?: Role[] currentUserRole?: Role } @@ -222,23 +233,25 @@ export function UserMobileActions({ userEmail, userStatus, userRole, + userRoles, currentUserRole, }: UserMobileActionsProps) { const [isSending, setIsSending] = useState(false) const utils = trpc.useUtils() const sendInvitation = trpc.user.sendInvitation.useMutation() - const updateUser = trpc.user.update.useMutation({ + const updateRoles = trpc.user.updateRoles.useMutation({ onSuccess: () => { utils.user.list.invalidate() - toast.success('Role updated successfully') + toast.success('Roles updated successfully') }, onError: (error) => { - toast.error(error.message || 'Failed to update role') + toast.error(error.message || 'Failed to update roles') }, }) const isSuperAdmin = currentUserRole === 'SUPER_ADMIN' const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole)) + const currentRoles: Role[] = userRoles?.length ? userRoles : [userRole] const handleSendInvitation = async () => { if (userStatus !== 'NONE' && userStatus !== 'INVITED') { @@ -283,21 +296,31 @@ export function UserMobileActions({ {canChangeRole && ( - + ).map((role) => { + const isActive = currentRoles.includes(role) + return ( + + ) + })} + )} ) diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts index c6c5708..c24643b 100644 --- a/src/server/routers/round.ts +++ b/src/server/routers/round.ts @@ -9,10 +9,11 @@ import { createBulkNotifications } from '../services/in-app-notification' import { getAdvancementNotificationTemplate, getRejectionNotificationTemplate, - sendStyledNotificationEmail, sendInvitationEmail, getBaseUrl, } from '@/lib/email' +import { sendBatchNotifications } from '../services/notification-sender' +import type { NotificationItem } from '../services/notification-sender' import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite' import { openWindow, @@ -812,10 +813,11 @@ export const roundRouter = router({ roundId: z.string(), targetRoundId: z.string().optional(), customMessage: z.string().optional(), + fullCustomBody: z.boolean().default(false), }) ) .query(async ({ ctx, input }) => { - const { roundId, targetRoundId, customMessage } = input + const { roundId, targetRoundId, customMessage, fullCustomBody } = input const currentRound = await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId }, @@ -865,7 +867,9 @@ export const roundRouter = router({ 'Your Project', currentRound.name, toRoundName, - customMessage || undefined + customMessage || undefined, + undefined, + fullCustomBody, ) return { html: template.html, subject: template.subject, recipientCount } @@ -877,11 +881,12 @@ export const roundRouter = router({ roundId: z.string(), targetRoundId: z.string().optional(), customMessage: z.string().optional(), + fullCustomBody: z.boolean().default(false), projectIds: z.array(z.string()).optional(), }) ) .mutation(async ({ ctx, input }) => { - const { roundId, targetRoundId, customMessage } = input + const { roundId, targetRoundId, customMessage, fullCustomBody } = input const currentRound = await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId }, @@ -922,48 +927,47 @@ export const roundRouter = router({ }, }) - let sent = 0 - let failed = 0 const allUserIds = new Set() + const items: NotificationItem[] = [] for (const project of projects) { - const recipients = new Map() + const recipients = new Map() for (const tm of project.teamMembers) { if (tm.user.email) { - recipients.set(tm.user.email, tm.user.name) + recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id }) allUserIds.add(tm.user.id) } } if (recipients.size === 0 && project.submittedByEmail) { - recipients.set(project.submittedByEmail, null) + recipients.set(project.submittedByEmail, { name: null, userId: '' }) } - for (const [email, name] of recipients) { - try { - await sendStyledNotificationEmail( - email, - name || '', - 'ADVANCEMENT_NOTIFICATION', - { - title: 'Your project has advanced!', - message: '', - linkUrl: '/applicant', - metadata: { - projectName: project.title, - fromRoundName: currentRound.name, - toRoundName, - customMessage: customMessage || undefined, - }, - } - ) - sent++ - } catch (err) { - console.error(`[sendAdvancementNotifications] Failed for ${email}:`, err) - failed++ - } + for (const [email, { name, userId }] of recipients) { + items.push({ + email, + name: name || '', + type: 'ADVANCEMENT_NOTIFICATION', + context: { + title: 'Your project has advanced!', + message: '', + linkUrl: '/applicant', + metadata: { + projectName: project.title, + fromRoundName: currentRound.name, + toRoundName, + customMessage: customMessage || undefined, + fullCustomBody, + }, + }, + projectId: project.id, + userId: userId || undefined, + roundId, + }) } } + const result = await sendBatchNotifications(items) + // Create in-app notifications if (allUserIds.size > 0) { void createBulkNotifications({ @@ -985,12 +989,12 @@ export const roundRouter = router({ action: 'SEND_ADVANCEMENT_NOTIFICATIONS', entityType: 'Round', entityId: roundId, - detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage }, + detailsJson: { sent: result.sent, failed: result.failed, projectCount: projectIds.length, customMessage: !!customMessage }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) - return { sent, failed } + return { sent: result.sent, failed: result.failed } }), previewRejectionEmail: adminProcedure @@ -998,22 +1002,36 @@ export const roundRouter = router({ z.object({ roundId: z.string(), customMessage: z.string().optional(), + fullCustomBody: z.boolean().default(false), }) ) .query(async ({ ctx, input }) => { - const { roundId, customMessage } = input + const { roundId, customMessage, fullCustomBody } = input const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId }, - select: { name: true }, + select: { name: true, roundType: true }, }) - // Count recipients: team members of REJECTED projects - const projectStates = await ctx.prisma.projectRoundState.findMany({ - where: { roundId, state: 'REJECTED' }, - select: { projectId: true }, - }) - const projectIds = projectStates.map((ps) => ps.projectId) + // For FILTERING rounds, also count projects filtered out via FilteringResult + let projectIds: string[] + if (round.roundType === 'FILTERING') { + const fromPRS = await ctx.prisma.projectRoundState.findMany({ + where: { roundId, state: 'REJECTED' }, + select: { projectId: true }, + }) + const fromFR = await ctx.prisma.filteringResult.findMany({ + where: { roundId, finalOutcome: 'FILTERED_OUT' }, + select: { projectId: true }, + }) + projectIds = [...new Set([...fromPRS, ...fromFR].map((p) => p.projectId))] + } else { + const projectStates = await ctx.prisma.projectRoundState.findMany({ + where: { roundId, state: 'REJECTED' }, + select: { projectId: true }, + }) + projectIds = projectStates.map((ps) => ps.projectId) + } let recipientCount = 0 if (projectIds.length > 0) { @@ -1039,7 +1057,8 @@ export const roundRouter = router({ 'Team Member', 'Your Project', round.name, - customMessage || undefined + customMessage || undefined, + fullCustomBody, ) return { html: template.html, subject: template.subject, recipientCount } @@ -1050,21 +1069,36 @@ export const roundRouter = router({ z.object({ roundId: z.string(), customMessage: z.string().optional(), + fullCustomBody: z.boolean().default(false), }) ) .mutation(async ({ ctx, input }) => { - const { roundId, customMessage } = input + const { roundId, customMessage, fullCustomBody } = input const round = await ctx.prisma.round.findUniqueOrThrow({ where: { id: roundId }, - select: { name: true }, + select: { name: true, roundType: true }, }) - const projectStates = await ctx.prisma.projectRoundState.findMany({ - where: { roundId, state: 'REJECTED' }, - select: { projectId: true }, - }) - const projectIds = projectStates.map((ps) => ps.projectId) + // For FILTERING rounds, also include projects filtered out via FilteringResult + let projectIds: string[] + if (round.roundType === 'FILTERING') { + const fromPRS = await ctx.prisma.projectRoundState.findMany({ + where: { roundId, state: 'REJECTED' }, + select: { projectId: true }, + }) + const fromFR = await ctx.prisma.filteringResult.findMany({ + where: { roundId, finalOutcome: 'FILTERED_OUT' }, + select: { projectId: true }, + }) + projectIds = [...new Set([...fromPRS, ...fromFR].map((p) => p.projectId))] + } else { + const projectStates = await ctx.prisma.projectRoundState.findMany({ + where: { roundId, state: 'REJECTED' }, + select: { projectId: true }, + }) + projectIds = projectStates.map((ps) => ps.projectId) + } if (projectIds.length === 0) { return { sent: 0, failed: 0 } @@ -1082,47 +1116,46 @@ export const roundRouter = router({ }, }) - let sent = 0 - let failed = 0 const allUserIds = new Set() + const items: NotificationItem[] = [] for (const project of projects) { - const recipients = new Map() + const recipients = new Map() for (const tm of project.teamMembers) { if (tm.user.email) { - recipients.set(tm.user.email, tm.user.name) + recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id }) allUserIds.add(tm.user.id) } } if (recipients.size === 0 && project.submittedByEmail) { - recipients.set(project.submittedByEmail, null) + recipients.set(project.submittedByEmail, { name: null, userId: '' }) } - for (const [email, name] of recipients) { - try { - await sendStyledNotificationEmail( - email, - name || '', - 'REJECTION_NOTIFICATION', - { - title: 'Update on your application', - message: '', - linkUrl: '/applicant', - metadata: { - projectName: project.title, - roundName: round.name, - customMessage: customMessage || undefined, - }, - } - ) - sent++ - } catch (err) { - console.error(`[sendRejectionNotifications] Failed for ${email}:`, err) - failed++ - } + for (const [email, { name, userId }] of recipients) { + items.push({ + email, + name: name || '', + type: 'REJECTION_NOTIFICATION', + context: { + title: 'Update on your application', + message: '', + linkUrl: '/applicant', + metadata: { + projectName: project.title, + roundName: round.name, + customMessage: customMessage || undefined, + fullCustomBody, + }, + }, + projectId: project.id, + userId: userId || undefined, + roundId, + }) } } + const result = await sendBatchNotifications(items) + // In-app notifications if (allUserIds.size > 0) { void createBulkNotifications({ @@ -1142,12 +1175,12 @@ export const roundRouter = router({ action: 'SEND_REJECTION_NOTIFICATIONS', entityType: 'Round', entityId: roundId, - detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage }, + detailsJson: { sent: result.sent, failed: result.failed, projectCount: projectIds.length, customMessage: !!customMessage }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) - return { sent, failed } + return { sent: result.sent, failed: result.failed } }), getBulkInvitePreview: adminProcedure diff --git a/src/server/routers/specialAward.ts b/src/server/routers/specialAward.ts index 5558d9a..cb24d55 100644 --- a/src/server/routers/specialAward.ts +++ b/src/server/routers/specialAward.ts @@ -4,8 +4,10 @@ import { Prisma } from '@prisma/client' import { router, protectedProcedure, adminProcedure } from '../trpc' import { logAudit } from '../utils/audit' import { processEligibilityJob } from '../services/award-eligibility-job' -import { sendStyledNotificationEmail, getAwardSelectionNotificationTemplate } from '@/lib/email' +import { getAwardSelectionNotificationTemplate } from '@/lib/email' import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite' +import { sendBatchNotifications } from '../services/notification-sender' +import type { NotificationItem } from '../services/notification-sender' import type { PrismaClient } from '@prisma/client' /** @@ -1270,8 +1272,18 @@ export const specialAwardRouter = router({ }) // Get eligible projects that haven't been notified yet + // Exclude projects that have been rejected at any stage const eligibilities = await ctx.prisma.awardEligibility.findMany({ - where: { awardId: input.awardId, eligible: true, notifiedAt: null }, + where: { + awardId: input.awardId, + eligible: true, + notifiedAt: null, + project: { + projectRoundStates: { + none: { state: 'REJECTED' }, + }, + }, + }, select: { id: true, projectId: true, @@ -1324,12 +1336,12 @@ export const specialAwardRouter = router({ }) } - // Send emails - let emailsSent = 0 - let emailsFailed = 0 + // Build notification items — track which eligibility each email belongs to + const items: NotificationItem[] = [] + const eligibilityEmailMap = new Map>() // eligibilityId → Set for (const e of eligibilities) { - const recipients: Array<{ id: string; email: string; name: string | null; passwordHash: string | null }> = [] + const recipients: Array<{ id: string; email: string; name: string | null }> = [] if (e.project.submittedBy) recipients.push(e.project.submittedBy) for (const tm of e.project.teamMembers) { if (!recipients.some((r) => r.id === tm.user.id)) { @@ -1337,39 +1349,46 @@ export const specialAwardRouter = router({ } } + const emails = new Set() for (const recipient of recipients) { const token = tokenMap.get(recipient.id) const accountUrl = token ? `/accept-invite?token=${token}` : undefined + emails.add(recipient.email) - try { - await sendStyledNotificationEmail( - recipient.email, - recipient.name || '', - 'AWARD_SELECTION_NOTIFICATION', - { - title: `Under consideration for ${award.name}`, - message: input.customMessage || '', - metadata: { - projectName: e.project.title, - awardName: award.name, - customMessage: input.customMessage, - accountUrl, - }, + items.push({ + email: recipient.email, + name: recipient.name || '', + type: 'AWARD_SELECTION_NOTIFICATION', + context: { + title: `Under consideration for ${award.name}`, + message: input.customMessage || '', + metadata: { + projectName: e.project.title, + awardName: award.name, + customMessage: input.customMessage, + accountUrl, }, - ) - emailsSent++ - } catch (err) { - console.error(`[award-notify] Failed to email ${recipient.email}:`, err) - emailsFailed++ - } + }, + projectId: e.projectId, + userId: recipient.id, + }) } + eligibilityEmailMap.set(e.id, emails) } - // Stamp notifiedAt on all processed eligibilities to prevent re-notification - const notifiedIds = eligibilities.map((e) => e.id) - if (notifiedIds.length > 0) { + const result = await sendBatchNotifications(items) + + // Determine which eligibilities had zero failures + const failedEmails = new Set(result.errors.map((e) => e.email)) + const successfulEligibilityIds: string[] = [] + for (const [eligId, emails] of eligibilityEmailMap) { + const hasFailure = [...emails].some((email) => failedEmails.has(email)) + if (!hasFailure) successfulEligibilityIds.push(eligId) + } + + if (successfulEligibilityIds.length > 0) { await ctx.prisma.awardEligibility.updateMany({ - where: { id: { in: notifiedIds } }, + where: { id: { in: successfulEligibilityIds } }, data: { notifiedAt: new Date() }, }) } @@ -1383,14 +1402,15 @@ export const specialAwardRouter = router({ detailsJson: { action: 'NOTIFY_ELIGIBLE_PROJECTS', eligibleCount: eligibilities.length, - emailsSent, - emailsFailed, + emailsSent: result.sent, + emailsFailed: result.failed, + failedRecipients: result.errors.length > 0 ? result.errors.map((e) => e.email) : undefined, }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }) - return { notified: eligibilities.length, emailsSent, emailsFailed } + return { notified: successfulEligibilityIds.length, emailsSent: result.sent, emailsFailed: result.failed } }), /** diff --git a/src/server/services/round-finalization.ts b/src/server/services/round-finalization.ts index 3a10bc0..b090fe9 100644 --- a/src/server/services/round-finalization.ts +++ b/src/server/services/round-finalization.ts @@ -10,12 +10,11 @@ import type { PrismaClient, ProjectRoundStateValue, RoundType, Prisma } from '@prisma/client' import { transitionProject, isTerminalState } from './round-engine' import { logAudit } from '@/server/utils/audit' -import { - sendStyledNotificationEmail, - getRejectionNotificationTemplate, -} from '@/lib/email' +import { getRejectionNotificationTemplate } from '@/lib/email' import { createBulkNotifications } from '../services/in-app-notification' import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite' +import { sendBatchNotifications } from './notification-sender' +import type { NotificationItem } from './notification-sender' // ─── Types ────────────────────────────────────────────────────────────────── @@ -724,6 +723,7 @@ export async function confirmFinalization( const advancedUserIds = new Set() const rejectedUserIds = new Set() + const notificationItems: NotificationItem[] = [] for (const prs of finalizedStates) { type Recipient = { email: string; name: string | null; userId: string | null } @@ -748,53 +748,56 @@ export async function confirmFinalization( } for (const recipient of recipients) { - try { - if (prs.state === 'PASSED') { - // Build account creation URL for passwordless users - const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined - const accountUrl = token ? `/accept-invite?token=${token}` : undefined + if (prs.state === 'PASSED') { + const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined + const accountUrl = token ? `/accept-invite?token=${token}` : undefined - await sendStyledNotificationEmail( - recipient.email, - recipient.name || '', - 'ADVANCEMENT_NOTIFICATION', - { - title: 'Your project has advanced!', - message: '', - linkUrl: accountUrl || '/applicant', - metadata: { - projectName: prs.project.title, - fromRoundName: round.name, - toRoundName: targetRoundName, - customMessage: options.advancementMessage || undefined, - accountUrl, - }, + notificationItems.push({ + email: recipient.email, + name: recipient.name || '', + type: 'ADVANCEMENT_NOTIFICATION', + context: { + title: 'Your project has advanced!', + message: '', + linkUrl: accountUrl || '/applicant', + metadata: { + projectName: prs.project.title, + fromRoundName: round.name, + toRoundName: targetRoundName, + customMessage: options.advancementMessage || undefined, + accountUrl, }, - ) - } else { - await sendStyledNotificationEmail( - recipient.email, - recipient.name || '', - 'REJECTION_NOTIFICATION', - { - title: `Update on your application: "${prs.project.title}"`, - message: '', - metadata: { - projectName: prs.project.title, - roundName: round.name, - customMessage: options.rejectionMessage || undefined, - }, + }, + projectId: prs.projectId, + userId: recipient.userId || undefined, + roundId: round.id, + }) + } else { + notificationItems.push({ + email: recipient.email, + name: recipient.name || '', + type: 'REJECTION_NOTIFICATION', + context: { + title: `Update on your application: "${prs.project.title}"`, + message: '', + metadata: { + projectName: prs.project.title, + roundName: round.name, + customMessage: options.rejectionMessage || undefined, }, - ) - } - emailsSent++ - } catch (err) { - console.error(`[Finalization] Email failed for ${recipient.email}:`, err) - emailsFailed++ + }, + projectId: prs.projectId, + userId: recipient.userId || undefined, + roundId: round.id, + }) } } } + const batchResult = await sendBatchNotifications(notificationItems) + emailsSent = batchResult.sent + emailsFailed = batchResult.failed + // Create in-app notifications if (advancedUserIds.size > 0) { void createBulkNotifications({ diff --git a/src/server/utils/image-upload.ts b/src/server/utils/image-upload.ts index 96339a8..f2aeead 100644 --- a/src/server/utils/image-upload.ts +++ b/src/server/utils/image-upload.ts @@ -80,7 +80,7 @@ export async function getImageUploadUrl( if (!isValidImageType(contentType)) { throw new TRPCError({ code: 'BAD_REQUEST', - message: 'Invalid image type. Allowed: JPEG, PNG, GIF, WebP', + message: `Invalid image type: "${contentType}". Allowed: JPEG, PNG, GIF, WebP, SVG`, }) } diff --git a/src/types/competition-configs.ts b/src/types/competition-configs.ts index e89ccac..4b37cbb 100644 --- a/src/types/competition-configs.ts +++ b/src/types/competition-configs.ts @@ -117,12 +117,14 @@ export const EvaluationConfigSchema = z.object({ showGlobalScore: z.boolean().default(false), showCriterionScores: z.boolean().default(false), showFeedbackText: z.boolean().default(false), + hideFromRejected: z.boolean().default(false), }) .default({ enabled: false, showGlobalScore: false, showCriterionScores: false, showFeedbackText: false, + hideFromRejected: false, }), advancementMode: z