/** * 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 { sendStyledNotificationEmail, ensureAbsoluteUrl } 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', AI_SUGGESTIONS_READY: 'AI_SUGGESTIONS_READY', 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', AI_RANKING_COMPLETE: 'AI_RANKING_COMPLETE', AI_RANKING_FAILED: 'AI_RANKING_FAILED', SYSTEM_ERROR: 'SYSTEM_ERROR', // Jury notifications ASSIGNED_TO_PROJECT: 'ASSIGNED_TO_PROJECT', COI_REASSIGNED: 'COI_REASSIGNED', MANUAL_REASSIGNED: 'MANUAL_REASSIGNED', DROPOUT_REASSIGNED: 'DROPOUT_REASSIGNED', 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', SUBMISSION_RECEIVED: 'SUBMISSION_RECEIVED', 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.COI_REASSIGNED]: 'RefreshCw', [NotificationTypes.MANUAL_REASSIGNED]: 'ArrowRightLeft', [NotificationTypes.DROPOUT_REASSIGNED]: 'UserMinus', [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.SUBMISSION_RECEIVED]: 'Inbox', [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', [NotificationTypes.AI_RANKING_COMPLETE]: 'BarChart3', [NotificationTypes.AI_RANKING_FAILED]: 'AlertTriangle', } // 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.COI_REASSIGNED]: 'high', [NotificationTypes.MANUAL_REASSIGNED]: 'high', [NotificationTypes.DROPOUT_REASSIGNED]: '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', [NotificationTypes.AI_RANKING_COMPLETE]: 'normal', [NotificationTypes.AI_RANKING_FAILED]: '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, metadata) } /** * 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 once, then send emails only if enabled const emailSetting = await prisma.notificationEmailSetting.findUnique({ where: { notificationType: type }, }) if (emailSetting?.sendEmail) { for (const userId of userIds) { await maybeSendEmailWithSetting(userId, type, title, message, emailSetting, linkUrl, metadata) } } } /** * 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 stage */ export async function notifyStageJury( 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, metadata?: Record ): 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 } await maybeSendEmailWithSetting(userId, type, title, message, emailSetting, linkUrl, metadata) } catch (error) { // Log but don't fail the notification creation console.error('[Notification] Failed to send email:', error) } } /** * Send email to a user using a pre-fetched email setting (skips the setting lookup) */ async function maybeSendEmailWithSetting( userId: string, type: string, title: string, message: string, emailSetting: { sendEmail: boolean; emailSubject: string | null }, linkUrl?: string, metadata?: Record ): Promise { try { // 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 !== 'EMAIL' && user.notificationPreference !== 'BOTH')) { return } // Ensure linkUrl is absolute for emails (relative paths break in email clients) const absoluteLinkUrl = ensureAbsoluteUrl(linkUrl) await sendStyledNotificationEmail( user.email, user.name || 'User', type, { title, message, linkUrl: absoluteLinkUrl, metadata, }, emailSetting.emailSubject || undefined ) } catch (error) { 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 }