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