MOPC-App/src/server/services/notification.ts

322 lines
7.4 KiB
TypeScript

/**
* Unified Notification Service
*
* Handles sending notifications via multiple channels:
* - Email (via nodemailer)
* - WhatsApp (via Meta or Twilio)
*/
import { prisma } from '@/lib/prisma'
import {
sendMagicLinkEmail,
sendJuryInvitationEmail,
sendEvaluationReminderEmail,
sendAnnouncementEmail,
} from '@/lib/email'
import { getWhatsAppProvider, getWhatsAppProviderType } from '@/lib/whatsapp'
import type { NotificationChannel } from '@prisma/client'
export type NotificationType =
| 'MAGIC_LINK'
| 'JURY_INVITATION'
| 'EVALUATION_REMINDER'
| 'ANNOUNCEMENT'
interface NotificationResult {
success: boolean
channels: {
email?: { success: boolean; error?: string }
whatsapp?: { success: boolean; messageId?: string; error?: string }
}
}
/**
* Send a notification to a user based on their preferences
*/
export async function sendNotification(
userId: string,
type: NotificationType,
data: Record<string, string>
): Promise<NotificationResult> {
// Get user with notification preferences
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
email: true,
name: true,
phoneNumber: true,
notificationPreference: true,
whatsappOptIn: true,
},
})
if (!user) {
return {
success: false,
channels: {},
}
}
const result: NotificationResult = {
success: true,
channels: {},
}
const preference = user.notificationPreference
// Determine which channels to use
const sendEmail = preference === 'EMAIL' || preference === 'BOTH'
const sendWhatsApp =
(preference === 'WHATSAPP' || preference === 'BOTH') &&
user.whatsappOptIn &&
user.phoneNumber
// Send via email
if (sendEmail) {
const emailResult = await sendEmailNotification(user.email, user.name, type, data)
result.channels.email = emailResult
// Log the notification
await logNotification(user.id, 'EMAIL', 'SMTP', type, emailResult)
}
// Send via WhatsApp
if (sendWhatsApp && user.phoneNumber) {
const whatsappResult = await sendWhatsAppNotification(
user.phoneNumber,
user.name,
type,
data
)
result.channels.whatsapp = whatsappResult
// Log the notification
const providerType = await getWhatsAppProviderType()
await logNotification(
user.id,
'WHATSAPP',
providerType || 'UNKNOWN',
type,
whatsappResult
)
}
// Overall success if at least one channel succeeded
result.success =
(result.channels.email?.success ?? true) ||
(result.channels.whatsapp?.success ?? true)
return result
}
/**
* Send email notification
*/
async function sendEmailNotification(
email: string,
name: string | null,
type: NotificationType,
data: Record<string, string>
): Promise<{ success: boolean; error?: string }> {
try {
switch (type) {
case 'MAGIC_LINK':
await sendMagicLinkEmail(email, data.url)
return { success: true }
case 'JURY_INVITATION':
await sendJuryInvitationEmail(
email,
data.inviteUrl,
data.programName,
data.roundName
)
return { success: true }
case 'EVALUATION_REMINDER':
await sendEvaluationReminderEmail(
email,
name,
parseInt(data.pendingCount || '0'),
data.roundName || 'Current Round',
data.deadline || 'Soon',
data.assignmentsUrl || `${process.env.NEXTAUTH_URL}/jury/assignments`
)
return { success: true }
case 'ANNOUNCEMENT':
await sendAnnouncementEmail(
email,
name,
data.title || 'Announcement',
data.message || '',
data.ctaText,
data.ctaUrl
)
return { success: true }
default:
return { success: false, error: `Unknown notification type: ${type}` }
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Email send failed',
}
}
}
/**
* Send WhatsApp notification
*/
async function sendWhatsAppNotification(
phoneNumber: string,
name: string | null,
type: NotificationType,
data: Record<string, string>
): Promise<{ success: boolean; messageId?: string; error?: string }> {
const provider = await getWhatsAppProvider()
if (!provider) {
return { success: false, error: 'WhatsApp not configured' }
}
try {
// Map notification types to templates
const templateMap: Record<NotificationType, string> = {
MAGIC_LINK: 'mopc_magic_link',
JURY_INVITATION: 'mopc_jury_invitation',
EVALUATION_REMINDER: 'mopc_evaluation_reminder',
ANNOUNCEMENT: 'mopc_announcement',
}
const template = templateMap[type]
// Build template params
const params: Record<string, string> = {
name: name || 'User',
...data,
}
const result = await provider.sendTemplate(phoneNumber, template, params)
return result
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'WhatsApp send failed',
}
}
}
/**
* Log notification to database
*/
async function logNotification(
userId: string,
channel: NotificationChannel,
provider: string,
type: NotificationType,
result: { success: boolean; messageId?: string; error?: string }
): Promise<void> {
try {
await prisma.notificationLog.create({
data: {
userId,
channel,
provider,
type,
status: result.success ? 'SENT' : 'FAILED',
externalId: result.messageId,
errorMsg: result.error,
},
})
} catch (error) {
console.error('Failed to log notification:', error)
}
}
/**
* Send bulk notifications to multiple users
*/
export async function sendBulkNotification(
userIds: string[],
type: NotificationType,
data: Record<string, string>
): Promise<{ sent: number; failed: number }> {
let sent = 0
let failed = 0
for (const userId of userIds) {
const result = await sendNotification(userId, type, data)
if (result.success) {
sent++
} else {
failed++
}
}
return { sent, failed }
}
/**
* Get notification statistics
*/
export async function getNotificationStats(options?: {
userId?: string
startDate?: Date
endDate?: Date
}): Promise<{
total: number
byChannel: Record<string, number>
byStatus: Record<string, number>
byType: Record<string, number>
}> {
const where: Record<string, unknown> = {}
if (options?.userId) {
where.userId = options.userId
}
if (options?.startDate || options?.endDate) {
where.createdAt = {}
if (options.startDate) {
(where.createdAt as Record<string, Date>).gte = options.startDate
}
if (options.endDate) {
(where.createdAt as Record<string, Date>).lte = options.endDate
}
}
const [total, byChannel, byStatus, byType] = await Promise.all([
prisma.notificationLog.count({ where }),
prisma.notificationLog.groupBy({
by: ['channel'],
where,
_count: true,
}),
prisma.notificationLog.groupBy({
by: ['status'],
where,
_count: true,
}),
prisma.notificationLog.groupBy({
by: ['type'],
where,
_count: true,
}),
])
return {
total,
byChannel: Object.fromEntries(
byChannel.map((r) => [r.channel, r._count])
),
byStatus: Object.fromEntries(
byStatus.map((r) => [r.status, r._count])
),
byType: Object.fromEntries(
byType.map((r) => [r.type, r._count])
),
}
}