From 0277768ed7b396c2d03a47680e97999f432de5ea Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 3 Feb 2026 21:30:25 +0100 Subject: [PATCH] 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 --- .../migration.sql | 58 +++ prisma/schema.prisma | 53 ++ prisma/seed-mopc-onboarding.ts | 456 +++++++++++++++++ prisma/seed-notification-settings.ts | 253 ++++++++++ src/app/(public)/apply/page.tsx | 160 ++++++ src/components/layouts/admin-sidebar.tsx | 32 +- src/components/layouts/jury-nav.tsx | 2 + src/components/layouts/mentor-nav.tsx | 2 + src/components/layouts/observer-nav.tsx | 2 + .../settings/notification-settings-form.tsx | 140 ++++++ src/components/settings/settings-content.tsx | 22 +- src/components/shared/notification-bell.tsx | 352 +++++++++++++ src/lib/email.ts | 68 +++ src/lib/utils.ts | 22 + src/server/routers/_app.ts | 2 + src/server/routers/filtering.ts | 39 ++ src/server/routers/notification.ts | 221 ++++++++ src/server/services/in-app-notification.ts | 473 ++++++++++++++++++ 18 files changed, 2344 insertions(+), 13 deletions(-) create mode 100644 prisma/migrations/20260203220000_add_in_app_notifications/migration.sql create mode 100644 prisma/seed-mopc-onboarding.ts create mode 100644 prisma/seed-notification-settings.ts create mode 100644 src/app/(public)/apply/page.tsx create mode 100644 src/components/settings/notification-settings-form.tsx create mode 100644 src/components/shared/notification-bell.tsx create mode 100644 src/server/routers/notification.ts create mode 100644 src/server/services/in-app-notification.ts diff --git a/prisma/migrations/20260203220000_add_in_app_notifications/migration.sql b/prisma/migrations/20260203220000_add_in_app_notifications/migration.sql new file mode 100644 index 0000000..061aa8e --- /dev/null +++ b/prisma/migrations/20260203220000_add_in_app_notifications/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2636a63..427d7f6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) // ============================================================================= diff --git a/prisma/seed-mopc-onboarding.ts b/prisma/seed-mopc-onboarding.ts new file mode 100644 index 0000000..5fe45b8 --- /dev/null +++ b/prisma/seed-mopc-onboarding.ts @@ -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 + 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 + 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() + }) diff --git a/prisma/seed-notification-settings.ts b/prisma/seed-notification-settings.ts new file mode 100644 index 0000000..8add7ad --- /dev/null +++ b/prisma/seed-notification-settings.ts @@ -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() + }) diff --git a/src/app/(public)/apply/page.tsx b/src/app/(public)/apply/page.tsx new file mode 100644 index 0000000..2605f5b --- /dev/null +++ b/src/app/(public)/apply/page.tsx @@ -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 ( +
+
+
+ +
+ + + + +

Applications Not Open

+

+ There are currently no open applications. Please check back later + or visit our website for more information. +

+ +
+
+
+
+ ) + } + + // Multiple forms available - show selection + return ( +
+
+
+ +

Apply Now

+

+ Select an application form below to get started. +

+
+ +
+ {forms.map((form) => { + const hasSteps = form.steps && form.steps.length > 0 + const url = hasSteps + ? `/apply/${form.publicSlug}/wizard` + : `/apply/${form.publicSlug}` + + return ( + + +
+
+ + + + {form.name} + + {form.description && ( + + {form.description} + + )} + + + {(form.opensAt || form.closesAt) && ( +
+ + {form.closesAt && ( + + Closes: {new Date(form.closesAt).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + })} + + )} +
+ )} +
+ +
+ +
+
+ +
+ ) + })} +
+ +
+

+ Having trouble? Contact us at{' '} + + support@monaco-opc.com + +

+
+
+
+ ) +} diff --git a/src/components/layouts/admin-sidebar.tsx b/src/components/layouts/admin-sidebar.tsx index 59d671a..d6ba2f7 100644 --- a/src/components/layouts/admin-sidebar.tsx +++ b/src/components/layouts/admin-sidebar.tsx @@ -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 */}
- +
+ + +
{/* Mobile menu overlay */} @@ -241,6 +245,10 @@ export function AdminSidebar({ user }: AdminSidebarProps) { {/* User Profile Section */}
+ {/* Notification Bell - Desktop */} +
+ +
+
+ ) + } + + return ( +
+

+ Toggle which notifications should also send email notifications to users. + Users can still disable email notifications in their personal preferences. +

+ + {Object.entries(CATEGORIES).map(([categoryKey, { label, icon: Icon }]) => { + const categorySettings = groupedSettings[categoryKey] + if (!categorySettings || categorySettings.length === 0) return null + + return ( + + + + + {label} + + {categorySettings.filter(s => s.sendEmail).length}/{categorySettings.length} enabled + + + + + {categorySettings.map((setting) => ( +
+
+ + {setting.description && ( +

+ {setting.description} +

+ )} +
+ + handleToggle(setting.notificationType, checked) + } + disabled={updateMutation.isPending} + /> +
+ ))} +
+
+ ) + })} +
+ ) +} diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index 8997ab1..5dec6db 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -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 ( - + AI @@ -121,6 +123,10 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) { Email + + + Notifications + Storage @@ -178,6 +184,20 @@ export function SettingsContent({ initialSettings }: SettingsContentProps) { + + + + Notification Email Settings + + Configure which notification types should also send email notifications + + + + + + + + diff --git a/src/components/shared/notification-bell.tsx b/src/components/shared/notification-bell.tsx new file mode 100644 index 0000000..88b2eb5 --- /dev/null +++ b/src/components/shared/notification-bell.tsx @@ -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> = { + 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 = ( +
+ {/* Icon with colored background */} +
+ +
+ + {/* Content */} +
+

+ {notification.title} +

+

+ {notification.message} +

+
+ + {formatRelativeTime(notification.createdAt)} + + {notification.linkLabel && ( + + {notification.linkLabel} → + + )} +
+
+ + {/* Unread dot */} + {!notification.isRead && ( +
+ )} +
+ ) + + if (notification.linkUrl) { + return ( + + {content} + + ) + } + + 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 ( + + + + + + {/* Header */} +
+

Notifications

+
+ {unreadCount > 0 && ( + + )} + +
+
+ + {/* Filter tabs */} +
+ + +
+ + {/* Notification list */} + +
+ {notifications.map((notification) => ( + { + if (!notification.isRead) { + markAsReadMutation.mutate({ id: notification.id }) + } + }} + /> + ))} + {notifications.length === 0 && ( +
+ +

+ {filter === 'unread' + ? 'No unread notifications' + : 'No notifications yet'} +

+
+ )} +
+
+ + {/* Footer */} + {notifications.length > 0 && ( +
+ +
+ )} +
+
+ ) +} diff --git a/src/lib/email.ts b/src/lib/email.ts index e88a743..2051a12 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -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, '&') + .replace(//g, '>') + .replace(/\n/g, '
') + + const content = ` + ${sectionTitle(greeting)} +
+ ${formattedBody} +
+ ${linkUrl ? ctaButton(linkUrl, 'View Details') : ''} +

+ You received this email because of your notification preferences on the MOPC Platform. +

+ ` + + 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 { + 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, + }) +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 786494a..bbafd0a 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -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) +} diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts index f6d81ea..f156e50 100644 --- a/src/server/routers/_app.ts +++ b/src/server/routers/_app.ts @@ -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 diff --git a/src/server/routers/filtering.ts b/src/server/routers/filtering.ts index 5a15226..9166876 100644 --- a/src/server/routers/filtering.ts +++ b/src/server/routers/filtering.ts @@ -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' }, + }) } } diff --git a/src/server/routers/notification.ts b/src/server/routers/notification.ts new file mode 100644 index 0000000..98289bb --- /dev/null +++ b/src/server/routers/notification.ts @@ -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 })), + } + }), +}) diff --git a/src/server/services/in-app-notification.ts b/src/server/services/in-app-notification.ts new file mode 100644 index 0000000..6d7cbf8 --- /dev/null +++ b/src/server/services/in-app-notification.ts @@ -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 = { + [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 = { + [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 + groupKey?: string + expiresAt?: Date +} + +/** + * Create a single in-app notification + */ +export async function createNotification( + params: CreateNotificationParams +): Promise { + 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 || {} + 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 +}): Promise { + 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 +}): Promise { + 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 +): Promise { + 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 +): Promise { + 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 +): Promise { + 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 { + 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 { + 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 { + 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 { + return prisma.inAppNotification.count({ + where: { userId, isRead: false }, + }) +} + +/** + * Delete expired notifications + */ +export async function deleteExpiredNotifications(): Promise { + 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 { + const cutoffDate = new Date() + cutoffDate.setDate(cutoffDate.getDate() - olderThanDays) + + const result = await prisma.inAppNotification.deleteMany({ + where: { + isRead: true, + createdAt: { lt: cutoffDate }, + }, + }) + return result.count +}