From 43e21c6c6ed2cde2e1387c6ae1fdc6a3ad7b6568 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 4 Mar 2026 15:41:03 +0100 Subject: [PATCH] feat: semi-finalist tracker dashboard, account reminders, search + UX fixes - Add getSemiFinalistStats query with per-category/per-award breakdown - Add sendAccountReminders mutation with invite token generation and dedup - Add SemiFinalistTracker dashboard widget with progress bars and remind buttons - Add ACCOUNT_REMINDER email template - Extend project search to match team member name/email (7 locations) - Fix Passed count deduplication: count distinct projects, not round-state rows - Fix role switcher: visible pills above user section, auto-refresh session on mount Co-Authored-By: Claude Opus 4.6 --- src/app/(admin)/admin/dashboard-content.tsx | 22 +- .../dashboard/semi-finalist-tracker.tsx | 235 ++++++++++++++++ src/components/layouts/admin-sidebar.tsx | 32 ++- src/components/layouts/role-nav.tsx | 10 +- src/lib/email.ts | 33 +++ src/server/routers/analytics.ts | 2 + src/server/routers/dashboard.ts | 262 ++++++++++++++++++ src/server/routers/file.ts | 4 + src/server/routers/project-pool.ts | 5 +- src/server/routers/project.ts | 31 ++- 10 files changed, 622 insertions(+), 14 deletions(-) create mode 100644 src/components/dashboard/semi-finalist-tracker.tsx diff --git a/src/app/(admin)/admin/dashboard-content.tsx b/src/app/(admin)/admin/dashboard-content.tsx index 7466454..62fe8df 100644 --- a/src/app/(admin)/admin/dashboard-content.tsx +++ b/src/app/(admin)/admin/dashboard-content.tsx @@ -35,6 +35,7 @@ import { ActivityFeed } from '@/components/dashboard/activity-feed' import { CategoryBreakdown } from '@/components/dashboard/category-breakdown' import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton' import { RecentEvaluations } from '@/components/dashboard/recent-evaluations' +import { SemiFinalistTracker } from '@/components/dashboard/semi-finalist-tracker' type DashboardContentProps = { editionId: string @@ -125,6 +126,10 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro { limit: 8 }, { enabled: !!editionId, refetchInterval: 5_000 } ) + const { data: semiFinalistStats } = trpc.dashboard.getSemiFinalistStats.useQuery( + { editionId }, + { enabled: !!editionId, refetchInterval: 60_000 } + ) if (isLoading) { return @@ -271,7 +276,18 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro - + {semiFinalistStats && semiFinalistStats.byCategory.length > 0 && ( + + + + )} + + @@ -280,12 +296,12 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro {/* Bottom Full Width */}
- +
- + = { + STARTUP: 'Startup', + BUSINESS_CONCEPT: 'Business Concept', + UNKNOWN: 'Unknown', +} + +export function SemiFinalistTracker({ + byCategory, + byAward, + unactivatedProjects, + editionId, +}: SemiFinalistTrackerProps) { + const utils = trpc.useUtils() + const sendReminders = trpc.dashboard.sendAccountReminders.useMutation({ + onSuccess: (data) => { + toast.success(`Sent ${data.sent} reminder${data.sent !== 1 ? 's' : ''}${data.failed > 0 ? `, ${data.failed} failed` : ''}`) + utils.dashboard.getSemiFinalistStats.invalidate() + }, + onError: (err) => { + toast.error(`Failed to send reminders: ${err.message}`) + }, + }) + const [sendingTarget, setSendingTarget] = useState(null) + + const totalProjects = byCategory.reduce((sum, c) => sum + c.total, 0) + const totalActivated = byCategory.reduce((sum, c) => sum + c.accountsSet, 0) + const totalPending = byCategory.reduce((sum, c) => sum + c.accountsNotSet, 0) + + if (totalProjects === 0) return null + + const handleSendReminder = async (target: string, opts: { category?: 'STARTUP' | 'BUSINESS_CONCEPT'; awardId?: string }) => { + setSendingTarget(target) + try { + await sendReminders.mutateAsync({ + editionId, + category: opts.category, + awardId: opts.awardId, + }) + } finally { + setSendingTarget(null) + } + } + + return ( + + +
+ + + Semi-Finalist Tracker + + + {totalActivated}/{totalProjects} activated + +
+
+ + {/* Per-category rows */} + {byCategory.map((cat) => { + const pct = cat.total > 0 ? Math.round((cat.accountsSet / cat.total) * 100) : 0 + const allSet = cat.accountsNotSet === 0 + return ( +
+
+ + {categoryLabels[cat.category] || cat.category} + +
+ + {cat.accountsSet} of {cat.total} activated + + {allSet ? ( + + ) : ( + + )} +
+
+ + {cat.accountsNotSet > 0 && ( +

+ + {cat.accountsNotSet} pending account setup +

+ )} +
+ ) + })} + + {/* Per-award rows */} + {byAward.length > 0 && ( + <> +
+

+ Special Awards +

+
+ {byAward.map((award) => { + const pct = award.total > 0 ? Math.round((award.accountsSet / award.total) * 100) : 0 + const allSet = award.accountsNotSet === 0 + return ( +
+
+ + + {award.awardName} + +
+ + {award.accountsSet} of {award.total} + + {allSet ? ( + + ) : ( + + )} +
+
+ +
+ ) + })} + + )} + + {/* Summary */} +
+
+ + Total: {totalActivated} of {totalProjects} team accounts activated + + {totalPending > 0 && ( + + )} +
+
+
+
+ ) +} diff --git a/src/components/layouts/admin-sidebar.tsx b/src/components/layouts/admin-sidebar.tsx index bbcecc7..57b9b5d 100644 --- a/src/components/layouts/admin-sidebar.tsx +++ b/src/components/layouts/admin-sidebar.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' import Link from 'next/link' import type { Route } from 'next' import { usePathname } from 'next/navigation' @@ -164,13 +164,21 @@ const ROLE_SWITCH_OPTIONS: Record { + if (isAuthenticated) { + updateSession() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthenticated]) + const isSuperAdmin = user.role === 'SUPER_ADMIN' const roleLabel = roleLabels[user.role || ''] || 'User' @@ -307,6 +315,26 @@ export function AdminSidebar({ user }: AdminSidebarProps) { )} + {/* Role Switcher — visible above user section */} + {switchableRoles.length > 0 && ( +
+

+ + Switch View +

+
+ {switchableRoles.map(([, opt]) => ( + setIsMobileMenuOpen(false)}> + + + ))} +
+
+ )} + {/* User Profile Section */}
diff --git a/src/components/layouts/role-nav.tsx b/src/components/layouts/role-nav.tsx index ec31227..647a266 100644 --- a/src/components/layouts/role-nav.tsx +++ b/src/components/layouts/role-nav.tsx @@ -69,7 +69,7 @@ function isNavItemActive(pathname: string, href: string, basePath: string): bool export function RoleNav({ navigation, roleName, user, basePath, statusBadge, editionSelector, helpEmail }: RoleNavProps) { const pathname = usePathname() const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false) - const { data: session, status: sessionStatus } = useSession() + const { data: session, status: sessionStatus, update: updateSession } = useSession() const isAuthenticated = sessionStatus === 'authenticated' const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(undefined, { enabled: isAuthenticated, @@ -78,6 +78,14 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi const [mounted, setMounted] = useState(false) useEffect(() => setMounted(true), []) + // Auto-refresh session on mount to pick up role changes without requiring re-login + useEffect(() => { + if (isAuthenticated) { + updateSession() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAuthenticated]) + // Roles the user can switch to (excluding current view) const userRoles = (session?.user?.roles as UserRole[] | undefined) ?? [] const switchableRoles = Object.entries(ROLE_SWITCH_OPTIONS) diff --git a/src/lib/email.ts b/src/lib/email.ts index 302bca8..1c2758e 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -1959,6 +1959,32 @@ export function getEmailPreviewHtml(subject: string, body: string): string { return getEmailWrapper(content) } +/** + * Generate "Account Setup Reminder" email template + * Sent to semi-finalist team members who haven't set up their account yet. + */ +export function getAccountReminderTemplate( + name: string, + projectName: string, + accountUrl: string, +): EmailTemplate { + const greeting = name ? `Hello ${name},` : 'Hello,' + + const content = ` + ${sectionTitle(greeting)} + ${paragraph(`Your project "${projectName}" has been selected as a semi-finalist in the Monaco Ocean Protection Challenge.`)} + ${infoBox('Please set up your account to access your applicant dashboard and stay up to date with the competition.', 'warning')} + ${ctaButton(accountUrl, 'Set Up Your Account')} + ${paragraph('If you have any questions, please contact the MOPC team.')} + ` + + return { + subject: `Action Required: Set up your MOPC account — "${projectName}"`, + html: getEmailWrapper(content), + text: `${greeting}\n\nYour project "${projectName}" has been selected as a semi-finalist in the Monaco Ocean Protection Challenge.\n\nPlease set up your account to access your applicant dashboard.\n\nSet up your account: ${getBaseUrl()}${accountUrl}\n\nIf you have any questions, please contact the MOPC team.\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`, + } +} + /** * Template registry mapping notification types to template generators */ @@ -2137,6 +2163,13 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record = { ctx.metadata?.accountUrl as string | undefined, ), + ACCOUNT_REMINDER: (ctx) => + getAccountReminderTemplate( + ctx.name || '', + (ctx.metadata?.projectName as string) || 'Your Project', + (ctx.metadata?.accountUrl as string) || '/accept-invite', + ), + // Admin templates NEW_APPLICATION: (ctx) => getNewApplicationTemplate( diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts index a51d334..9ffb5ee 100644 --- a/src/server/routers/analytics.ts +++ b/src/server/routers/analytics.ts @@ -1004,6 +1004,8 @@ export const analyticsRouter = router({ where.OR = [ { title: { contains: input.search, mode: 'insensitive' } }, { teamName: { contains: input.search, mode: 'insensitive' } }, + { teamMembers: { some: { user: { name: { contains: input.search, mode: 'insensitive' } } } } }, + { teamMembers: { some: { user: { email: { contains: input.search, mode: 'insensitive' } } } } }, ] } diff --git a/src/server/routers/dashboard.ts b/src/server/routers/dashboard.ts index 0744c5d..7a2cfde 100644 --- a/src/server/routers/dashboard.ts +++ b/src/server/routers/dashboard.ts @@ -1,6 +1,10 @@ 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 ────────────────────────────────────────────────────────────────── @@ -571,4 +575,262 @@ export const dashboardRouter = router({ }, }) }), + + /** + * 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 all projects with at least one PASSED state in this edition's rounds. + // Use the highest sortOrder PASSED round per project to avoid double-counting. + const passedStates = await ctx.prisma.projectRoundState.findMany({ + where: { + state: 'PASSED', + round: { competition: { programId: editionId } }, + }, + select: { + projectId: 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, + passwordHash: true, + }, + }, + }, + }, + }, + }, + }, + }) + + // Deduplicate: keep only the highest-sortOrder PASSED round per project + const projectMap = new Map() + for (const ps of passedStates) { + const existing = projectMap.get(ps.projectId) + if (!existing || ps.round.sortOrder > existing.round.sortOrder) { + projectMap.set(ps.projectId, ps) + } + } + + const uniqueProjects = Array.from(projectMap.values()) + + // Group by category + const catMap = new Map() + 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) + 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 }>() + 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) + if (hasActivated) accountsSet++ + else accountsNotSet++ + } + } + return { awardId, awardName, total: projectIds.size, accountsSet, accountsNotSet } + }) + + // Unactivated projects: no team member has passwordHash + const unactivatedProjects = uniqueProjects + .filter((ps) => !ps.project.teamMembers.some((tm) => tm.user.passwordHash !== null)) + .map((ps) => ({ + projectId: ps.projectId, + projectTitle: ps.project.title, + category: ps.project.competitionCategory, + teamEmails: ps.project.teamMembers + .filter((tm) => tm.user.passwordHash === null) + .map((tm) => tm.user.email), + roundName: ps.round.name, + })) + + return { byCategory, byAward, unactivatedProjects } + }), + + /** + * 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 = { + 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, + 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 && !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 } + }), }) diff --git a/src/server/routers/file.ts b/src/server/routers/file.ts index cf76101..72900c7 100644 --- a/src/server/routers/file.ts +++ b/src/server/routers/file.ts @@ -1008,6 +1008,8 @@ export const fileRouter = router({ projectWhere.OR = [ { title: { contains: input.search, mode: 'insensitive' } }, { teamName: { contains: input.search, mode: 'insensitive' } }, + { teamMembers: { some: { user: { name: { contains: input.search, mode: 'insensitive' } } } } }, + { teamMembers: { some: { user: { email: { contains: input.search, mode: 'insensitive' } } } } }, ] } @@ -1317,6 +1319,8 @@ export const fileRouter = router({ projectWhere.OR = [ { title: { contains: input.search, mode: 'insensitive' } }, { teamName: { contains: input.search, mode: 'insensitive' } }, + { teamMembers: { some: { user: { name: { contains: input.search, mode: 'insensitive' } } } } }, + { teamMembers: { some: { user: { email: { contains: input.search, mode: 'insensitive' } } } } }, ] } diff --git a/src/server/routers/project-pool.ts b/src/server/routers/project-pool.ts index fb8610f..b507776 100644 --- a/src/server/routers/project-pool.ts +++ b/src/server/routers/project-pool.ts @@ -57,7 +57,7 @@ export const projectPoolRouter = router({ where.competitionCategory = competitionCategory } - // Search in title, teamName, description, institution, country, geographicZone, team member names + // Search in title, teamName, description, institution, country, geographicZone, team member names/emails if (search) { where.OR = [ { title: { contains: search, mode: 'insensitive' } }, @@ -67,6 +67,7 @@ export const projectPoolRouter = router({ { country: { contains: search, mode: 'insensitive' } }, { geographicZone: { contains: search, mode: 'insensitive' } }, { teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } }, + { teamMembers: { some: { user: { email: { contains: search, mode: 'insensitive' } } } } }, ] } @@ -344,6 +345,8 @@ export const projectPoolRouter = router({ OR: [ { title: { contains: search, mode: 'insensitive' } }, { teamName: { contains: search, mode: 'insensitive' } }, + { teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } }, + { teamMembers: { some: { user: { email: { contains: search, mode: 'insensitive' } } } } }, ], } } diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index 693e288..0be6be2 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -133,6 +133,8 @@ export const projectRouter = router({ { title: { contains: search, mode: 'insensitive' } }, { teamName: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } }, + { teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } }, + { teamMembers: { some: { user: { email: { contains: search, mode: 'insensitive' } } } } }, ] } @@ -163,17 +165,28 @@ export const projectRouter = router({ }, }), ctx.prisma.project.count({ where }), - ctx.prisma.projectRoundState.groupBy({ - by: ['state'], - where: where.programId ? { project: { programId: where.programId as string } } : {}, - _count: true, - }), + // Count distinct projects per state (avoids double-counting projects that passed multiple rounds) + (async () => { + const stateFilter = where.programId ? { project: { programId: where.programId as string } } : {} + const states = ['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'] as const + const counts = await Promise.all( + states.map(async (state) => ({ + state, + count: await ctx.prisma.projectRoundState.findMany({ + where: { ...stateFilter, state }, + select: { projectId: true }, + distinct: ['projectId'], + }).then((rows) => rows.length), + })) + ) + return counts + })(), ]) - // Build round-state counts + // Build round-state counts (distinct projects per state) const statusCounts: Record = {} for (const g of roundStateCounts) { - statusCounts[g.state] = g._count + statusCounts[g.state] = g.count } const projectsWithLogos = await attachProjectLogoUrls(projects) @@ -259,6 +272,8 @@ export const projectRouter = router({ { title: { contains: search, mode: 'insensitive' } }, { teamName: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } }, + { teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } }, + { teamMembers: { some: { user: { email: { contains: search, mode: 'insensitive' } } } } }, ] } @@ -1151,6 +1166,8 @@ export const projectRouter = router({ where.OR = [ { title: { contains: search, mode: 'insensitive' } }, { teamName: { contains: search, mode: 'insensitive' } }, + { teamMembers: { some: { user: { name: { contains: search, mode: 'insensitive' } } } } }, + { teamMembers: { some: { user: { email: { contains: search, mode: 'insensitive' } } } } }, ] }