Files
MOPC-Portal/src/server/routers/dashboard.ts
Matt 6c52e519e5 feat: impersonation system, semi-finalist detail page, tRPC resilience
- Add super-admin impersonation: "Login As" from user list, red banner
  with "Return to Admin", audit logged start/end, nested impersonation
  blocked, onboarding gate skipped during impersonation
- Fix semi-finalist stats: check latest terminal state (not any PASSED),
  use passwordHash OR status=ACTIVE for activation check
- Add /admin/semi-finalists detail page with search, category/status filters
- Add account_reminder_days setting to notifications tab
- Add tRPC resilience: retry on 503/HTML responses, custom fetch detects
  nginx error pages, exponential backoff (2s/4s/8s)
- Reduce dashboard polling intervals (60s stats, 30s activity, 120s semi)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:55:44 +01:00

929 lines
32 KiB
TypeScript

import { z } from 'zod'
import { router, adminProcedure } from '../trpc'
import type { RoundType, RoundStatus, ProjectRoundStateValue } from '@prisma/client'
import { generateInviteToken, getInviteExpiryMs } from '../utils/invite'
import { sendBatchNotifications } from '../services/notification-sender'
import type { NotificationItem } from '../services/notification-sender'
import { getBaseUrl } from '@/lib/email'
// ─── Types ──────────────────────────────────────────────────────────────────
type ProjectStateCounts = {
PENDING: number
IN_PROGRESS: number
PASSED: number
REJECTED: number
COMPLETED: number
WITHDRAWN: number
total: number
}
export type PipelineRound = {
id: string
name: string
slug: string
roundType: RoundType
status: RoundStatus
sortOrder: number
windowOpenAt: Date | null
windowCloseAt: Date | null
projectStates: ProjectStateCounts
assignmentCount: number
evalSubmitted: number
evalDraft: number
evalTotal: number
filteringPassed: number
filteringRejected: number
filteringFlagged: number
filteringTotal: number
liveSessionStatus: string | null
deliberationCount: number
}
export type DashboardAction = {
id: string
severity: 'critical' | 'warning' | 'info'
title: string
description: string
href: string
roundId?: string
roundType?: RoundType
count?: number
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function emptyStateCounts(): ProjectStateCounts {
return { PENDING: 0, IN_PROGRESS: 0, PASSED: 0, REJECTED: 0, COMPLETED: 0, WITHDRAWN: 0, total: 0 }
}
function daysUntil(date: Date): number {
return Math.ceil((date.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
}
function formatRoundType(rt: RoundType): string {
return rt.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase())
}
// ─── Router ──────────────────────────────────────────────────────────────────
export const dashboardRouter = router({
/**
* Get all dashboard stats in a single query batch.
* Returns pipeline rounds, smart actions, and supporting data.
*/
getStats: adminProcedure
.input(z.object({ editionId: z.string() }))
.query(async ({ ctx, input }) => {
const { editionId } = input
const edition = await ctx.prisma.program.findUnique({
where: { id: editionId },
select: { name: true, year: true },
})
if (!edition) return null
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
// ── All queries in parallel ──────────────────────────────────────
const [
// Pipeline rounds (all, ordered by sortOrder)
allRounds,
// Per-round project state breakdown
stateBreakdown,
// Per-round eval data (assignments with eval status)
roundEvalData,
// Per-round filtering results
filteringStats,
// Live session statuses
liveSessions,
// Deliberation session counts
deliberationCounts,
// Summary counts
projectCount,
newProjectsThisWeek,
totalJurors,
activeJurors,
evaluationStats,
totalAssignments,
// Lists
latestProjects,
categoryBreakdown,
oceanIssueBreakdown,
recentActivity,
// Recently active
recentlyActiveEvals,
// Action signals
pendingCOIs,
] = await Promise.all([
// 1. All pipeline rounds
ctx.prisma.round.findMany({
where: { competition: { programId: editionId } },
orderBy: { sortOrder: 'asc' },
select: {
id: true,
name: true,
slug: true,
roundType: true,
status: true,
sortOrder: true,
windowOpenAt: true,
windowCloseAt: true,
_count: {
select: {
projectRoundStates: true,
assignments: true,
},
},
},
}),
// 2. Per-round project state counts
ctx.prisma.projectRoundState.groupBy({
by: ['roundId', 'state'],
where: { round: { competition: { programId: editionId } } },
_count: true,
}),
// 3. Assignments with eval status (for per-round eval aggregation)
ctx.prisma.assignment.findMany({
where: { round: { competition: { programId: editionId } } },
select: {
roundId: true,
evaluation: { select: { status: true } },
},
}),
// 4. Filtering results per round
ctx.prisma.filteringResult.groupBy({
by: ['roundId', 'outcome'],
where: { round: { competition: { programId: editionId } } },
_count: true,
}),
// 5. Live session statuses
ctx.prisma.liveVotingSession.findMany({
where: { round: { competition: { programId: editionId } } },
select: { roundId: true, status: true },
}),
// 6. Deliberation session counts
ctx.prisma.deliberationSession.groupBy({
by: ['roundId'],
where: { competition: { programId: editionId } },
_count: true,
}),
// 7. Project count
ctx.prisma.project.count({
where: { programId: editionId },
}),
// 8. New projects this week
ctx.prisma.project.count({
where: { programId: editionId, createdAt: { gte: sevenDaysAgo } },
}),
// 9. Total jurors
ctx.prisma.user.count({
where: {
roles: { has: 'JURY_MEMBER' },
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
assignments: { some: { round: { competition: { programId: editionId } } } },
},
}),
// 10. Active jurors
ctx.prisma.user.count({
where: {
roles: { has: 'JURY_MEMBER' },
status: 'ACTIVE',
assignments: { some: { round: { competition: { programId: editionId } } } },
},
}),
// 11. Global evaluation stats
ctx.prisma.evaluation.groupBy({
by: ['status'],
where: { assignment: { round: { competition: { programId: editionId } } } },
_count: true,
}),
// 12. Total assignments
ctx.prisma.assignment.count({
where: { round: { competition: { programId: editionId } } },
}),
// 13. Latest projects
ctx.prisma.project.findMany({
where: { programId: editionId },
orderBy: { createdAt: 'desc' },
take: 8,
select: {
id: true,
title: true,
teamName: true,
country: true,
competitionCategory: true,
oceanIssue: true,
logoKey: true,
createdAt: true,
submittedAt: true,
status: true,
},
}),
// 14. Category breakdown
ctx.prisma.project.groupBy({
by: ['competitionCategory'],
where: { programId: editionId },
_count: true,
}),
// 15. Ocean issue breakdown
ctx.prisma.project.groupBy({
by: ['oceanIssue'],
where: { programId: editionId },
_count: true,
}),
// 16. Recent activity
ctx.prisma.auditLog.findMany({
where: { timestamp: { gte: sevenDaysAgo } },
orderBy: { timestamp: 'desc' },
take: 8,
select: {
id: true,
action: true,
entityType: true,
timestamp: true,
user: { select: { name: true } },
},
}),
// 17. Recently active projects (with recent evaluations)
ctx.prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: {
round: { competition: { programId: editionId } },
},
},
orderBy: { submittedAt: 'desc' },
take: 8,
select: {
id: true,
globalScore: true,
submittedAt: true,
assignment: {
select: {
user: { select: { name: true } },
project: {
select: {
id: true,
title: true,
teamName: true,
country: true,
competitionCategory: true,
oceanIssue: true,
logoKey: true,
createdAt: true,
submittedAt: true,
status: true,
},
},
},
},
},
}),
// 18. Pending COIs
ctx.prisma.conflictOfInterest.count({
where: {
hasConflict: true,
reviewedAt: null,
assignment: { round: { competition: { programId: editionId } } },
},
}),
])
// ── Assemble pipeline rounds ────────────────────────────────────
// Build state counts map: roundId -> ProjectStateCounts
const stateMap = new Map<string, ProjectStateCounts>()
for (const row of stateBreakdown) {
if (!stateMap.has(row.roundId)) stateMap.set(row.roundId, emptyStateCounts())
const counts = stateMap.get(row.roundId)!
const state = row.state as ProjectRoundStateValue
if (state in counts) {
counts[state as keyof Omit<ProjectStateCounts, 'total'>] = row._count
counts.total += row._count
}
}
// Build eval map: roundId -> { submitted, draft, total }
const evalMap = new Map<string, { submitted: number; draft: number; total: number }>()
for (const a of roundEvalData) {
if (!evalMap.has(a.roundId)) evalMap.set(a.roundId, { submitted: 0, draft: 0, total: 0 })
const entry = evalMap.get(a.roundId)!
entry.total++
if (a.evaluation?.status === 'SUBMITTED') entry.submitted++
else if (a.evaluation?.status === 'DRAFT') entry.draft++
}
// Build filtering map: roundId -> { passed, rejected, flagged, total }
const filterMap = new Map<string, { passed: number; rejected: number; flagged: number; total: number }>()
for (const row of filteringStats) {
if (!filterMap.has(row.roundId)) filterMap.set(row.roundId, { passed: 0, rejected: 0, flagged: 0, total: 0 })
const entry = filterMap.get(row.roundId)!
entry.total += row._count
if (row.outcome === 'PASSED') entry.passed = row._count
else if (row.outcome === 'FILTERED_OUT') entry.rejected = row._count
else if (row.outcome === 'FLAGGED') entry.flagged = row._count
}
// Build live session map: roundId -> status
const liveMap = new Map<string, string>()
for (const s of liveSessions) {
if (s.roundId) liveMap.set(s.roundId, s.status)
}
// Build deliberation map: roundId -> count
const delibMap = new Map<string, number>()
for (const row of deliberationCounts) {
delibMap.set(row.roundId, row._count)
}
// Assemble pipeline rounds
const pipelineRounds: PipelineRound[] = allRounds.map((round) => {
const states = stateMap.get(round.id) ?? emptyStateCounts()
const evals = evalMap.get(round.id) ?? { submitted: 0, draft: 0, total: 0 }
const filters = filterMap.get(round.id) ?? { passed: 0, rejected: 0, flagged: 0, total: 0 }
return {
id: round.id,
name: round.name,
slug: round.slug,
roundType: round.roundType,
status: round.status,
sortOrder: round.sortOrder,
windowOpenAt: round.windowOpenAt,
windowCloseAt: round.windowCloseAt,
projectStates: states,
assignmentCount: round._count.assignments,
evalSubmitted: evals.submitted,
evalDraft: evals.draft,
evalTotal: evals.total,
filteringPassed: filters.passed,
filteringRejected: filters.rejected,
filteringFlagged: filters.flagged,
filteringTotal: filters.total,
liveSessionStatus: liveMap.get(round.id) ?? null,
deliberationCount: delibMap.get(round.id) ?? 0,
}
})
// ── Determine active round ──────────────────────────────────────
const activeRound = pipelineRounds.find((r) => r.status === 'ROUND_ACTIVE') ?? null
const activeRoundId = activeRound?.id ?? null
// ── Compute smart actions ───────────────────────────────────────
const nextActions: DashboardAction[] = []
const activeRounds = pipelineRounds.filter((r) => r.status === 'ROUND_ACTIVE')
const lastActiveSortOrder = Math.max(...activeRounds.map((r) => r.sortOrder), -1)
// 1. Next draft round (only the first one after the last active)
const nextDraft = pipelineRounds.find(
(r) => r.status === 'ROUND_DRAFT' && r.sortOrder > lastActiveSortOrder
)
if (nextDraft) {
nextActions.push({
id: `draft-${nextDraft.id}`,
severity: 'info',
title: `Configure "${nextDraft.name}"`,
description: `Next round (${formatRoundType(nextDraft.roundType)}) is in draft`,
href: `/admin/rounds/${nextDraft.id}`,
roundId: nextDraft.id,
roundType: nextDraft.roundType,
})
}
// 2. Per-active-round actions
for (const round of activeRounds) {
// Evaluation rounds: flag unassigned projects
if (round.roundType === 'EVALUATION' && round.projectStates.total > 0 && round.assignmentCount === 0) {
nextActions.push({
id: `unassigned-${round.id}`,
severity: 'warning',
title: `${round.projectStates.total} unassigned projects`,
description: `"${round.name}" has projects without jury assignments`,
href: `/admin/rounds/${round.id}`,
roundId: round.id,
roundType: round.roundType,
count: round.projectStates.total,
})
}
// Filtering rounds: flag if filtering not started
if (round.roundType === 'FILTERING' && round.filteringTotal === 0 && round.projectStates.total > 0) {
nextActions.push({
id: `filtering-${round.id}`,
severity: 'warning',
title: 'Filtering not started',
description: `"${round.name}" has ${round.projectStates.total} projects awaiting filtering`,
href: `/admin/rounds/${round.id}`,
roundId: round.id,
roundType: round.roundType,
})
}
// Deadline warnings
if (round.windowCloseAt) {
const days = daysUntil(round.windowCloseAt)
if (days > 0 && days <= 3) {
nextActions.push({
id: `deadline-${round.id}`,
severity: 'critical',
title: `${days}d until "${round.name}" closes`,
description: `Window closes ${round.windowCloseAt.toLocaleDateString()}`,
href: `/admin/rounds/${round.id}`,
roundId: round.id,
roundType: round.roundType,
})
} else if (days > 3 && days <= 7) {
nextActions.push({
id: `deadline-${round.id}`,
severity: 'warning',
title: `${days}d until "${round.name}" closes`,
description: `Window closes ${round.windowCloseAt.toLocaleDateString()}`,
href: `/admin/rounds/${round.id}`,
roundId: round.id,
roundType: round.roundType,
})
}
}
}
// 3. Pending COIs
if (pendingCOIs > 0) {
nextActions.push({
id: 'pending-cois',
severity: 'warning',
title: `${pendingCOIs} COI declarations pending`,
description: 'Jury members have declared conflicts that need admin review',
href: '/admin/rounds',
count: pendingCOIs,
})
}
// Sort by severity
const severityOrder = { critical: 0, warning: 1, info: 2 }
nextActions.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity])
// ── Return ──────────────────────────────────────────────────────
// Deduplicate recently active projects (same project may have multiple evals)
const seenProjectIds = new Set<string>()
const recentlyActiveProjects = recentlyActiveEvals
.filter((e) => {
const pid = e.assignment.project.id
if (seenProjectIds.has(pid)) return false
seenProjectIds.add(pid)
return true
})
.map((e) => ({
...e.assignment.project,
latestEvaluator: e.assignment.user.name,
latestScore: e.globalScore,
evaluatedAt: e.submittedAt,
}))
return {
edition,
// Pipeline
pipelineRounds,
activeRoundId,
// Smart actions
nextActions,
// Summary counts
projectCount,
newProjectsThisWeek,
totalJurors,
activeJurors,
evaluationStats,
totalAssignments,
pendingCOIs,
// Lists
latestProjects,
recentlyActiveProjects,
categoryBreakdown,
oceanIssueBreakdown,
recentActivity,
}
}),
getRecentEvaluations: adminProcedure
.input(z.object({ editionId: z.string(), limit: z.number().int().min(1).max(50).optional() }))
.query(async ({ ctx, input }) => {
const take = input.limit ?? 10
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: {
round: { competition: { programId: input.editionId } },
},
},
orderBy: { submittedAt: 'desc' },
take,
select: {
id: true,
globalScore: true,
binaryDecision: true,
submittedAt: true,
feedbackText: true,
assignment: {
select: {
project: { select: { id: true, title: true } },
round: { select: { id: true, name: true } },
user: { select: { id: true, name: true, email: true } },
},
},
},
})
return evaluations
}),
getRecentActivity: adminProcedure
.input(z.object({ limit: z.number().int().min(1).max(20).optional() }))
.query(async ({ ctx, input }) => {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
return ctx.prisma.auditLog.findMany({
where: { timestamp: { gte: sevenDaysAgo } },
orderBy: { timestamp: 'desc' },
take: input.limit ?? 8,
select: {
id: true,
action: true,
entityType: true,
timestamp: true,
user: { select: { name: true } },
},
})
}),
/**
* Get semi-finalist stats: counts by category, by award, and unactivated projects.
* "Semi-finalist" = project with PASSED ProjectRoundState.
* "Account set up" = at least 1 team member has passwordHash.
*/
getSemiFinalistStats: adminProcedure
.input(z.object({ editionId: z.string() }))
.query(async ({ ctx, input }) => {
const { editionId } = input
// Find projects whose LATEST terminal state (PASSED/REJECTED/WITHDRAWN) is PASSED.
// A project that passed round 1 but was rejected in round 2 is NOT a semi-finalist.
const terminalStates = await ctx.prisma.projectRoundState.findMany({
where: {
state: { in: ['PASSED', 'REJECTED', 'WITHDRAWN'] },
round: { competition: { programId: editionId } },
},
select: {
projectId: true,
state: true,
round: { select: { id: true, name: true, sortOrder: true } },
project: {
select: {
id: true,
title: true,
competitionCategory: true,
teamMembers: {
select: {
user: {
select: {
id: true,
email: true,
name: true,
status: true,
passwordHash: true,
},
},
},
},
},
},
},
})
// For each project, keep only the terminal state from the highest-sortOrder round
const projectMap = new Map<string, (typeof terminalStates)[0]>()
for (const ts of terminalStates) {
const existing = projectMap.get(ts.projectId)
if (!existing || ts.round.sortOrder > existing.round.sortOrder) {
projectMap.set(ts.projectId, ts)
}
}
// Only include projects whose latest terminal state is PASSED
const uniqueProjects = Array.from(projectMap.values()).filter(ps => ps.state === 'PASSED')
// Group by category
const catMap = new Map<string, { total: number; accountsSet: number; accountsNotSet: number }>()
for (const ps of uniqueProjects) {
const cat = ps.project.competitionCategory || 'UNKNOWN'
if (!catMap.has(cat)) catMap.set(cat, { total: 0, accountsSet: 0, accountsNotSet: 0 })
const entry = catMap.get(cat)!
entry.total++
const hasActivated = ps.project.teamMembers.some((tm) => tm.user.passwordHash !== null || tm.user.status === 'ACTIVE')
if (hasActivated) entry.accountsSet++
else entry.accountsNotSet++
}
const byCategory = Array.from(catMap.entries()).map(([category, counts]) => ({
category,
...counts,
}))
// Get award eligibility for PASSED projects
const passedProjectIds = uniqueProjects.map((p) => p.projectId)
const awardEligibility = passedProjectIds.length > 0
? await ctx.prisma.awardEligibility.findMany({
where: {
projectId: { in: passedProjectIds },
eligible: true,
},
select: {
projectId: true,
award: { select: { id: true, name: true } },
},
})
: []
// Group by award
const awardMap = new Map<string, { awardName: string; projectIds: Set<string> }>()
for (const ae of awardEligibility) {
if (!awardMap.has(ae.award.id)) {
awardMap.set(ae.award.id, { awardName: ae.award.name, projectIds: new Set() })
}
awardMap.get(ae.award.id)!.projectIds.add(ae.projectId)
}
const byAward = Array.from(awardMap.entries()).map(([awardId, { awardName, projectIds }]) => {
let accountsSet = 0
let accountsNotSet = 0
for (const pid of projectIds) {
const ps = projectMap.get(pid)
if (ps) {
const hasActivated = ps.project.teamMembers.some((tm) => tm.user.passwordHash !== null || tm.user.status === 'ACTIVE')
if (hasActivated) accountsSet++
else accountsNotSet++
}
}
return { awardId, awardName, total: projectIds.size, accountsSet, accountsNotSet }
})
// Unactivated projects: no team member has set up their account
const unactivatedProjects = uniqueProjects
.filter((ps) => !ps.project.teamMembers.some((tm) => tm.user.passwordHash !== null || tm.user.status === 'ACTIVE'))
.map((ps) => ({
projectId: ps.projectId,
projectTitle: ps.project.title,
category: ps.project.competitionCategory,
teamEmails: ps.project.teamMembers
.filter((tm) => tm.user.passwordHash === null && tm.user.status !== 'ACTIVE')
.map((tm) => tm.user.email),
roundName: ps.round.name,
}))
return { byCategory, byAward, unactivatedProjects }
}),
/**
* Get detailed semi-finalist list for the "See All" page.
* Returns every project whose latest terminal state is PASSED, with team and round info.
*/
getSemiFinalistDetail: adminProcedure
.input(z.object({ editionId: z.string() }))
.query(async ({ ctx, input }) => {
const { editionId } = input
// Fetch all terminal states for projects in this edition
const terminalStates = await ctx.prisma.projectRoundState.findMany({
where: {
state: { in: ['PASSED', 'REJECTED', 'WITHDRAWN'] },
round: { competition: { programId: editionId } },
},
select: {
projectId: true,
state: true,
round: { select: { id: true, name: true, sortOrder: true, roundType: true } },
project: {
select: {
id: true,
title: true,
teamName: true,
competitionCategory: true,
country: true,
teamMembers: {
select: {
role: true,
user: {
select: {
id: true,
email: true,
name: true,
status: true,
passwordHash: true,
lastLoginAt: true,
},
},
},
},
},
},
},
})
// Keep the latest terminal state per project
const projectMap = new Map<string, (typeof terminalStates)[0]>()
for (const ts of terminalStates) {
const existing = projectMap.get(ts.projectId)
if (!existing || ts.round.sortOrder > existing.round.sortOrder) {
projectMap.set(ts.projectId, ts)
}
}
// Only include PASSED projects
const semiFinalists = Array.from(projectMap.values())
.filter(ps => ps.state === 'PASSED')
.map(ps => ({
projectId: ps.projectId,
title: ps.project.title,
teamName: ps.project.teamName,
category: ps.project.competitionCategory,
country: ps.project.country,
currentRound: ps.round.name,
currentRoundType: ps.round.roundType,
teamMembers: ps.project.teamMembers.map(tm => ({
name: tm.user.name,
email: tm.user.email,
role: tm.role,
accountStatus: tm.user.passwordHash !== null
? 'active' as const
: tm.user.status === 'ACTIVE'
? 'active' as const
: tm.user.status === 'INVITED'
? 'invited' as const
: 'none' as const,
lastLogin: tm.user.lastLoginAt,
})),
allActivated: ps.project.teamMembers.every(
tm => tm.user.passwordHash !== null || tm.user.status === 'ACTIVE'
),
}))
.sort((a, b) => a.title.localeCompare(b.title))
return semiFinalists
}),
/**
* Send account setup reminder emails to semi-finalist team members
* who haven't set their password yet.
*/
sendAccountReminders: adminProcedure
.input(z.object({
projectIds: z.array(z.string()).optional(),
category: z.enum(['STARTUP', 'BUSINESS_CONCEPT']).optional(),
awardId: z.string().optional(),
editionId: z.string(),
}))
.mutation(async ({ ctx, input }) => {
const { editionId, projectIds, category, awardId } = input
// Build filter for projects
let targetProjectIds: string[] = projectIds ?? []
if (!projectIds?.length) {
// Find PASSED projects matching filters
const passedWhere: Record<string, unknown> = {
state: 'PASSED' as const,
round: { competition: { programId: editionId } },
}
if (category) {
passedWhere.project = { competitionCategory: category }
}
const passedStates = await ctx.prisma.projectRoundState.findMany({
where: passedWhere,
select: { projectId: true },
distinct: ['projectId'],
})
targetProjectIds = passedStates.map((ps) => ps.projectId)
// If filtering by award, intersect with award eligibility
if (awardId) {
const eligible = await ctx.prisma.awardEligibility.findMany({
where: {
projectId: { in: targetProjectIds },
awardId,
eligible: true,
},
select: { projectId: true },
})
const eligibleSet = new Set(eligible.map((e) => e.projectId))
targetProjectIds = targetProjectIds.filter((id) => eligibleSet.has(id))
}
}
if (targetProjectIds.length === 0) {
return { sent: 0, failed: 0, total: 0 }
}
// Find team members without passwordHash on these projects
const projects = await ctx.prisma.project.findMany({
where: { id: { in: targetProjectIds } },
select: {
id: true,
title: true,
teamMembers: {
select: {
user: {
select: {
id: true,
email: true,
name: true,
status: true,
passwordHash: true,
},
},
},
},
},
})
// Skip projects that already had a recent ACCOUNT_REMINDER
const recentReminders = await ctx.prisma.notificationLog.findMany({
where: {
type: 'ACCOUNT_REMINDER',
status: 'SENT',
createdAt: { gte: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000) }, // 3 days
},
select: { email: true },
})
const recentReminderEmails = new Set(recentReminders.map((r) => r.email).filter(Boolean))
const expiryMs = await getInviteExpiryMs(ctx.prisma)
const expiresAt = new Date(Date.now() + expiryMs)
const baseUrl = getBaseUrl()
const notifications: NotificationItem[] = []
for (const project of projects) {
const unactivated = project.teamMembers.filter(
(tm) => tm.user.passwordHash === null && tm.user.status !== 'ACTIVE' && !recentReminderEmails.has(tm.user.email)
)
for (const tm of unactivated) {
// Generate invite token for each user
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: tm.user.id },
data: { inviteToken: token, inviteTokenExpiresAt: expiresAt },
})
const accountUrl = `/accept-invite?token=${token}`
notifications.push({
email: tm.user.email,
name: tm.user.name || '',
type: 'ACCOUNT_REMINDER',
context: {
title: 'Set Up Your Account',
message: `Your project "${project.title}" is a semi-finalist. Please set up your account.`,
linkUrl: `${baseUrl}${accountUrl}`,
metadata: {
projectName: project.title,
accountUrl,
},
},
projectId: project.id,
userId: tm.user.id,
})
}
}
if (notifications.length === 0) {
return { sent: 0, failed: 0, total: 0 }
}
const result = await sendBatchNotifications(notifications)
return { sent: result.sent, failed: result.failed, total: notifications.length }
}),
})