2026-02-14 15:26:42 +01:00
|
|
|
/**
|
|
|
|
|
* 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'
|
2026-02-23 14:27:58 +01:00
|
|
|
import { sendStyledNotificationEmail, ensureAbsoluteUrl } from '@/lib/email'
|
2026-02-14 15:26:42 +01:00
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
})),
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-19 12:59:35 +01:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-02-14 15:26:42 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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(
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
roundId: string,
|
2026-02-14 15:26:42 +01:00
|
|
|
params: Omit<CreateNotificationParams, 'userId'>
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const assignments = await prisma.assignment.findMany({
|
Competition/Round architecture: full platform rewrite (Phases 1-9)
Replace Pipeline/Stage system with Competition/Round architecture.
New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy,
ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow.
New services: round-engine, round-assignment, deliberation, result-lock,
submission-manager, competition-context, ai-prompt-guard.
Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with
structured prompts, retry logic, and injection detection. All legacy
pipeline/stage code removed. 4 new migrations + seed aligned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:04:15 +01:00
|
|
|
where: { roundId },
|
2026-02-14 15:26:42 +01:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 12:59:35 +01:00
|
|
|
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 {
|
2026-02-14 15:26:42 +01:00
|
|
|
// Check user's notification preference
|
|
|
|
|
const user = await prisma.user.findUnique({
|
|
|
|
|
where: { id: userId },
|
|
|
|
|
select: { email: true, name: true, notificationPreference: true },
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-19 12:59:35 +01:00
|
|
|
if (!user || (user.notificationPreference !== 'EMAIL' && user.notificationPreference !== 'BOTH')) {
|
2026-02-14 15:26:42 +01:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 13:45:20 +01:00
|
|
|
// Ensure linkUrl is absolute for emails (relative paths break in email clients)
|
2026-02-23 14:27:58 +01:00
|
|
|
const absoluteLinkUrl = ensureAbsoluteUrl(linkUrl)
|
2026-02-19 13:45:20 +01:00
|
|
|
|
2026-02-14 15:26:42 +01:00
|
|
|
await sendStyledNotificationEmail(
|
|
|
|
|
user.email,
|
|
|
|
|
user.name || 'User',
|
|
|
|
|
type,
|
|
|
|
|
{
|
|
|
|
|
title,
|
|
|
|
|
message,
|
2026-02-19 13:45:20 +01:00
|
|
|
linkUrl: absoluteLinkUrl,
|
2026-02-14 15:26:42 +01:00
|
|
|
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
|
|
|
|
|
}
|