feat: resolve project logo URLs server-side, show logos in admin + observer
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m30s

Add attachProjectLogoUrls utility mirroring avatar URL pattern. Pipe
project.list and analytics.getAllProjects through logo URL resolver so
ProjectLogo components receive presigned URLs. Add logos to observer
projects table and mobile cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 13:29:54 +01:00
parent a39e27f6ff
commit 267d26581d
5 changed files with 919 additions and 41 deletions

View File

@@ -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<string, number> = {}
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<string, { roundId: string; roundName: string; nextRoundName: string; projectIds: Set<string> }>()
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<string>()
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<string, { roundName: string; nextRoundName: string }>()
const projectIds = new Set<string>()
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<string, string>()
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<string, { name: string | null; userId: string }>()
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<string, { roundId: string; roundName: string; sortOrder: number }>()
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<string>()
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<string, string>()
if (includeInviteLink) {
const passwordlessUserIds = new Set<string>()
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<string, { name: string | null; userId: string }>()
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<string, string>()
const passwordlessUserIds = new Set<string>()
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<string, Set<string>>() // eligId -> emails
const items: NotificationItem[] = []
for (const elig of eligibilities) {
const project = elig.project
const emailsForElig = new Set<string>()
const recipients = new Map<string, { name: string | null; userId: string }>()
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 }
}),
})