Add notification bell system and MOPC onboarding form

Notification System:
- Add InAppNotification and NotificationEmailSetting database models
- Create notification service with 60+ notification types for all user roles
- Add notification router with CRUD endpoints
- Build NotificationBell UI component with dropdown and unread count
- Integrate bell into admin, jury, mentor, and observer navs
- Add notification email settings admin UI in Settings > Notifications
- Add notification triggers to filtering router (complete/failed)
- Add sendNotificationEmail function to email library
- Add formatRelativeTime utility function

MOPC Onboarding Form:
- Create /apply landing page with auto-redirect for single form
- Create seed script for MOPC 2026 application form (6 steps)
- Create seed script for default notification email settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 21:30:25 +01:00
parent e1968d45df
commit 0277768ed7
18 changed files with 2344 additions and 13 deletions

View File

@@ -0,0 +1,160 @@
import { redirect } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { prisma } from '@/lib/prisma'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/shared/logo'
import { FileText, Calendar, ArrowRight, ExternalLink } from 'lucide-react'
export const dynamic = 'force-dynamic'
export default async function ApplyLandingPage() {
// Get all published, public application forms
const forms = await prisma.applicationForm.findMany({
where: {
status: 'PUBLISHED',
isPublic: true,
OR: [
{ opensAt: null },
{ opensAt: { lte: new Date() } },
],
AND: [
{
OR: [
{ closesAt: null },
{ closesAt: { gte: new Date() } },
],
},
],
},
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
description: true,
publicSlug: true,
opensAt: true,
closesAt: true,
steps: {
select: { id: true },
},
},
})
// If exactly one form is available, redirect to it
if (forms.length === 1 && forms[0].publicSlug) {
const form = forms[0]
const hasSteps = form.steps && form.steps.length > 0
const url = hasSteps
? `/apply/${form.publicSlug}/wizard`
: `/apply/${form.publicSlug}`
redirect(url as Route)
}
// If no forms are available, show a message
if (forms.length === 0) {
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white dark:from-slate-950 dark:to-slate-900">
<div className="container max-w-2xl py-16">
<div className="text-center mb-12">
<Logo variant="long" />
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-16">
<FileText className="h-16 w-16 text-muted-foreground/30 mb-6" />
<h1 className="text-2xl font-semibold mb-3">Applications Not Open</h1>
<p className="text-muted-foreground text-center max-w-md">
There are currently no open applications. Please check back later
or visit our website for more information.
</p>
<Button asChild className="mt-8">
<a href="https://monaco-opc.com" target="_blank" rel="noopener noreferrer">
Visit Website
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</CardContent>
</Card>
</div>
</div>
)
}
// Multiple forms available - show selection
return (
<div className="min-h-screen bg-gradient-to-b from-slate-50 to-white dark:from-slate-950 dark:to-slate-900">
<div className="container max-w-4xl py-16">
<div className="text-center mb-12">
<Logo variant="long" />
<h1 className="text-3xl font-bold mt-8 mb-3">Apply Now</h1>
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
Select an application form below to get started.
</p>
</div>
<div className="grid gap-6">
{forms.map((form) => {
const hasSteps = form.steps && form.steps.length > 0
const url = hasSteps
? `/apply/${form.publicSlug}/wizard`
: `/apply/${form.publicSlug}`
return (
<Card key={form.id} className="overflow-hidden hover:shadow-lg transition-shadow">
<Link href={url as Route} className="block">
<div className="flex items-stretch">
<div className="flex-1 p-6">
<CardHeader className="p-0 pb-2">
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5 text-primary" />
{form.name}
</CardTitle>
{form.description && (
<CardDescription className="line-clamp-2">
{form.description}
</CardDescription>
)}
</CardHeader>
{(form.opensAt || form.closesAt) && (
<div className="flex items-center gap-4 mt-4 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
{form.closesAt && (
<span>
Closes: {new Date(form.closesAt).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</span>
)}
</div>
)}
</div>
<div className="flex items-center px-6 bg-muted/30 border-l">
<Button variant="ghost" size="icon" className="rounded-full">
<ArrowRight className="h-5 w-5" />
</Button>
</div>
</div>
</Link>
</Card>
)
})}
</div>
<div className="text-center mt-12">
<p className="text-sm text-muted-foreground">
Having trouble? Contact us at{' '}
<a href="mailto:support@monaco-opc.com" className="text-primary hover:underline">
support@monaco-opc.com
</a>
</p>
</div>
</div>
</div>
)
}

View File

@@ -38,6 +38,7 @@ import { getInitials } from '@/lib/utils'
import { Logo } from '@/components/shared/logo'
import { EditionSelector } from '@/components/shared/edition-selector'
import { UserAvatar } from '@/components/shared/user-avatar'
import { NotificationBell } from '@/components/shared/notification-bell'
import { trpc } from '@/lib/trpc/client'
interface AdminSidebarProps {
@@ -137,18 +138,21 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
{/* Mobile menu button */}
<div className="fixed top-0 left-0 right-0 z-40 flex h-16 items-center justify-between border-b bg-card px-4 lg:hidden">
<Logo showText textSuffix="Admin" />
<Button
variant="ghost"
size="icon"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
<div className="flex items-center gap-2">
<NotificationBell />
<Button
variant="ghost"
size="icon"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
{/* Mobile menu overlay */}
@@ -241,6 +245,10 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
{/* User Profile Section */}
<div className="border-t p-3">
{/* Notification Bell - Desktop */}
<div className="hidden lg:flex justify-end mb-2">
<NotificationBell />
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="group flex w-full items-center gap-3 rounded-xl p-2.5 text-left transition-all duration-200 hover:bg-slate-100 dark:hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">

View File

@@ -18,6 +18,7 @@ import {
import type { Route } from 'next'
import { BookOpen, ClipboardList, Home, LogOut, Menu, Settings, User, X } from 'lucide-react'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
interface JuryNavProps {
user: {
@@ -84,6 +85,7 @@ export function JuryNav({ user }: JuryNavProps) {
{/* User menu & mobile toggle */}
<div className="flex items-center gap-2">
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button

View File

@@ -18,6 +18,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { BookOpen, Home, LogOut, Menu, Settings, User, Users, X } from 'lucide-react'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
interface MentorNavProps {
user: {
@@ -84,6 +85,7 @@ export function MentorNav({ user }: MentorNavProps) {
{/* User menu & mobile toggle */}
<div className="flex items-center gap-2">
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button

View File

@@ -18,6 +18,7 @@ import {
import type { Route } from 'next'
import { Home, BarChart3, Menu, X, LogOut, Eye, Settings } from 'lucide-react'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
interface ObserverNavProps {
user: {
@@ -76,6 +77,7 @@ export function ObserverNav({ user }: ObserverNavProps) {
{/* User Menu */}
<div className="flex items-center gap-2">
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2">

View File

@@ -0,0 +1,140 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from 'sonner'
import { Users, Scale, GraduationCap, Eye, Shield } from 'lucide-react'
// Category icons and labels
const CATEGORIES = {
team: { label: 'Team / Applicant', icon: Users },
jury: { label: 'Jury Members', icon: Scale },
mentor: { label: 'Mentors', icon: GraduationCap },
observer: { label: 'Observers', icon: Eye },
admin: { label: 'Administrators', icon: Shield },
}
type NotificationSetting = {
id: string
notificationType: string
category: string
label: string
description: string | null
sendEmail: boolean
}
export function NotificationSettingsForm() {
const { data: settings, isLoading, refetch } = trpc.notification.getEmailSettings.useQuery()
const updateMutation = trpc.notification.updateEmailSetting.useMutation({
onSuccess: () => {
toast.success('Notification setting updated')
refetch()
},
onError: (error) => {
toast.error(`Failed to update: ${error.message}`)
},
})
const handleToggle = (notificationType: string, sendEmail: boolean) => {
updateMutation.mutate({ notificationType, sendEmail })
}
if (isLoading) {
return (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)
}
// Group settings by category
const groupedSettings = (settings || []).reduce(
(acc, setting) => {
const category = setting.category || 'other'
if (!acc[category]) acc[category] = []
acc[category].push(setting)
return acc
},
{} as Record<string, NotificationSetting[]>
)
if (Object.keys(groupedSettings).length === 0) {
return (
<div className="text-center py-8">
<p className="text-muted-foreground">
No notification types configured yet. Notification settings will appear here once the system is seeded.
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => {
toast.info('Run the seed script to populate notification types')
}}
>
Learn More
</Button>
</div>
)
}
return (
<div className="space-y-6">
<p className="text-sm text-muted-foreground">
Toggle which notifications should also send email notifications to users.
Users can still disable email notifications in their personal preferences.
</p>
{Object.entries(CATEGORIES).map(([categoryKey, { label, icon: Icon }]) => {
const categorySettings = groupedSettings[categoryKey]
if (!categorySettings || categorySettings.length === 0) return null
return (
<Card key={categoryKey}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-3 text-base">
<Icon className="h-5 w-5 text-muted-foreground" />
{label}
<span className="ml-auto text-xs font-normal text-muted-foreground">
{categorySettings.filter(s => s.sendEmail).length}/{categorySettings.length} enabled
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{categorySettings.map((setting) => (
<div
key={setting.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div className="space-y-0.5">
<Label className="text-sm font-medium">
{setting.label}
</Label>
{setting.description && (
<p className="text-xs text-muted-foreground">
{setting.description}
</p>
)}
</div>
<Switch
checked={setting.sendEmail}
onCheckedChange={(checked) =>
handleToggle(setting.notificationType, checked)
}
disabled={updateMutation.isPending}
/>
</div>
))}
</CardContent>
</Card>
)
})}
</div>
)
}

View File

@@ -17,6 +17,7 @@ import {
HardDrive,
Shield,
Settings as SettingsIcon,
Bell,
} from 'lucide-react'
import { AISettingsForm } from './ai-settings-form'
import { AIUsageCard } from './ai-usage-card'
@@ -25,6 +26,7 @@ import { EmailSettingsForm } from './email-settings-form'
import { StorageSettingsForm } from './storage-settings-form'
import { SecuritySettingsForm } from './security-settings-form'
import { DefaultsSettingsForm } from './defaults-settings-form'
import { NotificationSettingsForm } from './notification-settings-form'
function SettingsSkeleton() {
return (
@@ -108,7 +110,7 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
return (
<Tabs defaultValue="ai" className="space-y-6">
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-6">
<TabsList className="grid w-full grid-cols-3 lg:grid-cols-7">
<TabsTrigger value="ai" className="gap-2">
<Bot className="h-4 w-4" />
<span className="hidden sm:inline">AI</span>
@@ -121,6 +123,10 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
<Mail className="h-4 w-4" />
<span className="hidden sm:inline">Email</span>
</TabsTrigger>
<TabsTrigger value="notifications" className="gap-2">
<Bell className="h-4 w-4" />
<span className="hidden sm:inline">Notifications</span>
</TabsTrigger>
<TabsTrigger value="storage" className="gap-2">
<HardDrive className="h-4 w-4" />
<span className="hidden sm:inline">Storage</span>
@@ -178,6 +184,20 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) {
</Card>
</TabsContent>
<TabsContent value="notifications">
<Card>
<CardHeader>
<CardTitle>Notification Email Settings</CardTitle>
<CardDescription>
Configure which notification types should also send email notifications
</CardDescription>
</CardHeader>
<CardContent>
<NotificationSettingsForm />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="storage">
<Card>
<CardHeader>

View File

@@ -0,0 +1,352 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { cn, formatRelativeTime } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Bell,
CheckCheck,
Settings,
AlertTriangle,
FileText,
Files,
Upload,
ClipboardList,
PlayCircle,
Clock,
AlertCircle,
Lock,
Users,
TrendingUp,
Trophy,
CheckCircle,
Star,
GraduationCap,
Vote,
Brain,
Download,
AlertOctagon,
RefreshCw,
CalendarPlus,
Heart,
BarChart,
Award,
UserPlus,
UserCheck,
UserMinus,
FileCheck,
Eye,
MessageSquare,
MessageCircle,
Info,
Calendar,
Newspaper,
UserX,
Lightbulb,
BookOpen,
XCircle,
Edit,
FileUp,
} from 'lucide-react'
// Icon mapping for notification types
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
AlertTriangle,
FileText,
Files,
Upload,
ClipboardList,
PlayCircle,
Clock,
AlertCircle,
Lock,
Users,
TrendingUp,
Trophy,
CheckCircle,
Star,
GraduationCap,
Vote,
Download,
AlertOctagon,
RefreshCw,
CalendarPlus,
Heart,
BarChart,
Award,
UserPlus,
UserCheck,
UserMinus,
FileCheck,
Eye,
MessageSquare,
MessageCircle,
Info,
Calendar,
Newspaper,
UserX,
Lightbulb,
BookOpen,
XCircle,
Edit,
FileUp,
Bell,
}
// Priority styles
const PRIORITY_STYLES = {
low: {
iconBg: 'bg-slate-100 dark:bg-slate-800',
iconColor: 'text-slate-500',
},
normal: {
iconBg: 'bg-blue-100 dark:bg-blue-900/30',
iconColor: 'text-blue-600 dark:text-blue-400',
},
high: {
iconBg: 'bg-amber-100 dark:bg-amber-900/30',
iconColor: 'text-amber-600 dark:text-amber-400',
},
urgent: {
iconBg: 'bg-red-100 dark:bg-red-900/30',
iconColor: 'text-red-600 dark:text-red-400',
},
}
type Notification = {
id: string
type: string
priority: string
icon: string | null
title: string
message: string
linkUrl: string | null
linkLabel: string | null
isRead: boolean
createdAt: Date
}
function NotificationItem({
notification,
onRead,
}: {
notification: Notification
onRead: () => void
}) {
const IconComponent = ICON_MAP[notification.icon || 'Bell'] || Bell
const priorityStyle =
PRIORITY_STYLES[notification.priority as keyof typeof PRIORITY_STYLES] ||
PRIORITY_STYLES.normal
const content = (
<div
className={cn(
'flex gap-3 p-3 hover:bg-muted/50 transition-colors cursor-pointer',
!notification.isRead && 'bg-blue-50/50 dark:bg-blue-950/20'
)}
onClick={onRead}
>
{/* Icon with colored background */}
<div
className={cn(
'shrink-0 w-10 h-10 rounded-full flex items-center justify-center',
priorityStyle.iconBg
)}
>
<IconComponent className={cn('h-5 w-5', priorityStyle.iconColor)} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p className={cn('text-sm', !notification.isRead && 'font-medium')}>
{notification.title}
</p>
<p className="text-sm text-muted-foreground line-clamp-2">
{notification.message}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-muted-foreground">
{formatRelativeTime(notification.createdAt)}
</span>
{notification.linkLabel && (
<span className="text-xs text-primary font-medium">
{notification.linkLabel} &rarr;
</span>
)}
</div>
</div>
{/* Unread dot */}
{!notification.isRead && (
<div className="shrink-0 w-2 h-2 rounded-full bg-primary mt-2" />
)}
</div>
)
if (notification.linkUrl) {
return (
<Link href={notification.linkUrl as Route} className="block">
{content}
</Link>
)
}
return content
}
export function NotificationBell() {
const [filter, setFilter] = useState<'all' | 'unread'>('all')
const [open, setOpen] = useState(false)
const { data: countData } = trpc.notification.getUnreadCount.useQuery(
undefined,
{
refetchInterval: 30000, // Refetch every 30 seconds
}
)
const { data: hasUrgent } = trpc.notification.hasUrgent.useQuery(undefined, {
refetchInterval: 30000,
})
const { data: notificationData, refetch } = trpc.notification.list.useQuery(
{
unreadOnly: filter === 'unread',
limit: 20,
},
{
enabled: open, // Only fetch when popover is open
}
)
const markAsReadMutation = trpc.notification.markAsRead.useMutation({
onSuccess: () => refetch(),
})
const markAllAsReadMutation = trpc.notification.markAllAsRead.useMutation({
onSuccess: () => refetch(),
})
const unreadCount = countData ?? 0
const notifications = notificationData?.notifications ?? []
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell
className={cn('h-5 w-5', hasUrgent && 'animate-pulse text-red-500')}
/>
{unreadCount > 0 && (
<span
className={cn(
'absolute -top-1 -right-1 min-w-5 h-5 px-1 rounded-full text-[10px] font-bold text-white flex items-center justify-center',
hasUrgent ? 'bg-red-500' : 'bg-primary'
)}
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
<span className="sr-only">Notifications</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 p-0" align="end">
{/* Header */}
<div className="flex items-center justify-between p-3 border-b">
<h3 className="font-semibold">Notifications</h3>
<div className="flex gap-1">
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => markAllAsReadMutation.mutate()}
disabled={markAllAsReadMutation.isPending}
>
<CheckCheck className="h-4 w-4 mr-1" />
Mark all read
</Button>
)}
<Button variant="ghost" size="icon" asChild>
<Link href={'/admin/settings' as Route}>
<Settings className="h-4 w-4" />
<span className="sr-only">Notification settings</span>
</Link>
</Button>
</div>
</div>
{/* Filter tabs */}
<div className="flex border-b">
<button
className={cn(
'flex-1 py-2 text-sm transition-colors',
filter === 'all'
? 'border-b-2 border-primary font-medium'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => setFilter('all')}
>
All
</button>
<button
className={cn(
'flex-1 py-2 text-sm transition-colors',
filter === 'unread'
? 'border-b-2 border-primary font-medium'
: 'text-muted-foreground hover:text-foreground'
)}
onClick={() => setFilter('unread')}
>
Unread ({unreadCount})
</button>
</div>
{/* Notification list */}
<ScrollArea className="h-[400px]">
<div className="divide-y">
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onRead={() => {
if (!notification.isRead) {
markAsReadMutation.mutate({ id: notification.id })
}
}}
/>
))}
{notifications.length === 0 && (
<div className="p-8 text-center">
<Bell className="h-10 w-10 mx-auto text-muted-foreground/30" />
<p className="mt-2 text-muted-foreground">
{filter === 'unread'
? 'No unread notifications'
: 'No notifications yet'}
</p>
</div>
)}
</div>
</ScrollArea>
{/* Footer */}
{notifications.length > 0 && (
<div className="p-2 border-t bg-muted/30">
<Button variant="ghost" className="w-full" asChild>
<Link href={'/admin/notifications' as Route}>View all notifications</Link>
</Button>
</div>
)}
</PopoverContent>
</Popover>
)
}

View File

@@ -780,3 +780,71 @@ export async function sendTeamMemberInviteEmail(
html: template.html,
})
}
/**
* Generate notification email template
*/
function getNotificationEmailTemplate(
name: string,
subject: string,
body: string,
linkUrl?: string
): EmailTemplate {
const greeting = name ? `Hello ${name},` : 'Hello,'
// Format body text preserving line breaks
const formattedBody = body
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
const content = `
${sectionTitle(greeting)}
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">
${formattedBody}
</div>
${linkUrl ? ctaButton(linkUrl, 'View Details') : ''}
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 13px; text-align: center;">
You received this email because of your notification preferences on the MOPC Platform.
</p>
`
return {
subject,
html: getEmailWrapper(content),
text: `
${greeting}
${body}
${linkUrl ? `View details: ${linkUrl}` : ''}
---
Monaco Ocean Protection Challenge
Together for a healthier ocean.
`,
}
}
/**
* Send notification email (triggered by in-app notification system)
*/
export async function sendNotificationEmail(
email: string,
name: string,
subject: string,
body: string,
linkUrl?: string
): Promise<void> {
const template = getNotificationEmailTemplate(name, subject, body, linkUrl)
const { transporter, from } = await getTransporter()
await transporter.sendMail({
from,
to: email,
subject: template.subject,
text: template.text,
html: template.html,
})
}

View File

@@ -59,3 +59,25 @@ export function daysUntil(date: Date | string): number {
const now = new Date()
return Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
}
export function formatRelativeTime(date: Date | string): string {
const now = new Date()
const target = new Date(date)
const diffMs = now.getTime() - target.getTime()
const diffSec = Math.floor(diffMs / 1000)
const diffMin = Math.floor(diffSec / 60)
const diffHour = Math.floor(diffMin / 60)
const diffDay = Math.floor(diffHour / 24)
const diffWeek = Math.floor(diffDay / 7)
if (diffSec < 60) return 'just now'
if (diffMin < 60) return `${diffMin}m ago`
if (diffHour < 24) return `${diffHour}h ago`
if (diffDay < 7) return `${diffDay}d ago`
if (diffWeek < 4) return `${diffWeek}w ago`
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
}).format(target)
}

View File

@@ -30,6 +30,7 @@ import { applicationRouter } from './application'
import { mentorRouter } from './mentor'
import { filteringRouter } from './filtering'
import { specialAwardRouter } from './specialAward'
import { notificationRouter } from './notification'
/**
* Root tRPC router that combines all domain routers
@@ -66,6 +67,7 @@ export const appRouter = router({
mentor: mentorRouter,
filtering: filteringRouter,
specialAward: specialAwardRouter,
notification: notificationRouter,
})
export type AppRouter = typeof appRouter

View File

@@ -6,6 +6,10 @@ import { executeFilteringRules, type ProgressCallback } from '../services/ai-fil
import { logAudit } from '../utils/audit'
import { isOpenAIConfigured, testOpenAIConnection } from '@/lib/openai'
import { prisma } from '@/lib/prisma'
import {
notifyAdmins,
NotificationTypes,
} from '../services/in-app-notification'
// Background job execution function
async function runFilteringJob(jobId: string, roundId: string, userId: string) {
@@ -123,6 +127,30 @@ async function runFilteringJob(jobId: string, roundId: string, userId: string) {
flagged: flaggedCount,
},
})
// Get round name for notification
const round = await prisma.round.findUnique({
where: { id: roundId },
select: { name: true },
})
// Notify admins that filtering is complete
await notifyAdmins({
type: NotificationTypes.FILTERING_COMPLETE,
title: 'AI Filtering Complete',
message: `Filtering complete for ${round?.name || 'round'}: ${passedCount} passed, ${flaggedCount} flagged, ${filteredCount} filtered out`,
linkUrl: `/admin/rounds/${roundId}/filtering/results`,
linkLabel: 'View Results',
priority: 'high',
metadata: {
roundId,
jobId,
projectCount: projects.length,
passedCount,
filteredCount,
flaggedCount,
},
})
} catch (error) {
console.error('[Filtering Job] Error:', error)
await prisma.filteringJob.update({
@@ -133,6 +161,17 @@ async function runFilteringJob(jobId: string, roundId: string, userId: string) {
errorMessage: error instanceof Error ? error.message : 'Unknown error',
},
})
// Notify admins of failure
await notifyAdmins({
type: NotificationTypes.FILTERING_FAILED,
title: 'AI Filtering Failed',
message: `Filtering job failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
linkUrl: `/admin/rounds/${roundId}/filtering`,
linkLabel: 'View Details',
priority: 'urgent',
metadata: { roundId, jobId, error: error instanceof Error ? error.message : 'Unknown error' },
})
}
}

View File

@@ -0,0 +1,221 @@
/**
* Notification Router
*
* Handles in-app notification CRUD operations for users.
*/
import { z } from 'zod'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import {
markNotificationAsRead,
markAllNotificationsAsRead,
getUnreadCount,
deleteExpiredNotifications,
deleteOldNotifications,
NotificationIcons,
NotificationPriorities,
} from '../services/in-app-notification'
export const notificationRouter = router({
/**
* List notifications for the current user
*/
list: protectedProcedure
.input(
z.object({
unreadOnly: z.boolean().default(false),
limit: z.number().int().min(1).max(100).default(50),
cursor: z.string().optional(), // For infinite scroll pagination
})
)
.query(async ({ ctx, input }) => {
const { unreadOnly, limit, cursor } = input
const userId = ctx.user.id
const where = {
userId,
...(unreadOnly && { isRead: false }),
// Don't show expired notifications
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
}
const notifications = await ctx.prisma.inAppNotification.findMany({
where,
take: limit + 1, // Fetch one extra to check if there are more
orderBy: { createdAt: 'desc' },
...(cursor && {
cursor: { id: cursor },
skip: 1, // Skip the cursor item
}),
})
let nextCursor: string | undefined
if (notifications.length > limit) {
const nextItem = notifications.pop()
nextCursor = nextItem?.id
}
return {
notifications,
nextCursor,
}
}),
/**
* Get unread notification count for the current user
*/
getUnreadCount: protectedProcedure.query(async ({ ctx }) => {
return getUnreadCount(ctx.user.id)
}),
/**
* Check if there are any urgent unread notifications
*/
hasUrgent: protectedProcedure.query(async ({ ctx }) => {
const count = await ctx.prisma.inAppNotification.count({
where: {
userId: ctx.user.id,
isRead: false,
priority: 'urgent',
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
},
})
return count > 0
}),
/**
* Mark a single notification as read
*/
markAsRead: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await markNotificationAsRead(input.id, ctx.user.id)
return { success: true }
}),
/**
* Mark all notifications as read for the current user
*/
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
await markAllNotificationsAsRead(ctx.user.id)
return { success: true }
}),
/**
* Delete a notification (user can only delete their own)
*/
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.inAppNotification.deleteMany({
where: {
id: input.id,
userId: ctx.user.id, // Ensure user can only delete their own
},
})
return { success: true }
}),
/**
* Get notification email settings (admin only)
*/
getEmailSettings: adminProcedure.query(async ({ ctx }) => {
return ctx.prisma.notificationEmailSetting.findMany({
orderBy: [{ category: 'asc' }, { label: 'asc' }],
include: {
updatedBy: { select: { name: true, email: true } },
},
})
}),
/**
* Update a notification email setting (admin only)
*/
updateEmailSetting: adminProcedure
.input(
z.object({
notificationType: z.string(),
sendEmail: z.boolean(),
emailSubject: z.string().optional(),
emailTemplate: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { notificationType, sendEmail, emailSubject, emailTemplate } = input
return ctx.prisma.notificationEmailSetting.upsert({
where: { notificationType },
update: {
sendEmail,
emailSubject,
emailTemplate,
updatedById: ctx.user.id,
},
create: {
notificationType,
category: 'custom',
label: notificationType,
sendEmail,
emailSubject,
emailTemplate,
updatedById: ctx.user.id,
},
})
}),
/**
* Delete expired notifications (admin cleanup)
*/
deleteExpired: adminProcedure.mutation(async () => {
const count = await deleteExpiredNotifications()
return { deletedCount: count }
}),
/**
* Delete old read notifications (admin cleanup)
*/
deleteOld: adminProcedure
.input(z.object({ olderThanDays: z.number().int().min(1).max(365).default(30) }))
.mutation(async ({ input }) => {
const count = await deleteOldNotifications(input.olderThanDays)
return { deletedCount: count }
}),
/**
* Get notification icon and priority mappings (for UI)
*/
getMappings: protectedProcedure.query(() => {
return {
icons: NotificationIcons,
priorities: NotificationPriorities,
}
}),
/**
* Admin: Get notification statistics
*/
getStats: adminProcedure.query(async ({ ctx }) => {
const [total, unread, byType, byPriority] = await Promise.all([
ctx.prisma.inAppNotification.count(),
ctx.prisma.inAppNotification.count({ where: { isRead: false } }),
ctx.prisma.inAppNotification.groupBy({
by: ['type'],
_count: true,
orderBy: { _count: { type: 'desc' } },
take: 10,
}),
ctx.prisma.inAppNotification.groupBy({
by: ['priority'],
_count: true,
}),
])
return {
total,
unread,
readRate: total > 0 ? ((total - unread) / total) * 100 : 0,
byType: byType.map((t) => ({ type: t.type, count: t._count })),
byPriority: byPriority.map((p) => ({ priority: p.priority, count: p._count })),
}
}),
})

View File

@@ -0,0 +1,473 @@
/**
* In-App Notification Service
*
* Creates and manages in-app notifications for users.
* Optionally sends email notifications based on admin settings.
*/
import { prisma } from '@/lib/prisma'
import { sendNotificationEmail } from '@/lib/email'
// Notification priority levels
export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent'
// Notification type constants
export const NotificationTypes = {
// Admin notifications
FILTERING_COMPLETE: 'FILTERING_COMPLETE',
FILTERING_FAILED: 'FILTERING_FAILED',
NEW_APPLICATION: 'NEW_APPLICATION',
BULK_APPLICATIONS: 'BULK_APPLICATIONS',
DOCUMENTS_UPLOADED: 'DOCUMENTS_UPLOADED',
EVALUATION_MILESTONE: 'EVALUATION_MILESTONE',
ALL_EVALUATIONS_DONE: 'ALL_EVALUATIONS_DONE',
JURY_INACTIVE: 'JURY_INACTIVE',
DEADLINE_24H: 'DEADLINE_24H',
DEADLINE_1H: 'DEADLINE_1H',
ROUND_AUTO_CLOSED: 'ROUND_AUTO_CLOSED',
EXPORT_READY: 'EXPORT_READY',
SYSTEM_ERROR: 'SYSTEM_ERROR',
// Jury notifications
ASSIGNED_TO_PROJECT: 'ASSIGNED_TO_PROJECT',
BATCH_ASSIGNED: 'BATCH_ASSIGNED',
PROJECT_UPDATED: 'PROJECT_UPDATED',
ROUND_NOW_OPEN: 'ROUND_NOW_OPEN',
REMINDER_3_DAYS: 'REMINDER_3_DAYS',
REMINDER_24H: 'REMINDER_24H',
REMINDER_1H: 'REMINDER_1H',
ROUND_EXTENDED: 'ROUND_EXTENDED',
ROUND_CLOSED: 'ROUND_CLOSED',
THANK_YOU: 'THANK_YOU',
RESULTS_AVAILABLE: 'RESULTS_AVAILABLE',
// Jury - Award specific
AWARD_JURY_SELECTED: 'AWARD_JURY_SELECTED',
AWARD_VOTING_OPEN: 'AWARD_VOTING_OPEN',
AWARD_REMINDER: 'AWARD_REMINDER',
AWARD_RESULTS: 'AWARD_RESULTS',
// Mentor notifications
MENTEE_ASSIGNED: 'MENTEE_ASSIGNED',
MENTEE_BATCH_ASSIGNED: 'MENTEE_BATCH_ASSIGNED',
MENTEE_INTRO: 'MENTEE_INTRO',
MENTEE_UPLOADED_DOCS: 'MENTEE_UPLOADED_DOCS',
MENTEE_UPDATED_PROJECT: 'MENTEE_UPDATED_PROJECT',
MENTEE_ADVANCED: 'MENTEE_ADVANCED',
MENTEE_FINALIST: 'MENTEE_FINALIST',
MENTEE_WON: 'MENTEE_WON',
MENTEE_ELIMINATED: 'MENTEE_ELIMINATED',
MENTORSHIP_TIP: 'MENTORSHIP_TIP',
NEW_RESOURCE: 'NEW_RESOURCE',
// Team/Applicant notifications
APPLICATION_SUBMITTED: 'APPLICATION_SUBMITTED',
APPLICATION_INCOMPLETE: 'APPLICATION_INCOMPLETE',
TEAM_INVITE_RECEIVED: 'TEAM_INVITE_RECEIVED',
TEAM_MEMBER_JOINED: 'TEAM_MEMBER_JOINED',
TEAM_MEMBER_LEFT: 'TEAM_MEMBER_LEFT',
DOCUMENTS_RECEIVED: 'DOCUMENTS_RECEIVED',
REVIEW_IN_PROGRESS: 'REVIEW_IN_PROGRESS',
ADVANCED_SEMIFINAL: 'ADVANCED_SEMIFINAL',
ADVANCED_FINAL: 'ADVANCED_FINAL',
MENTOR_ASSIGNED: 'MENTOR_ASSIGNED',
MENTOR_MESSAGE: 'MENTOR_MESSAGE',
NOT_SELECTED: 'NOT_SELECTED',
FEEDBACK_AVAILABLE: 'FEEDBACK_AVAILABLE',
EVENT_INVITATION: 'EVENT_INVITATION',
WINNER_ANNOUNCEMENT: 'WINNER_ANNOUNCEMENT',
CERTIFICATE_READY: 'CERTIFICATE_READY',
PROGRAM_NEWSLETTER: 'PROGRAM_NEWSLETTER',
// Observer notifications
ROUND_STARTED: 'ROUND_STARTED',
ROUND_PROGRESS: 'ROUND_PROGRESS',
ROUND_COMPLETED: 'ROUND_COMPLETED',
FINALISTS_ANNOUNCED: 'FINALISTS_ANNOUNCED',
WINNERS_ANNOUNCED: 'WINNERS_ANNOUNCED',
REPORT_AVAILABLE: 'REPORT_AVAILABLE',
} as const
export type NotificationType = (typeof NotificationTypes)[keyof typeof NotificationTypes]
// Notification icons by type
export const NotificationIcons: Record<string, string> = {
[NotificationTypes.FILTERING_COMPLETE]: 'Brain',
[NotificationTypes.FILTERING_FAILED]: 'AlertTriangle',
[NotificationTypes.NEW_APPLICATION]: 'FileText',
[NotificationTypes.BULK_APPLICATIONS]: 'Files',
[NotificationTypes.DOCUMENTS_UPLOADED]: 'Upload',
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'ClipboardList',
[NotificationTypes.ROUND_NOW_OPEN]: 'PlayCircle',
[NotificationTypes.REMINDER_24H]: 'Clock',
[NotificationTypes.REMINDER_1H]: 'AlertCircle',
[NotificationTypes.ROUND_CLOSED]: 'Lock',
[NotificationTypes.MENTEE_ASSIGNED]: 'Users',
[NotificationTypes.MENTEE_ADVANCED]: 'TrendingUp',
[NotificationTypes.MENTEE_WON]: 'Trophy',
[NotificationTypes.APPLICATION_SUBMITTED]: 'CheckCircle',
[NotificationTypes.ADVANCED_SEMIFINAL]: 'TrendingUp',
[NotificationTypes.ADVANCED_FINAL]: 'Star',
[NotificationTypes.MENTOR_ASSIGNED]: 'GraduationCap',
[NotificationTypes.WINNER_ANNOUNCEMENT]: 'Trophy',
[NotificationTypes.AWARD_VOTING_OPEN]: 'Vote',
[NotificationTypes.AWARD_RESULTS]: 'Trophy',
}
// Priority by notification type
export const NotificationPriorities: Record<string, NotificationPriority> = {
[NotificationTypes.FILTERING_COMPLETE]: 'high',
[NotificationTypes.FILTERING_FAILED]: 'urgent',
[NotificationTypes.DEADLINE_1H]: 'urgent',
[NotificationTypes.REMINDER_1H]: 'urgent',
[NotificationTypes.SYSTEM_ERROR]: 'urgent',
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'high',
[NotificationTypes.ROUND_NOW_OPEN]: 'high',
[NotificationTypes.DEADLINE_24H]: 'high',
[NotificationTypes.REMINDER_24H]: 'high',
[NotificationTypes.MENTEE_ASSIGNED]: 'high',
[NotificationTypes.APPLICATION_SUBMITTED]: 'high',
[NotificationTypes.ADVANCED_SEMIFINAL]: 'high',
[NotificationTypes.ADVANCED_FINAL]: 'high',
[NotificationTypes.WINNER_ANNOUNCEMENT]: 'high',
[NotificationTypes.AWARD_VOTING_OPEN]: 'high',
}
interface CreateNotificationParams {
userId: string
type: string
title: string
message: string
linkUrl?: string
linkLabel?: string
icon?: string
priority?: NotificationPriority
metadata?: Record<string, unknown>
groupKey?: string
expiresAt?: Date
}
/**
* Create a single in-app notification
*/
export async function createNotification(
params: CreateNotificationParams
): Promise<void> {
const {
userId,
type,
title,
message,
linkUrl,
linkLabel,
icon,
priority,
metadata,
groupKey,
expiresAt,
} = params
// Determine icon and priority if not provided
const finalIcon = icon || NotificationIcons[type] || 'Bell'
const finalPriority = priority || NotificationPriorities[type] || 'normal'
// Check for existing notification with same groupKey (for batching)
if (groupKey) {
const existingNotification = await prisma.inAppNotification.findFirst({
where: {
userId,
groupKey,
isRead: false,
createdAt: {
gte: new Date(Date.now() - 60 * 60 * 1000), // Within last hour
},
},
})
if (existingNotification) {
// Update existing notification instead of creating new one
const existingMeta = existingNotification.metadata as Record<string, unknown> || {}
const currentCount = (existingMeta.count as number) || 1
await prisma.inAppNotification.update({
where: { id: existingNotification.id },
data: {
message,
metadata: { ...existingMeta, ...metadata, count: currentCount + 1 },
createdAt: new Date(), // Bump to top
},
})
return
}
}
// Create the in-app notification
await prisma.inAppNotification.create({
data: {
userId,
type,
title,
message,
linkUrl,
linkLabel,
icon: finalIcon,
priority: finalPriority,
metadata: metadata as object | undefined,
groupKey,
expiresAt,
},
})
// Check if we should also send an email
await maybeSendEmail(userId, type, title, message, linkUrl)
}
/**
* Create notifications for multiple users
*/
export async function createBulkNotifications(params: {
userIds: string[]
type: string
title: string
message: string
linkUrl?: string
linkLabel?: string
icon?: string
priority?: NotificationPriority
metadata?: Record<string, unknown>
}): Promise<void> {
const {
userIds,
type,
title,
message,
linkUrl,
linkLabel,
icon,
priority,
metadata,
} = params
const finalIcon = icon || NotificationIcons[type] || 'Bell'
const finalPriority = priority || NotificationPriorities[type] || 'normal'
// Create notifications in bulk
await prisma.inAppNotification.createMany({
data: userIds.map((userId) => ({
userId,
type,
title,
message,
linkUrl,
linkLabel,
icon: finalIcon,
priority: finalPriority,
metadata: metadata as object | undefined,
})),
})
// Check email settings and send emails
for (const userId of userIds) {
await maybeSendEmail(userId, type, title, message, linkUrl)
}
}
/**
* Notify all admin users
*/
export async function notifyAdmins(params: {
type: string
title: string
message: string
linkUrl?: string
linkLabel?: string
icon?: string
priority?: NotificationPriority
metadata?: Record<string, unknown>
}): Promise<void> {
const admins = await prisma.user.findMany({
where: {
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
status: 'ACTIVE',
},
select: { id: true },
})
if (admins.length === 0) return
await createBulkNotifications({
...params,
userIds: admins.map((a) => a.id),
})
}
/**
* Notify all jury members for a specific round
*/
export async function notifyRoundJury(
roundId: string,
params: Omit<CreateNotificationParams, 'userId'>
): Promise<void> {
const assignments = await prisma.assignment.findMany({
where: { roundId },
select: { userId: true },
distinct: ['userId'],
})
if (assignments.length === 0) return
await createBulkNotifications({
...params,
userIds: assignments.map((a) => a.userId),
})
}
/**
* Notify team members of a project
*/
export async function notifyProjectTeam(
projectId: string,
params: Omit<CreateNotificationParams, 'userId'>
): Promise<void> {
const teamMembers = await prisma.teamMember.findMany({
where: { projectId },
include: { user: { select: { id: true } } },
})
const userIds = teamMembers
.filter((tm) => tm.user)
.map((tm) => tm.user!.id)
if (userIds.length === 0) return
await createBulkNotifications({
...params,
userIds,
})
}
/**
* Notify assigned mentors of a project
*/
export async function notifyProjectMentors(
projectId: string,
params: Omit<CreateNotificationParams, 'userId'>
): Promise<void> {
const mentorAssignments = await prisma.mentorAssignment.findMany({
where: { projectId },
select: { mentorId: true },
})
if (mentorAssignments.length === 0) return
await createBulkNotifications({
...params,
userIds: mentorAssignments.map((ma) => ma.mentorId),
})
}
/**
* Check email settings and send email if enabled
*/
async function maybeSendEmail(
userId: string,
type: string,
title: string,
message: string,
linkUrl?: string
): Promise<void> {
try {
// Check if email is enabled for this notification type
const emailSetting = await prisma.notificationEmailSetting.findUnique({
where: { notificationType: type },
})
// If no setting exists, don't send email by default
if (!emailSetting || !emailSetting.sendEmail) {
return
}
// Check user's notification preference
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true, name: true, notificationPreference: true },
})
if (!user || user.notificationPreference === 'NONE') {
return
}
// Send the email
const subject = emailSetting.emailSubject || title
const body = emailSetting.emailTemplate
? emailSetting.emailTemplate
.replace('{title}', title)
.replace('{message}', message)
.replace('{link}', linkUrl || '')
: message
await sendNotificationEmail(user.email, user.name || 'User', subject, body, linkUrl)
} catch (error) {
// Log but don't fail the notification creation
console.error('[Notification] Failed to send email:', error)
}
}
/**
* Mark a notification as read
*/
export async function markNotificationAsRead(
notificationId: string,
userId: string
): Promise<void> {
await prisma.inAppNotification.updateMany({
where: { id: notificationId, userId },
data: { isRead: true, readAt: new Date() },
})
}
/**
* Mark all notifications as read for a user
*/
export async function markAllNotificationsAsRead(userId: string): Promise<void> {
await prisma.inAppNotification.updateMany({
where: { userId, isRead: false },
data: { isRead: true, readAt: new Date() },
})
}
/**
* Get unread notification count for a user
*/
export async function getUnreadCount(userId: string): Promise<number> {
return prisma.inAppNotification.count({
where: { userId, isRead: false },
})
}
/**
* Delete expired notifications
*/
export async function deleteExpiredNotifications(): Promise<number> {
const result = await prisma.inAppNotification.deleteMany({
where: {
expiresAt: { lt: new Date() },
},
})
return result.count
}
/**
* Delete old read notifications (cleanup job)
*/
export async function deleteOldNotifications(olderThanDays: number): Promise<number> {
const cutoffDate = new Date()
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays)
const result = await prisma.inAppNotification.deleteMany({
where: {
isRead: true,
createdAt: { lt: cutoffDate },
},
})
return result.count
}