Files
MOPC-Portal/src/server/services/in-app-notification.ts
Matt c62a335424
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m49s
Fix email links using relative paths — prepend baseUrl for absolute URLs
Relative linkUrl paths (e.g. /jury/competitions) were passed as-is to
email templates, causing email clients to interpret them as local file
protocols (x-webdoc:// on macOS). Now prepends NEXTAUTH_URL to any
relative path before sending.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:45:20 +01:00

509 lines
14 KiB
TypeScript

/**
* 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 } 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',
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',
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<string, string> = {
[NotificationTypes.FILTERING_COMPLETE]: 'Brain',
[NotificationTypes.FILTERING_FAILED]: 'AlertTriangle',
[NotificationTypes.NEW_APPLICATION]: 'FileText',
[NotificationTypes.BULK_APPLICATIONS]: 'Files',
[NotificationTypes.DOCUMENTS_UPLOADED]: 'Upload',
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'ClipboardList',
[NotificationTypes.ROUND_NOW_OPEN]: 'PlayCircle',
[NotificationTypes.REMINDER_24H]: 'Clock',
[NotificationTypes.REMINDER_1H]: 'AlertCircle',
[NotificationTypes.ROUND_CLOSED]: 'Lock',
[NotificationTypes.MENTEE_ASSIGNED]: 'Users',
[NotificationTypes.MENTEE_ADVANCED]: 'TrendingUp',
[NotificationTypes.MENTEE_WON]: 'Trophy',
[NotificationTypes.APPLICATION_SUBMITTED]: 'CheckCircle',
[NotificationTypes.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',
}
// Priority by notification type
export const NotificationPriorities: Record<string, NotificationPriority> = {
[NotificationTypes.FILTERING_COMPLETE]: 'high',
[NotificationTypes.FILTERING_FAILED]: 'urgent',
[NotificationTypes.DEADLINE_1H]: 'urgent',
[NotificationTypes.REMINDER_1H]: 'urgent',
[NotificationTypes.SYSTEM_ERROR]: 'urgent',
[NotificationTypes.ASSIGNED_TO_PROJECT]: 'high',
[NotificationTypes.ROUND_NOW_OPEN]: 'high',
[NotificationTypes.DEADLINE_24H]: 'high',
[NotificationTypes.REMINDER_24H]: 'high',
[NotificationTypes.MENTEE_ASSIGNED]: 'high',
[NotificationTypes.APPLICATION_SUBMITTED]: 'high',
[NotificationTypes.ADVANCED_SEMIFINAL]: 'high',
[NotificationTypes.ADVANCED_FINAL]: 'high',
[NotificationTypes.WINNER_ANNOUNCEMENT]: 'high',
[NotificationTypes.AWARD_VOTING_OPEN]: 'high',
}
interface CreateNotificationParams {
userId: string
type: string
title: string
message: string
linkUrl?: string
linkLabel?: string
icon?: string
priority?: NotificationPriority
metadata?: Record<string, unknown>
groupKey?: string
expiresAt?: Date
}
/**
* Create a single in-app notification
*/
export async function createNotification(
params: CreateNotificationParams
): Promise<void> {
const {
userId,
type,
title,
message,
linkUrl,
linkLabel,
icon,
priority,
metadata,
groupKey,
expiresAt,
} = params
// Determine icon and priority if not provided
const finalIcon = icon || NotificationIcons[type] || 'Bell'
const finalPriority = priority || NotificationPriorities[type] || 'normal'
// Check for existing notification with same groupKey (for batching)
if (groupKey) {
const existingNotification = await prisma.inAppNotification.findFirst({
where: {
userId,
groupKey,
isRead: false,
createdAt: {
gte: new Date(Date.now() - 60 * 60 * 1000), // Within last hour
},
},
})
if (existingNotification) {
// Update existing notification instead of creating new one
const existingMeta = existingNotification.metadata as Record<string, unknown> || {}
const currentCount = (existingMeta.count as number) || 1
await prisma.inAppNotification.update({
where: { id: existingNotification.id },
data: {
message,
metadata: { ...existingMeta, ...metadata, count: currentCount + 1 },
createdAt: new Date(), // Bump to top
},
})
return
}
}
// Create the in-app notification
await prisma.inAppNotification.create({
data: {
userId,
type,
title,
message,
linkUrl,
linkLabel,
icon: finalIcon,
priority: finalPriority,
metadata: metadata as object | undefined,
groupKey,
expiresAt,
},
})
// Check if we should also send an email
await maybeSendEmail(userId, type, title, message, linkUrl, 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<string, unknown>
}): Promise<void> {
const {
userIds,
type,
title,
message,
linkUrl,
linkLabel,
icon,
priority,
metadata,
} = params
const finalIcon = icon || NotificationIcons[type] || 'Bell'
const finalPriority = priority || NotificationPriorities[type] || 'normal'
// Create notifications in bulk
await prisma.inAppNotification.createMany({
data: userIds.map((userId) => ({
userId,
type,
title,
message,
linkUrl,
linkLabel,
icon: finalIcon,
priority: finalPriority,
metadata: metadata as object | undefined,
})),
})
// Check email settings 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<string, unknown>
}): Promise<void> {
const admins = await prisma.user.findMany({
where: {
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
status: 'ACTIVE',
},
select: { id: true },
})
if (admins.length === 0) return
await createBulkNotifications({
...params,
userIds: admins.map((a) => a.id),
})
}
/**
* Notify all jury members for a specific stage
*/
export async function notifyStageJury(
roundId: string,
params: Omit<CreateNotificationParams, 'userId'>
): Promise<void> {
const assignments = await prisma.assignment.findMany({
where: { roundId },
select: { userId: true },
distinct: ['userId'],
})
if (assignments.length === 0) return
await createBulkNotifications({
...params,
userIds: assignments.map((a) => a.userId),
})
}
/**
* Notify team members of a project
*/
export async function notifyProjectTeam(
projectId: string,
params: Omit<CreateNotificationParams, 'userId'>
): Promise<void> {
const teamMembers = await prisma.teamMember.findMany({
where: { projectId },
include: { user: { select: { id: true } } },
})
const userIds = teamMembers
.filter((tm) => tm.user)
.map((tm) => tm.user!.id)
if (userIds.length === 0) return
await createBulkNotifications({
...params,
userIds,
})
}
/**
* Notify assigned mentors of a project
*/
export async function notifyProjectMentors(
projectId: string,
params: Omit<CreateNotificationParams, 'userId'>
): Promise<void> {
const mentorAssignments = await prisma.mentorAssignment.findMany({
where: { projectId },
select: { mentorId: true },
})
if (mentorAssignments.length === 0) return
await createBulkNotifications({
...params,
userIds: mentorAssignments.map((ma) => ma.mentorId),
})
}
/**
* Check email settings and send email if enabled
*/
async function maybeSendEmail(
userId: string,
type: string,
title: string,
message: string,
linkUrl?: string,
metadata?: Record<string, unknown>
): Promise<void> {
try {
// Check if email is enabled for this notification type
const emailSetting = await prisma.notificationEmailSetting.findUnique({
where: { notificationType: type },
})
// If no setting exists, don't send email by default
if (!emailSetting || !emailSetting.sendEmail) {
return
}
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<string, unknown>
): Promise<void> {
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 baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
const absoluteLinkUrl = linkUrl && linkUrl.startsWith('/') ? `${baseUrl}${linkUrl}` : 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<void> {
await prisma.inAppNotification.updateMany({
where: { id: notificationId, userId },
data: { isRead: true, readAt: new Date() },
})
}
/**
* Mark all notifications as read for a user
*/
export async function markAllNotificationsAsRead(userId: string): Promise<void> {
await prisma.inAppNotification.updateMany({
where: { userId, isRead: false },
data: { isRead: true, readAt: new Date() },
})
}
/**
* Get unread notification count for a user
*/
export async function getUnreadCount(userId: string): Promise<number> {
return prisma.inAppNotification.count({
where: { userId, isRead: false },
})
}
/**
* Delete expired notifications
*/
export async function deleteExpiredNotifications(): Promise<number> {
const result = await prisma.inAppNotification.deleteMany({
where: {
expiresAt: { lt: new Date() },
},
})
return result.count
}
/**
* Delete old read notifications (cleanup job)
*/
export async function deleteOldNotifications(olderThanDays: number): Promise<number> {
const cutoffDate = new Date()
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays)
const result = await prisma.inAppNotification.deleteMany({
where: {
isRead: true,
createdAt: { lt: cutoffDate },
},
})
return result.count
}