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,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()
})