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
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:
@@ -72,6 +72,7 @@ import {
|
||||
ArrowRightCircle,
|
||||
LayoutGrid,
|
||||
LayoutList,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Select,
|
||||
@@ -90,7 +91,8 @@ import {
|
||||
} from '@/components/ui/tooltip'
|
||||
import { truncate } from '@/lib/utils'
|
||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||
import { StatusBadge } from '@/components/shared/status-badge'
|
||||
import { BulkNotificationDialog } from '@/components/admin/projects/bulk-notification-dialog'
|
||||
|
||||
import { Pagination } from '@/components/shared/pagination'
|
||||
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
|
||||
import { CountryFlagImg } from '@/components/ui/country-select'
|
||||
@@ -113,6 +115,25 @@ const statusColors: Record<
|
||||
WINNER: 'success',
|
||||
REJECTED: 'destructive',
|
||||
WITHDRAWN: 'secondary',
|
||||
// Round-state-based statuses
|
||||
PENDING: 'secondary',
|
||||
IN_PROGRESS: 'default',
|
||||
COMPLETED: 'default',
|
||||
PASSED: 'success',
|
||||
}
|
||||
|
||||
type ProjectRoundStateInfo = {
|
||||
state: string
|
||||
round: { name: string; sortOrder: number }
|
||||
}
|
||||
|
||||
function deriveProjectStatus(prs: ProjectRoundStateInfo[]): { label: string; variant: 'default' | 'success' | 'secondary' | 'destructive' | 'warning' } {
|
||||
if (!prs.length) return { label: 'Submitted', variant: 'secondary' }
|
||||
if (prs.some((p) => p.state === 'REJECTED')) return { label: 'Rejected', variant: 'destructive' }
|
||||
// prs is already sorted by sortOrder desc — first item is the latest round
|
||||
const latest = prs[0]
|
||||
if (latest.state === 'PASSED') return { label: latest.round.name, variant: 'success' }
|
||||
return { label: latest.round.name, variant: 'default' }
|
||||
}
|
||||
|
||||
function parseFiltersFromParams(
|
||||
@@ -290,6 +311,7 @@ export default function ProjectsPage() {
|
||||
const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null)
|
||||
const [assignRoundId, setAssignRoundId] = useState('')
|
||||
|
||||
const [bulkNotifyOpen, setBulkNotifyOpen] = useState(false)
|
||||
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
|
||||
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
|
||||
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
|
||||
@@ -619,6 +641,13 @@ export default function ProjectsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setBulkNotifyOpen(true)}
|
||||
>
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
Send Notifications
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAiTagDialogOpen(true)}
|
||||
@@ -713,7 +742,7 @@ export default function ProjectsPage() {
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{Object.entries(data.statusCounts ?? {})
|
||||
.sort(([a], [b]) => {
|
||||
const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN']
|
||||
const order = ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN']
|
||||
return order.indexOf(a) - order.indexOf(b)
|
||||
})
|
||||
.map(([status, count]) => (
|
||||
@@ -873,7 +902,7 @@ export default function ProjectsPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.projects.map((project) => {
|
||||
const isEliminated = project.status === 'REJECTED'
|
||||
const isEliminated = (project.projectRoundStates ?? []).some((p: ProjectRoundStateInfo) => p.state === 'REJECTED')
|
||||
return (
|
||||
<TableRow
|
||||
key={project.id}
|
||||
@@ -894,6 +923,7 @@ export default function ProjectsPage() {
|
||||
>
|
||||
<ProjectLogo
|
||||
project={project}
|
||||
logoUrl={project.logoUrl}
|
||||
size="sm"
|
||||
fallback="initials"
|
||||
/>
|
||||
@@ -972,7 +1002,10 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={project.status ?? 'SUBMITTED'} />
|
||||
{(() => {
|
||||
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
|
||||
return <Badge variant={derived.variant}>{derived.label}</Badge>
|
||||
})()}
|
||||
</TableCell>
|
||||
<TableCell className="relative z-10 text-right">
|
||||
<DropdownMenu>
|
||||
@@ -1042,13 +1075,16 @@ export default function ProjectsPage() {
|
||||
<Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start gap-3 pl-8">
|
||||
<ProjectLogo project={project} size="md" fallback="initials" />
|
||||
<ProjectLogo project={project} logoUrl={project.logoUrl} size="md" fallback="initials" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
|
||||
{project.title}
|
||||
</CardTitle>
|
||||
<StatusBadge status={project.status ?? 'SUBMITTED'} className="shrink-0" />
|
||||
{(() => {
|
||||
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
|
||||
return <Badge variant={derived.variant} className="shrink-0">{derived.label}</Badge>
|
||||
})()}
|
||||
</div>
|
||||
<CardDescription>{project.teamName}</CardDescription>
|
||||
</div>
|
||||
@@ -1096,7 +1132,7 @@ export default function ProjectsPage() {
|
||||
/* Card View */
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{data.projects.map((project) => {
|
||||
const isEliminated = project.status === 'REJECTED'
|
||||
const isEliminated = (project.projectRoundStates ?? []).some((p: ProjectRoundStateInfo) => p.state === 'REJECTED')
|
||||
return (
|
||||
<div key={project.id} className="relative">
|
||||
<div className="absolute left-3 top-3 z-10">
|
||||
@@ -1110,7 +1146,7 @@ export default function ProjectsPage() {
|
||||
<Card className={`transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md h-full ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start gap-3 pl-7">
|
||||
<ProjectLogo project={project} size="lg" fallback="initials" />
|
||||
<ProjectLogo project={project} logoUrl={project.logoUrl} size="lg" fallback="initials" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
|
||||
@@ -1177,7 +1213,10 @@ export default function ProjectsPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<StatusBadge status={project.status ?? 'SUBMITTED'} />
|
||||
{(() => {
|
||||
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
|
||||
return <Badge variant={derived.variant}>{derived.label}</Badge>
|
||||
})()}
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||
@@ -1846,6 +1885,8 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<BulkNotificationDialog open={bulkNotifyOpen} onOpenChange={setBulkNotifyOpen} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||
|
||||
|
||||
export function ObserverProjectsContent() {
|
||||
@@ -322,7 +323,15 @@ export function ObserverProjectsContent() {
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => router.push(`/observer/projects/${project.id}`)}
|
||||
>
|
||||
<TableCell className="pl-6 max-w-[260px]">
|
||||
<TableCell className="pl-6 max-w-[300px]">
|
||||
<div className="flex items-center gap-3">
|
||||
<ProjectLogo
|
||||
project={project}
|
||||
logoUrl={project.logoUrl}
|
||||
size="sm"
|
||||
fallback="initials"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/observer/projects/${project.id}` as Route}
|
||||
className="font-medium hover:underline truncate block"
|
||||
@@ -335,6 +344,8 @@ export function ObserverProjectsContent() {
|
||||
{project.teamName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{project.country ?? '-'}
|
||||
@@ -395,6 +406,13 @@ export function ObserverProjectsContent() {
|
||||
<Card className="transition-colors hover:bg-muted/50">
|
||||
<CardContent className="pt-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<ProjectLogo
|
||||
project={project}
|
||||
logoUrl={project.logoUrl}
|
||||
size="sm"
|
||||
fallback="initials"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-sm leading-tight truncate">
|
||||
{project.title}
|
||||
@@ -405,6 +423,7 @@ export function ObserverProjectsContent() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge status={project.observerStatus ?? project.status} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}),
|
||||
})
|
||||
|
||||
35
src/server/utils/project-logo-url.ts
Normal file
35
src/server/utils/project-logo-url.ts
Normal file
@@ -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<string | null> {
|
||||
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),
|
||||
}))
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user