Initial commit: MOPC platform with Docker deployment setup
Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth. Includes production Dockerfile (multi-stage, port 7600), docker-compose with registry-based image pull, Gitea Actions CI workflow, nginx config for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
321
src/server/services/notification.ts
Normal file
321
src/server/services/notification.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* 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])
|
||||
),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user