Add notification bell system and MOPC onboarding form
Build and Push Docker Image / build (push) Successful in 8m59s Details

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:
Matt 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,58 @@
-- CreateTable
CREATE TABLE "InAppNotification" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"priority" TEXT NOT NULL DEFAULT 'normal',
"icon" TEXT,
"title" TEXT NOT NULL,
"message" TEXT NOT NULL,
"linkUrl" TEXT,
"linkLabel" TEXT,
"metadata" JSONB,
"groupKey" TEXT,
"isRead" BOOLEAN NOT NULL DEFAULT false,
"readAt" TIMESTAMP(3),
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "InAppNotification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NotificationEmailSetting" (
"id" TEXT NOT NULL,
"notificationType" TEXT NOT NULL,
"category" TEXT NOT NULL,
"label" TEXT NOT NULL,
"description" TEXT,
"sendEmail" BOOLEAN NOT NULL DEFAULT true,
"emailSubject" TEXT,
"emailTemplate" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"updatedById" TEXT,
CONSTRAINT "NotificationEmailSetting_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "InAppNotification_userId_isRead_idx" ON "InAppNotification"("userId", "isRead");
-- CreateIndex
CREATE INDEX "InAppNotification_userId_createdAt_idx" ON "InAppNotification"("userId", "createdAt");
-- CreateIndex
CREATE INDEX "InAppNotification_groupKey_idx" ON "InAppNotification"("groupKey");
-- CreateIndex
CREATE UNIQUE INDEX "NotificationEmailSetting_notificationType_key" ON "NotificationEmailSetting"("notificationType");
-- CreateIndex
CREATE INDEX "NotificationEmailSetting_category_idx" ON "NotificationEmailSetting"("category");
-- AddForeignKey
ALTER TABLE "InAppNotification" ADD CONSTRAINT "InAppNotification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationEmailSetting" ADD CONSTRAINT "NotificationEmailSetting_updatedById_fkey" FOREIGN KEY ("updatedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -278,6 +278,10 @@ model User {
// Award overrides
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
// In-app notifications
notifications InAppNotification[] @relation("UserNotifications")
notificationSettingsUpdated NotificationEmailSetting[] @relation("NotificationSettingUpdater")
// NextAuth relations
accounts Account[]
sessions Session[]
@ -759,6 +763,55 @@ model NotificationLog {
@@index([createdAt])
}
// =============================================================================
// IN-APP NOTIFICATIONS
// =============================================================================
model InAppNotification {
id String @id @default(cuid())
userId String
type String // FILTERING_COMPLETE, NEW_APPLICATION, ASSIGNED_TO_PROJECT, etc.
priority String @default("normal") // low, normal, high, urgent
icon String? // lucide icon name
title String
message String @db.Text
linkUrl String? // Where to navigate when clicked
linkLabel String? // CTA text
metadata Json? @db.JsonB // Extra context (projectId, roundId, etc.)
groupKey String? // For batching similar notifications
isRead Boolean @default(false)
readAt DateTime?
expiresAt DateTime? // Auto-dismiss after date
createdAt DateTime @default(now())
// Relations
user User @relation("UserNotifications", fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, isRead])
@@index([userId, createdAt])
@@index([groupKey])
}
model NotificationEmailSetting {
id String @id @default(cuid())
notificationType String @unique // e.g., "ADVANCED_TO_ROUND", "ASSIGNED_TO_PROJECT"
category String // "team", "jury", "mentor", "admin"
label String // Human-readable label for admin UI
description String? // Help text
sendEmail Boolean @default(true)
emailSubject String? // Custom subject template (optional)
emailTemplate String? @db.Text // Custom body template (optional)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
updatedById String?
updatedBy User? @relation("NotificationSettingUpdater", fields: [updatedById], references: [id])
@@index([category])
}
// =============================================================================
// LEARNING HUB (Phase 2)
// =============================================================================

View File

@ -0,0 +1,456 @@
/**
* Seed script for MOPC Onboarding Form
*
* This creates the application form configuration for the Monaco Ocean Protection Challenge.
* The form is accessible at /apply/mopc-2026
*
* Run with: npx tsx prisma/seed-mopc-onboarding.ts
*/
import { PrismaClient, FormFieldType, SpecialFieldType } from '@prisma/client'
const prisma = new PrismaClient()
const MOPC_FORM_CONFIG = {
name: 'MOPC Application 2026',
description: 'Monaco Ocean Protection Challenge application form',
publicSlug: 'mopc-2026',
status: 'PUBLISHED',
isPublic: true,
sendConfirmationEmail: true,
sendTeamInviteEmails: true,
confirmationEmailSubject: 'Application Received - Monaco Ocean Protection Challenge',
confirmationEmailBody: `Thank you for applying to the Monaco Ocean Protection Challenge 2026!
We have received your application and our team will review it carefully.
If you have any questions, please don't hesitate to reach out.
Good luck!
The MOPC Team`,
confirmationMessage: 'Thank you for your application! We have sent a confirmation email to the address you provided. Our team will review your submission and get back to you soon.',
}
const STEPS = [
{
name: 'category',
title: 'Competition Category',
description: 'Select your competition track',
sortOrder: 0,
isOptional: false,
fields: [
{
name: 'competitionCategory',
label: 'Which category best describes your project?',
fieldType: FormFieldType.RADIO,
specialType: SpecialFieldType.COMPETITION_CATEGORY,
required: true,
sortOrder: 0,
width: 'full',
projectMapping: 'competitionCategory',
description: 'Choose the category that best fits your stage of development',
optionsJson: [
{
value: 'STARTUP',
label: 'Startup',
description: 'You have an existing company or registered business entity',
},
{
value: 'BUSINESS_CONCEPT',
label: 'Business Concept',
description: 'You are a student, graduate, or have an idea not yet incorporated',
},
],
},
],
},
{
name: 'contact',
title: 'Contact Information',
description: 'Tell us how to reach you',
sortOrder: 1,
isOptional: false,
fields: [
{
name: 'contactName',
label: 'Full Name',
fieldType: FormFieldType.TEXT,
required: true,
sortOrder: 0,
width: 'half',
placeholder: 'Enter your full name',
},
{
name: 'contactEmail',
label: 'Email Address',
fieldType: FormFieldType.EMAIL,
required: true,
sortOrder: 1,
width: 'half',
placeholder: 'your.email@example.com',
description: 'We will use this email for all communications',
},
{
name: 'contactPhone',
label: 'Phone Number',
fieldType: FormFieldType.PHONE,
required: true,
sortOrder: 2,
width: 'half',
placeholder: '+1 (555) 123-4567',
},
{
name: 'country',
label: 'Country',
fieldType: FormFieldType.SELECT,
specialType: SpecialFieldType.COUNTRY_SELECT,
required: true,
sortOrder: 3,
width: 'half',
projectMapping: 'country',
},
{
name: 'city',
label: 'City',
fieldType: FormFieldType.TEXT,
required: false,
sortOrder: 4,
width: 'half',
placeholder: 'City name',
},
],
},
{
name: 'project',
title: 'Project Details',
description: 'Tell us about your ocean protection project',
sortOrder: 2,
isOptional: false,
fields: [
{
name: 'projectName',
label: 'Project Name',
fieldType: FormFieldType.TEXT,
required: true,
sortOrder: 0,
width: 'full',
projectMapping: 'title',
maxLength: 200,
placeholder: 'Give your project a memorable name',
},
{
name: 'teamName',
label: 'Team / Company Name',
fieldType: FormFieldType.TEXT,
required: false,
sortOrder: 1,
width: 'half',
projectMapping: 'teamName',
placeholder: 'Your team or company name',
},
{
name: 'oceanIssue',
label: 'Primary Ocean Issue',
fieldType: FormFieldType.SELECT,
specialType: SpecialFieldType.OCEAN_ISSUE,
required: true,
sortOrder: 2,
width: 'half',
projectMapping: 'oceanIssue',
description: 'Select the primary ocean issue your project addresses',
},
{
name: 'description',
label: 'Project Description',
fieldType: FormFieldType.TEXTAREA,
required: true,
sortOrder: 3,
width: 'full',
projectMapping: 'description',
minLength: 50,
maxLength: 2000,
placeholder: 'Describe your project, its goals, and how it will help protect the ocean...',
description: 'Provide a clear description of your project (50-2000 characters)',
},
{
name: 'websiteUrl',
label: 'Website URL',
fieldType: FormFieldType.URL,
required: false,
sortOrder: 4,
width: 'half',
projectMapping: 'websiteUrl',
placeholder: 'https://yourproject.com',
},
],
},
{
name: 'team',
title: 'Team Members',
description: 'Add your team members (they will receive email invitations)',
sortOrder: 3,
isOptional: true,
fields: [
{
name: 'teamMembers',
label: 'Team Members',
fieldType: FormFieldType.TEXT, // Will use specialType for rendering
specialType: SpecialFieldType.TEAM_MEMBERS,
required: false,
sortOrder: 0,
width: 'full',
description: 'Add up to 5 team members. They will receive an invitation email to join your application.',
},
],
},
{
name: 'additional',
title: 'Additional Details',
description: 'A few more questions about your project',
sortOrder: 4,
isOptional: false,
fields: [
{
name: 'institution',
label: 'University / School',
fieldType: FormFieldType.TEXT,
required: false,
sortOrder: 0,
width: 'half',
projectMapping: 'institution',
placeholder: 'Name of your institution',
conditionJson: {
field: 'competitionCategory',
operator: 'equals',
value: 'BUSINESS_CONCEPT',
},
},
{
name: 'startupCreatedDate',
label: 'Startup Founded Date',
fieldType: FormFieldType.DATE,
required: false,
sortOrder: 1,
width: 'half',
description: 'When was your company founded?',
conditionJson: {
field: 'competitionCategory',
operator: 'equals',
value: 'STARTUP',
},
},
{
name: 'wantsMentorship',
label: 'I am interested in receiving mentorship',
fieldType: FormFieldType.CHECKBOX,
required: false,
sortOrder: 2,
width: 'full',
projectMapping: 'wantsMentorship',
description: 'Check this box if you would like to be paired with an expert mentor',
},
{
name: 'referralSource',
label: 'How did you hear about MOPC?',
fieldType: FormFieldType.SELECT,
required: false,
sortOrder: 3,
width: 'half',
optionsJson: [
{ value: 'social_media', label: 'Social Media' },
{ value: 'search_engine', label: 'Search Engine' },
{ value: 'word_of_mouth', label: 'Word of Mouth' },
{ value: 'university', label: 'University / School' },
{ value: 'partner', label: 'Partner Organization' },
{ value: 'media', label: 'News / Media' },
{ value: 'event', label: 'Event / Conference' },
{ value: 'other', label: 'Other' },
],
},
],
},
{
name: 'review',
title: 'Review & Submit',
description: 'Review your application and accept the terms',
sortOrder: 5,
isOptional: false,
fields: [
{
name: 'instructions',
label: 'Review Instructions',
fieldType: FormFieldType.INSTRUCTIONS,
required: false,
sortOrder: 0,
width: 'full',
description: 'Please review all the information you have provided. Once submitted, you will not be able to make changes.',
},
{
name: 'gdprConsent',
label: 'I consent to the processing of my personal data in accordance with the GDPR and the MOPC Privacy Policy',
fieldType: FormFieldType.CHECKBOX,
specialType: SpecialFieldType.GDPR_CONSENT,
required: true,
sortOrder: 1,
width: 'full',
},
{
name: 'termsAccepted',
label: 'I have read and accept the Terms and Conditions of the Monaco Ocean Protection Challenge',
fieldType: FormFieldType.CHECKBOX,
required: true,
sortOrder: 2,
width: 'full',
},
],
},
]
async function main() {
console.log('Seeding MOPC onboarding form...')
// Check if form already exists
const existingForm = await prisma.applicationForm.findUnique({
where: { publicSlug: MOPC_FORM_CONFIG.publicSlug },
})
if (existingForm) {
console.log('Form with slug "mopc-2026" already exists. Updating...')
// Delete existing steps and fields to recreate them
await prisma.applicationFormField.deleteMany({
where: { formId: existingForm.id },
})
await prisma.onboardingStep.deleteMany({
where: { formId: existingForm.id },
})
// Update the form
await prisma.applicationForm.update({
where: { id: existingForm.id },
data: {
name: MOPC_FORM_CONFIG.name,
description: MOPC_FORM_CONFIG.description,
status: MOPC_FORM_CONFIG.status,
isPublic: MOPC_FORM_CONFIG.isPublic,
sendConfirmationEmail: MOPC_FORM_CONFIG.sendConfirmationEmail,
sendTeamInviteEmails: MOPC_FORM_CONFIG.sendTeamInviteEmails,
confirmationEmailSubject: MOPC_FORM_CONFIG.confirmationEmailSubject,
confirmationEmailBody: MOPC_FORM_CONFIG.confirmationEmailBody,
confirmationMessage: MOPC_FORM_CONFIG.confirmationMessage,
},
})
// Create steps and fields
for (const stepData of STEPS) {
const step = await prisma.onboardingStep.create({
data: {
formId: existingForm.id,
name: stepData.name,
title: stepData.title,
description: stepData.description,
sortOrder: stepData.sortOrder,
isOptional: stepData.isOptional,
},
})
for (const fieldData of stepData.fields) {
const field = fieldData as Record<string, unknown>
await prisma.applicationFormField.create({
data: {
formId: existingForm.id,
stepId: step.id,
name: field.name as string,
label: field.label as string,
fieldType: field.fieldType as FormFieldType,
specialType: (field.specialType as SpecialFieldType) || null,
required: field.required as boolean,
sortOrder: field.sortOrder as number,
width: field.width as string,
description: (field.description as string) || null,
placeholder: (field.placeholder as string) || null,
projectMapping: (field.projectMapping as string) || null,
minLength: (field.minLength as number) || null,
maxLength: (field.maxLength as number) || null,
optionsJson: field.optionsJson as object | undefined,
conditionJson: field.conditionJson as object | undefined,
},
})
}
console.log(` - Created step: ${stepData.title} (${stepData.fields.length} fields)`)
}
console.log(`\nForm updated: ${existingForm.id}`)
return
}
// Create new form
const form = await prisma.applicationForm.create({
data: {
name: MOPC_FORM_CONFIG.name,
description: MOPC_FORM_CONFIG.description,
publicSlug: MOPC_FORM_CONFIG.publicSlug,
status: MOPC_FORM_CONFIG.status,
isPublic: MOPC_FORM_CONFIG.isPublic,
sendConfirmationEmail: MOPC_FORM_CONFIG.sendConfirmationEmail,
sendTeamInviteEmails: MOPC_FORM_CONFIG.sendTeamInviteEmails,
confirmationEmailSubject: MOPC_FORM_CONFIG.confirmationEmailSubject,
confirmationEmailBody: MOPC_FORM_CONFIG.confirmationEmailBody,
confirmationMessage: MOPC_FORM_CONFIG.confirmationMessage,
},
})
console.log(`Created form: ${form.id}`)
// Create steps and fields
for (const stepData of STEPS) {
const step = await prisma.onboardingStep.create({
data: {
formId: form.id,
name: stepData.name,
title: stepData.title,
description: stepData.description,
sortOrder: stepData.sortOrder,
isOptional: stepData.isOptional,
},
})
for (const fieldData of stepData.fields) {
const field = fieldData as Record<string, unknown>
await prisma.applicationFormField.create({
data: {
formId: form.id,
stepId: step.id,
name: field.name as string,
label: field.label as string,
fieldType: field.fieldType as FormFieldType,
specialType: (field.specialType as SpecialFieldType) || null,
required: field.required as boolean,
sortOrder: field.sortOrder as number,
width: field.width as string,
description: (field.description as string) || null,
placeholder: (field.placeholder as string) || null,
projectMapping: (field.projectMapping as string) || null,
minLength: (field.minLength as number) || null,
maxLength: (field.maxLength as number) || null,
optionsJson: field.optionsJson as object | undefined,
conditionJson: field.conditionJson as object | undefined,
},
})
}
console.log(` - Created step: ${stepData.title} (${stepData.fields.length} fields)`)
}
console.log(`\nMOPC form seeded successfully!`)
console.log(`Form ID: ${form.id}`)
console.log(`Public URL: /apply/${form.publicSlug}`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -0,0 +1,253 @@
/**
* Seed script for notification email settings
*
* Run with: npx tsx prisma/seed-notification-settings.ts
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
// Default notification email settings by category
const NOTIFICATION_EMAIL_SETTINGS = [
// Team / Applicant notifications
{
notificationType: 'APPLICATION_SUBMITTED',
category: 'team',
label: 'Application Submitted',
description: 'When a team submits their application',
sendEmail: true,
},
{
notificationType: 'TEAM_INVITE_RECEIVED',
category: 'team',
label: 'Team Invitation Received',
description: 'When someone is invited to join a team',
sendEmail: true,
},
{
notificationType: 'TEAM_MEMBER_JOINED',
category: 'team',
label: 'Team Member Joined',
description: 'When a new member joins the team',
sendEmail: false,
},
{
notificationType: 'ADVANCED_SEMIFINAL',
category: 'team',
label: 'Advanced to Semi-Finals',
description: 'When a project advances to semi-finals',
sendEmail: true,
},
{
notificationType: 'ADVANCED_FINAL',
category: 'team',
label: 'Selected as Finalist',
description: 'When a project is selected as a finalist',
sendEmail: true,
},
{
notificationType: 'MENTOR_ASSIGNED',
category: 'team',
label: 'Mentor Assigned',
description: 'When a mentor is assigned to the team',
sendEmail: true,
},
{
notificationType: 'NOT_SELECTED',
category: 'team',
label: 'Not Selected',
description: 'When a project is not selected for the next round',
sendEmail: true,
},
{
notificationType: 'FEEDBACK_AVAILABLE',
category: 'team',
label: 'Feedback Available',
description: 'When jury feedback becomes available',
sendEmail: true,
},
{
notificationType: 'WINNER_ANNOUNCEMENT',
category: 'team',
label: 'Winner Announcement',
description: 'When a project wins an award',
sendEmail: true,
},
// Jury notifications
{
notificationType: 'ASSIGNED_TO_PROJECT',
category: 'jury',
label: 'Assigned to Project',
description: 'When a jury member is assigned to a project',
sendEmail: true,
},
{
notificationType: 'BATCH_ASSIGNED',
category: 'jury',
label: 'Batch Assignment',
description: 'When multiple projects are assigned at once',
sendEmail: true,
},
{
notificationType: 'ROUND_NOW_OPEN',
category: 'jury',
label: 'Round Now Open',
description: 'When a round opens for evaluation',
sendEmail: true,
},
{
notificationType: 'REMINDER_24H',
category: 'jury',
label: 'Reminder (24h)',
description: 'Reminder 24 hours before deadline',
sendEmail: true,
},
{
notificationType: 'REMINDER_1H',
category: 'jury',
label: 'Reminder (1h)',
description: 'Urgent reminder 1 hour before deadline',
sendEmail: true,
},
{
notificationType: 'ROUND_CLOSED',
category: 'jury',
label: 'Round Closed',
description: 'When a round closes',
sendEmail: false,
},
{
notificationType: 'AWARD_VOTING_OPEN',
category: 'jury',
label: 'Award Voting Open',
description: 'When special award voting opens',
sendEmail: true,
},
// Mentor notifications
{
notificationType: 'MENTEE_ASSIGNED',
category: 'mentor',
label: 'Mentee Assigned',
description: 'When assigned as mentor to a project',
sendEmail: true,
},
{
notificationType: 'MENTEE_UPLOADED_DOCS',
category: 'mentor',
label: 'Mentee Documents Updated',
description: 'When a mentee uploads new documents',
sendEmail: false,
},
{
notificationType: 'MENTEE_ADVANCED',
category: 'mentor',
label: 'Mentee Advanced',
description: 'When a mentee advances to the next round',
sendEmail: true,
},
{
notificationType: 'MENTEE_FINALIST',
category: 'mentor',
label: 'Mentee is Finalist',
description: 'When a mentee is selected as finalist',
sendEmail: true,
},
{
notificationType: 'MENTEE_WON',
category: 'mentor',
label: 'Mentee Won',
description: 'When a mentee wins an award',
sendEmail: true,
},
// Observer notifications
{
notificationType: 'ROUND_STARTED',
category: 'observer',
label: 'Round Started',
description: 'When a new round begins',
sendEmail: false,
},
{
notificationType: 'ROUND_COMPLETED',
category: 'observer',
label: 'Round Completed',
description: 'When a round is completed',
sendEmail: true,
},
{
notificationType: 'FINALISTS_ANNOUNCED',
category: 'observer',
label: 'Finalists Announced',
description: 'When finalists are announced',
sendEmail: true,
},
{
notificationType: 'WINNERS_ANNOUNCED',
category: 'observer',
label: 'Winners Announced',
description: 'When winners are announced',
sendEmail: true,
},
// Admin notifications (in-app only by default)
{
notificationType: 'FILTERING_COMPLETE',
category: 'admin',
label: 'AI Filtering Complete',
description: 'When AI filtering job completes',
sendEmail: false,
},
{
notificationType: 'FILTERING_FAILED',
category: 'admin',
label: 'AI Filtering Failed',
description: 'When AI filtering job fails',
sendEmail: true,
},
{
notificationType: 'NEW_APPLICATION',
category: 'admin',
label: 'New Application',
description: 'When a new application is received',
sendEmail: false,
},
{
notificationType: 'SYSTEM_ERROR',
category: 'admin',
label: 'System Error',
description: 'When a system error occurs',
sendEmail: true,
},
]
async function main() {
console.log('Seeding notification email settings...')
for (const setting of NOTIFICATION_EMAIL_SETTINGS) {
await prisma.notificationEmailSetting.upsert({
where: { notificationType: setting.notificationType },
update: {
category: setting.category,
label: setting.label,
description: setting.description,
},
create: setting,
})
console.log(` - ${setting.label} (${setting.notificationType})`)
}
console.log(`\nSeeded ${NOTIFICATION_EMAIL_SETTINGS.length} notification email settings.`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

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
}