-
-
- {project.title}
-
- {project.teamName && (
-
- {project.teamName}
+
+
+
+
+ {project.title}
- )}
+ {project.teamName && (
+
+ {project.teamName}
+
+ )}
+
diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts
index b2badc0..a51d334 100644
--- a/src/server/routers/analytics.ts
+++ b/src/server/routers/analytics.ts
@@ -2,6 +2,7 @@ import { z } from 'zod'
import { router, observerProcedure } from '../trpc'
import { normalizeCountryToCode } from '@/lib/countries'
import { getUserAvatarUrl } from '../utils/avatar-url'
+import { getProjectLogoUrl } from '../utils/project-logo-url'
import { aggregateVotes } from '../services/deliberation'
const editionOrRoundInput = z.object({
@@ -1020,6 +1021,8 @@ export const analyticsRouter = router({
teamName: true,
status: true,
country: true,
+ logoKey: true,
+ logoProvider: true,
assignments: {
select: {
roundId: true,
@@ -1048,7 +1051,7 @@ export const analyticsRouter = router({
ctx.prisma.project.count({ where }),
])
- const mapped = projects.map((p) => {
+ const mapped = await Promise.all(projects.map(async (p) => {
const submitted = p.assignments
.map((a) => a.evaluation)
.filter((e) => e?.status === 'SUBMITTED')
@@ -1080,6 +1083,8 @@ export const analyticsRouter = router({
else if (drafts.length > 0) observerStatus = 'UNDER_REVIEW'
else observerStatus = 'NOT_REVIEWED'
+ const logoUrl = await getProjectLogoUrl(p.logoKey, p.logoProvider)
+
return {
id: p.id,
title: p.title,
@@ -1087,12 +1092,13 @@ export const analyticsRouter = router({
status: p.status,
observerStatus,
country: p.country,
+ logoUrl,
roundId: furthestRoundState?.round?.id ?? roundAssignment?.round?.id ?? '',
roundName: furthestRoundState?.round?.name ?? roundAssignment?.round?.name ?? '',
averageScore,
evaluationCount: submitted.length,
}
- })
+ }))
// Filter by observer-derived status in JS
const observerStatusFilter = input.status && OBSERVER_DERIVED_STATUSES.includes(input.status)
diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts
index ef58183..4f7a9c4 100644
--- a/src/server/routers/project.ts
+++ b/src/server/routers/project.ts
@@ -4,13 +4,17 @@ import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, userHasRole } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url'
+import { attachProjectLogoUrls } from '../utils/project-logo-url'
import {
notifyProjectTeam,
NotificationTypes,
} from '../services/in-app-notification'
import { normalizeCountryToCode } from '@/lib/countries'
import { logAudit } from '../utils/audit'
-import { sendInvitationEmail } from '@/lib/email'
+import { sendInvitationEmail, getBaseUrl } from '@/lib/email'
+import { generateInviteToken, getInviteExpiryMs } from '../utils/invite'
+import { sendBatchNotifications } from '../services/notification-sender'
+import type { NotificationItem } from '../services/notification-sender'
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
const STATUSES_WITH_TEAM_NOTIFICATIONS = ['SEMIFINALIST', 'FINALIST', 'REJECTED'] as const
@@ -140,7 +144,7 @@ export const projectRouter = router({
}
}
- const [projects, total, statusGroups] = await Promise.all([
+ const [projects, total, roundStateCounts] = await Promise.all([
ctx.prisma.project.findMany({
where,
skip,
@@ -149,24 +153,33 @@ export const projectRouter = router({
include: {
program: { select: { id: true, name: true, year: true } },
_count: { select: { assignments: true, files: true } },
+ projectRoundStates: {
+ select: {
+ state: true,
+ round: { select: { name: true, sortOrder: true } },
+ },
+ orderBy: { round: { sortOrder: 'desc' } },
+ },
},
}),
ctx.prisma.project.count({ where }),
- ctx.prisma.project.groupBy({
- by: ['status'],
- where,
+ ctx.prisma.projectRoundState.groupBy({
+ by: ['state'],
+ where: where.programId ? { project: { programId: where.programId as string } } : {},
_count: true,
}),
])
- // Build status counts from groupBy (across all pages)
+ // Build round-state counts
const statusCounts: Record
= {}
- for (const g of statusGroups) {
- statusCounts[g.status] = g._count
+ for (const g of roundStateCounts) {
+ statusCounts[g.state] = g._count
}
+ const projectsWithLogos = await attachProjectLogoUrls(projects)
+
return {
- projects,
+ projects: projectsWithLogos,
total,
page,
perPage,
@@ -1189,6 +1202,13 @@ export const projectRouter = router({
},
},
},
+ projectRoundStates: {
+ select: {
+ state: true,
+ round: { select: { name: true, sortOrder: true } },
+ },
+ orderBy: { round: { sortOrder: 'desc' } },
+ },
},
}),
ctx.prisma.projectTag.findMany({
@@ -1389,4 +1409,761 @@ export const projectRouter = router({
return project
}),
+
+ /**
+ * Add a team member to a project (admin only).
+ * Finds or creates user, then creates TeamMember record.
+ * Optionally sends invite email if user has no password set.
+ */
+ addTeamMember: adminProcedure
+ .input(
+ z.object({
+ projectId: z.string(),
+ email: z.string().email(),
+ name: z.string().min(1),
+ role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']),
+ title: z.string().optional(),
+ sendInvite: z.boolean().default(false),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { projectId, email, name, role, title, sendInvite } = input
+
+ // Verify project exists
+ await ctx.prisma.project.findUniqueOrThrow({
+ where: { id: projectId },
+ select: { id: true },
+ })
+
+ // Find or create user
+ let user = await ctx.prisma.user.findUnique({
+ where: { email: email.toLowerCase() },
+ select: { id: true, name: true, email: true, passwordHash: true, status: true },
+ })
+
+ if (!user) {
+ user = await ctx.prisma.user.create({
+ data: {
+ email: email.toLowerCase(),
+ name,
+ role: 'APPLICANT',
+ roles: ['APPLICANT'],
+ status: 'INVITED',
+ },
+ select: { id: true, name: true, email: true, passwordHash: true, status: true },
+ })
+ }
+
+ // Create TeamMember record
+ let teamMember
+ try {
+ teamMember = await ctx.prisma.teamMember.create({
+ data: {
+ projectId,
+ userId: user.id,
+ role,
+ title: title || null,
+ },
+ include: {
+ user: {
+ select: { id: true, name: true, email: true },
+ },
+ },
+ })
+ } catch (err) {
+ if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
+ throw new TRPCError({
+ code: 'CONFLICT',
+ message: 'This user is already a team member of this project',
+ })
+ }
+ throw err
+ }
+
+ // Send invite email if requested and user has no password
+ if (sendInvite && !user.passwordHash) {
+ try {
+ const token = generateInviteToken()
+ const expiryMs = await getInviteExpiryMs(ctx.prisma)
+ await ctx.prisma.user.update({
+ where: { id: user.id },
+ data: {
+ status: 'INVITED',
+ inviteToken: token,
+ inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
+ },
+ })
+
+ const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
+ const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
+ await sendInvitationEmail(email.toLowerCase(), name, inviteUrl, 'APPLICANT')
+ } catch {
+ // Email sending failure should not block member creation
+ console.error(`Failed to send invite to ${email}`)
+ }
+ }
+
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'ADD_TEAM_MEMBER',
+ entityType: 'Project',
+ entityId: projectId,
+ detailsJson: { memberId: user.id, email, role },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ })
+
+ return teamMember
+ }),
+
+ /**
+ * Remove a team member from a project (admin only).
+ * Prevents removing the last LEAD.
+ */
+ removeTeamMember: adminProcedure
+ .input(
+ z.object({
+ projectId: z.string(),
+ userId: z.string(),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { projectId, userId } = input
+
+ // Check if this is the last LEAD
+ const targetMember = await ctx.prisma.teamMember.findUniqueOrThrow({
+ where: { projectId_userId: { projectId, userId } },
+ select: { id: true, role: true },
+ })
+
+ if (targetMember.role === 'LEAD') {
+ const leadCount = await ctx.prisma.teamMember.count({
+ where: { projectId, role: 'LEAD' },
+ })
+ if (leadCount <= 1) {
+ throw new TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'Cannot remove the last team lead',
+ })
+ }
+ }
+
+ await ctx.prisma.teamMember.delete({
+ where: { projectId_userId: { projectId, userId } },
+ })
+
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'REMOVE_TEAM_MEMBER',
+ entityType: 'Project',
+ entityId: projectId,
+ detailsJson: { removedUserId: userId },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ })
+
+ return { success: true }
+ }),
+
+ // =========================================================================
+ // BULK NOTIFICATION ENDPOINTS
+ // =========================================================================
+
+ /**
+ * Get summary of projects eligible for bulk notifications.
+ * Returns counts for passed (by round), rejected, and award pool projects,
+ * plus how many have already been notified.
+ */
+ getBulkNotificationSummary: adminProcedure
+ .query(async ({ ctx }) => {
+ // 1. Passed projects grouped by round
+ const passedStates = await ctx.prisma.projectRoundState.findMany({
+ where: { state: 'PASSED' },
+ select: {
+ projectId: true,
+ roundId: true,
+ round: { select: { name: true, sortOrder: true, competition: { select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } } } } },
+ },
+ })
+
+ // Group by round and compute next round name
+ const passedByRound = new Map }>()
+ for (const ps of passedStates) {
+ if (!passedByRound.has(ps.roundId)) {
+ const rounds = ps.round.competition.rounds
+ const idx = rounds.findIndex((r) => r.id === ps.roundId)
+ const nextRound = rounds[idx + 1]
+ passedByRound.set(ps.roundId, {
+ roundId: ps.roundId,
+ roundName: ps.round.name,
+ nextRoundName: nextRound?.name ?? 'Next Round',
+ projectIds: new Set(),
+ })
+ }
+ passedByRound.get(ps.roundId)!.projectIds.add(ps.projectId)
+ }
+
+ const passed = [...passedByRound.values()].map((g) => ({
+ roundId: g.roundId,
+ roundName: g.roundName,
+ nextRoundName: g.nextRoundName,
+ projectCount: g.projectIds.size,
+ }))
+
+ // 2. Rejected projects (REJECTED in ProjectRoundState + FILTERED_OUT in FilteringResult)
+ const [rejectedPRS, filteredOut] = await Promise.all([
+ ctx.prisma.projectRoundState.findMany({
+ where: { state: 'REJECTED' },
+ select: { projectId: true },
+ }),
+ ctx.prisma.filteringResult.findMany({
+ where: {
+ OR: [
+ { finalOutcome: 'FILTERED_OUT' },
+ { outcome: 'FILTERED_OUT', finalOutcome: null },
+ ],
+ },
+ select: { projectId: true },
+ }),
+ ])
+ const rejectedProjectIds = new Set([
+ ...rejectedPRS.map((r) => r.projectId),
+ ...filteredOut.map((r) => r.projectId),
+ ])
+
+ // 3. Award pools
+ const awards = await ctx.prisma.specialAward.findMany({
+ select: {
+ id: true,
+ name: true,
+ _count: { select: { eligibilities: { where: { eligible: true } } } },
+ },
+ })
+ const awardPools = awards.map((a) => ({
+ awardId: a.id,
+ awardName: a.name,
+ eligibleCount: a._count.eligibilities,
+ }))
+
+ // 4. Already-sent counts from NotificationLog
+ const [advancementSent, rejectionSent] = await Promise.all([
+ ctx.prisma.notificationLog.count({
+ where: { type: 'ADVANCEMENT_NOTIFICATION', status: 'SENT' },
+ }),
+ ctx.prisma.notificationLog.count({
+ where: { type: 'REJECTION_NOTIFICATION', status: 'SENT' },
+ }),
+ ])
+
+ return {
+ passed,
+ rejected: { count: rejectedProjectIds.size },
+ awardPools,
+ alreadyNotified: { advancement: advancementSent, rejection: rejectionSent },
+ }
+ }),
+
+ /**
+ * Send bulk advancement notifications to all PASSED projects.
+ * Groups by round, determines next round, sends via batch sender.
+ * Skips projects that have already been notified (unless skipAlreadySent=false).
+ */
+ sendBulkPassedNotifications: adminProcedure
+ .input(
+ z.object({
+ customMessage: z.string().optional(),
+ fullCustomBody: z.boolean().default(false),
+ skipAlreadySent: z.boolean().default(true),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { customMessage, fullCustomBody, skipAlreadySent } = input
+
+ // Find all PASSED project round states
+ const passedStates = await ctx.prisma.projectRoundState.findMany({
+ where: { state: 'PASSED' },
+ select: {
+ projectId: true,
+ roundId: true,
+ round: {
+ select: {
+ name: true,
+ sortOrder: true,
+ competition: {
+ select: {
+ rounds: {
+ select: { id: true, name: true, sortOrder: true },
+ orderBy: { sortOrder: 'asc' },
+ },
+ },
+ },
+ },
+ },
+ },
+ })
+
+ // Get already-sent project IDs if needed
+ const alreadySentProjectIds = new Set()
+ if (skipAlreadySent) {
+ const sentLogs = await ctx.prisma.notificationLog.findMany({
+ where: { type: 'ADVANCEMENT_NOTIFICATION', status: 'SENT', projectId: { not: null } },
+ select: { projectId: true },
+ distinct: ['projectId'],
+ })
+ for (const log of sentLogs) {
+ if (log.projectId) alreadySentProjectIds.add(log.projectId)
+ }
+ }
+
+ // Group by round for next-round resolution
+ const roundMap = new Map()
+ const projectIds = new Set()
+ for (const ps of passedStates) {
+ if (skipAlreadySent && alreadySentProjectIds.has(ps.projectId)) continue
+ projectIds.add(ps.projectId)
+ if (!roundMap.has(ps.roundId)) {
+ const rounds = ps.round.competition.rounds
+ const idx = rounds.findIndex((r) => r.id === ps.roundId)
+ const nextRound = rounds[idx + 1]
+ roundMap.set(ps.roundId, {
+ roundName: ps.round.name,
+ nextRoundName: nextRound?.name ?? 'Next Round',
+ })
+ }
+ }
+
+ if (projectIds.size === 0) {
+ return { sent: 0, failed: 0, skipped: alreadySentProjectIds.size }
+ }
+
+ // Fetch projects with team members
+ const projects = await ctx.prisma.project.findMany({
+ where: { id: { in: [...projectIds] } },
+ select: {
+ id: true,
+ title: true,
+ submittedByEmail: true,
+ teamMembers: {
+ select: { user: { select: { id: true, email: true, name: true, passwordHash: true } } },
+ },
+ projectRoundStates: {
+ where: { state: 'PASSED' },
+ select: { roundId: true },
+ take: 1,
+ },
+ },
+ })
+
+ // For passwordless users: generate invite tokens
+ const baseUrl = getBaseUrl()
+ const passwordlessUserIds: string[] = []
+ for (const project of projects) {
+ for (const tm of project.teamMembers) {
+ if (!tm.user.passwordHash) {
+ passwordlessUserIds.push(tm.user.id)
+ }
+ }
+ }
+
+ const tokenMap = new Map()
+ if (passwordlessUserIds.length > 0) {
+ const expiryMs = await getInviteExpiryMs(ctx.prisma)
+ for (const userId of [...new Set(passwordlessUserIds)]) {
+ const token = generateInviteToken()
+ await ctx.prisma.user.update({
+ where: { id: userId },
+ data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs) },
+ })
+ tokenMap.set(userId, token)
+ }
+ }
+
+ // Build notification items
+ const items: NotificationItem[] = []
+ for (const project of projects) {
+ const roundId = project.projectRoundStates[0]?.roundId
+ const roundInfo = roundId ? roundMap.get(roundId) : undefined
+
+ const recipients = new Map()
+ for (const tm of project.teamMembers) {
+ if (tm.user.email) {
+ recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
+ }
+ }
+ if (recipients.size === 0 && project.submittedByEmail) {
+ recipients.set(project.submittedByEmail, { name: null, userId: '' })
+ }
+
+ for (const [email, { name, userId }] of recipients) {
+ const inviteToken = tokenMap.get(userId)
+ const accountUrl = inviteToken ? `${baseUrl}/accept-invite?token=${inviteToken}` : undefined
+
+ items.push({
+ email,
+ name: name || '',
+ type: 'ADVANCEMENT_NOTIFICATION',
+ context: {
+ title: 'Your project has advanced!',
+ message: '',
+ linkUrl: '/applicant',
+ metadata: {
+ projectName: project.title,
+ fromRoundName: roundInfo?.roundName ?? 'this round',
+ toRoundName: roundInfo?.nextRoundName ?? 'Next Round',
+ customMessage: customMessage || undefined,
+ fullCustomBody,
+ accountUrl,
+ },
+ },
+ projectId: project.id,
+ userId: userId || undefined,
+ roundId: roundId || undefined,
+ })
+ }
+ }
+
+ const result = await sendBatchNotifications(items)
+
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'SEND_BULK_PASSED_NOTIFICATIONS',
+ entityType: 'Project',
+ entityId: 'bulk',
+ detailsJson: {
+ sent: result.sent,
+ failed: result.failed,
+ projectCount: projectIds.size,
+ skipped: alreadySentProjectIds.size,
+ batchId: result.batchId,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ })
+
+ return { sent: result.sent, failed: result.failed, skipped: alreadySentProjectIds.size }
+ }),
+
+ /**
+ * Send bulk rejection notifications to all REJECTED and FILTERED_OUT projects.
+ * Deduplicates by project, uses highest-sortOrder rejection round as context.
+ */
+ sendBulkRejectionNotifications: adminProcedure
+ .input(
+ z.object({
+ customMessage: z.string().optional(),
+ fullCustomBody: z.boolean().default(false),
+ includeInviteLink: z.boolean().default(false),
+ skipAlreadySent: z.boolean().default(true),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { customMessage, fullCustomBody, includeInviteLink, skipAlreadySent } = input
+
+ // Find REJECTED from ProjectRoundState
+ const rejectedPRS = await ctx.prisma.projectRoundState.findMany({
+ where: { state: 'REJECTED' },
+ select: {
+ projectId: true,
+ roundId: true,
+ round: { select: { name: true, sortOrder: true } },
+ },
+ })
+
+ // Find FILTERED_OUT from FilteringResult
+ const filteredOut = await ctx.prisma.filteringResult.findMany({
+ where: {
+ OR: [
+ { finalOutcome: 'FILTERED_OUT' },
+ { outcome: 'FILTERED_OUT', finalOutcome: null },
+ ],
+ },
+ select: {
+ projectId: true,
+ roundId: true,
+ round: { select: { name: true, sortOrder: true } },
+ },
+ })
+
+ // Deduplicate by project, keep highest-sortOrder rejection round
+ const projectRejectionMap = new Map()
+ for (const r of [...rejectedPRS, ...filteredOut]) {
+ const existing = projectRejectionMap.get(r.projectId)
+ if (!existing || r.round.sortOrder > existing.sortOrder) {
+ projectRejectionMap.set(r.projectId, {
+ roundId: r.roundId,
+ roundName: r.round.name,
+ sortOrder: r.round.sortOrder,
+ })
+ }
+ }
+
+ // Skip already-sent
+ const alreadySentProjectIds = new Set()
+ if (skipAlreadySent) {
+ const sentLogs = await ctx.prisma.notificationLog.findMany({
+ where: { type: 'REJECTION_NOTIFICATION', status: 'SENT', projectId: { not: null } },
+ select: { projectId: true },
+ distinct: ['projectId'],
+ })
+ for (const log of sentLogs) {
+ if (log.projectId) alreadySentProjectIds.add(log.projectId)
+ }
+ }
+
+ const targetProjectIds = [...projectRejectionMap.keys()].filter(
+ (pid) => !skipAlreadySent || !alreadySentProjectIds.has(pid)
+ )
+
+ if (targetProjectIds.length === 0) {
+ return { sent: 0, failed: 0, skipped: alreadySentProjectIds.size }
+ }
+
+ // Fetch projects with team members
+ const projects = await ctx.prisma.project.findMany({
+ where: { id: { in: targetProjectIds } },
+ select: {
+ id: true,
+ title: true,
+ submittedByEmail: true,
+ teamMembers: {
+ select: { user: { select: { id: true, email: true, name: true, passwordHash: true } } },
+ },
+ },
+ })
+
+ // Generate invite tokens for passwordless users if needed
+ const baseUrl = getBaseUrl()
+ const tokenMap = new Map()
+ if (includeInviteLink) {
+ const passwordlessUserIds = new Set()
+ for (const project of projects) {
+ for (const tm of project.teamMembers) {
+ if (!tm.user.passwordHash) passwordlessUserIds.add(tm.user.id)
+ }
+ }
+ if (passwordlessUserIds.size > 0) {
+ const expiryMs = await getInviteExpiryMs(ctx.prisma)
+ for (const userId of passwordlessUserIds) {
+ const token = generateInviteToken()
+ await ctx.prisma.user.update({
+ where: { id: userId },
+ data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs) },
+ })
+ tokenMap.set(userId, token)
+ }
+ }
+ }
+
+ // Build notification items
+ const items: NotificationItem[] = []
+ for (const project of projects) {
+ const rejection = projectRejectionMap.get(project.id)
+
+ const recipients = new Map()
+ for (const tm of project.teamMembers) {
+ if (tm.user.email) {
+ recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
+ }
+ }
+ if (recipients.size === 0 && project.submittedByEmail) {
+ recipients.set(project.submittedByEmail, { name: null, userId: '' })
+ }
+
+ for (const [email, { name, userId }] of recipients) {
+ const inviteToken = tokenMap.get(userId)
+ const accountUrl = inviteToken ? `${baseUrl}/accept-invite?token=${inviteToken}` : undefined
+
+ items.push({
+ email,
+ name: name || '',
+ type: 'REJECTION_NOTIFICATION',
+ context: {
+ title: 'Project Status Update',
+ message: '',
+ linkUrl: includeInviteLink ? accountUrl : undefined,
+ metadata: {
+ projectName: project.title,
+ roundName: rejection?.roundName ?? 'this round',
+ customMessage: customMessage || undefined,
+ fullCustomBody,
+ },
+ },
+ projectId: project.id,
+ userId: userId || undefined,
+ roundId: rejection?.roundId,
+ })
+ }
+ }
+
+ const result = await sendBatchNotifications(items)
+
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'SEND_BULK_REJECTION_NOTIFICATIONS',
+ entityType: 'Project',
+ entityId: 'bulk',
+ detailsJson: {
+ sent: result.sent,
+ failed: result.failed,
+ projectCount: targetProjectIds.length,
+ skipped: alreadySentProjectIds.size,
+ batchId: result.batchId,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ })
+
+ return { sent: result.sent, failed: result.failed, skipped: alreadySentProjectIds.size }
+ }),
+
+ /**
+ * Send bulk award pool notifications for a specific award.
+ * Uses the existing award notification pattern via batch sender.
+ */
+ sendBulkAwardNotifications: adminProcedure
+ .input(
+ z.object({
+ awardId: z.string(),
+ customMessage: z.string().optional(),
+ skipAlreadySent: z.boolean().default(true),
+ })
+ )
+ .mutation(async ({ ctx, input }) => {
+ const { awardId, customMessage, skipAlreadySent } = input
+
+ const award = await ctx.prisma.specialAward.findUniqueOrThrow({
+ where: { id: awardId },
+ select: { id: true, name: true },
+ })
+
+ // Get eligible projects for this award
+ const eligibilities = await ctx.prisma.awardEligibility.findMany({
+ where: {
+ awardId,
+ eligible: true,
+ ...(skipAlreadySent ? { notifiedAt: null } : {}),
+ },
+ select: {
+ id: true,
+ projectId: true,
+ project: {
+ select: {
+ id: true,
+ title: true,
+ submittedByEmail: true,
+ teamMembers: {
+ select: { user: { select: { id: true, email: true, name: true, passwordHash: true } } },
+ },
+ },
+ },
+ },
+ })
+
+ if (eligibilities.length === 0) {
+ return { sent: 0, failed: 0, skipped: 0 }
+ }
+
+ // Generate invite tokens for passwordless users
+ const baseUrl = getBaseUrl()
+ const tokenMap = new Map()
+ const passwordlessUserIds = new Set()
+ for (const elig of eligibilities) {
+ for (const tm of elig.project.teamMembers) {
+ if (!tm.user.passwordHash) passwordlessUserIds.add(tm.user.id)
+ }
+ }
+ if (passwordlessUserIds.size > 0) {
+ const expiryMs = await getInviteExpiryMs(ctx.prisma)
+ for (const userId of passwordlessUserIds) {
+ const token = generateInviteToken()
+ await ctx.prisma.user.update({
+ where: { id: userId },
+ data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs) },
+ })
+ tokenMap.set(userId, token)
+ }
+ }
+
+ // Build items with eligibility tracking
+ const eligibilityEmailMap = new Map>() // eligId -> emails
+ const items: NotificationItem[] = []
+ for (const elig of eligibilities) {
+ const project = elig.project
+ const emailsForElig = new Set()
+
+ const recipients = new Map()
+ for (const tm of project.teamMembers) {
+ if (tm.user.email) {
+ recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
+ }
+ }
+ if (recipients.size === 0 && project.submittedByEmail) {
+ recipients.set(project.submittedByEmail, { name: null, userId: '' })
+ }
+
+ for (const [email, { name, userId }] of recipients) {
+ emailsForElig.add(email)
+ const inviteToken = tokenMap.get(userId)
+ const accountUrl = inviteToken ? `${baseUrl}/accept-invite?token=${inviteToken}` : undefined
+
+ items.push({
+ email,
+ name: name || '',
+ type: 'AWARD_SELECTION_NOTIFICATION',
+ context: {
+ title: `Your project is being considered for ${award.name}`,
+ message: '',
+ linkUrl: '/applicant',
+ metadata: {
+ projectName: project.title,
+ awardName: award.name,
+ customMessage: customMessage || undefined,
+ accountUrl,
+ },
+ },
+ projectId: project.id,
+ userId: userId || undefined,
+ })
+ }
+
+ eligibilityEmailMap.set(elig.id, emailsForElig)
+ }
+
+ const result = await sendBatchNotifications(items)
+
+ // Stamp notifiedAt only for eligibilities where all emails succeeded
+ const failedEmails = new Set(result.errors.map((e) => e.email))
+ for (const [eligId, emails] of eligibilityEmailMap) {
+ const anyFailed = [...emails].some((e) => failedEmails.has(e))
+ if (!anyFailed) {
+ await ctx.prisma.awardEligibility.update({
+ where: { id: eligId },
+ data: { notifiedAt: new Date() },
+ })
+ }
+ }
+
+ await logAudit({
+ prisma: ctx.prisma,
+ userId: ctx.user.id,
+ action: 'SEND_BULK_AWARD_NOTIFICATIONS',
+ entityType: 'SpecialAward',
+ entityId: awardId,
+ detailsJson: {
+ awardName: award.name,
+ sent: result.sent,
+ failed: result.failed,
+ eligibilityCount: eligibilities.length,
+ batchId: result.batchId,
+ },
+ ipAddress: ctx.ip,
+ userAgent: ctx.userAgent,
+ })
+
+ return { sent: result.sent, failed: result.failed, skipped: 0 }
+ }),
})
diff --git a/src/server/utils/project-logo-url.ts b/src/server/utils/project-logo-url.ts
new file mode 100644
index 0000000..ee9cdfd
--- /dev/null
+++ b/src/server/utils/project-logo-url.ts
@@ -0,0 +1,35 @@
+import { createStorageProvider, type StorageProviderType } from '@/lib/storage'
+
+/**
+ * Generate a pre-signed download URL for a project logo.
+ * Returns null if the project has no logo.
+ */
+export async function getProjectLogoUrl(
+ logoKey: string | null | undefined,
+ logoProvider: string | null | undefined
+): Promise {
+ if (!logoKey) return null
+
+ try {
+ const providerType = (logoProvider as StorageProviderType) || 's3'
+ const provider = createStorageProvider(providerType)
+ return await provider.getDownloadUrl(logoKey)
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Batch-generate logo URLs for multiple projects.
+ * Adds `logoUrl` field to each project object.
+ */
+export async function attachProjectLogoUrls<
+ T extends { logoKey?: string | null; logoProvider?: string | null }
+>(projects: T[]): Promise<(T & { logoUrl: string | null })[]> {
+ return Promise.all(
+ projects.map(async (project) => ({
+ ...project,
+ logoUrl: await getProjectLogoUrl(project.logoKey, project.logoProvider),
+ }))
+ )
+}