322 lines
7.4 KiB
TypeScript
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])
|
|
),
|
|
}
|
|
}
|