Files

412 lines
14 KiB
JavaScript

import prisma from "~/server/utils/prisma";
import { processEmailQueue } from "~/server/utils/emailService";
// Basic input validation function
function validateBasicInput(body) {
const errors = [];
// Required fields validation
if (!body.title || body.title.trim() === '') {
errors.push('Title is required');
}
if (!body.type || !['single', 'bulk'].includes(body.type)) {
errors.push('Type must be either "single" or "bulk"');
}
if (!body.priority || !['low', 'medium', 'high', 'critical'].includes(body.priority)) {
errors.push('Priority must be one of: low, medium, high, critical');
}
if (!body.category || body.category.trim() === '') {
errors.push('Category is required');
}
if (!body.channels || !Array.isArray(body.channels) || body.channels.length === 0) {
errors.push('At least one channel is required');
} else {
const validChannels = ['email', 'push', 'sms'];
const invalidChannels = body.channels.filter(channel => !validChannels.includes(channel));
if (invalidChannels.length > 0) {
errors.push(`Invalid channels: ${invalidChannels.join(', ')}`);
}
}
if (!body.deliveryType || !['immediate', 'scheduled'].includes(body.deliveryType)) {
errors.push('Delivery type must be either "immediate" or "scheduled"');
}
if (!body.audienceType || !['all', 'specific', 'segmented'].includes(body.audienceType)) {
errors.push('Audience type must be one of: all, specific, segmented');
}
if (!body.contentType || !['new', 'template'].includes(body.contentType)) {
errors.push('Content type must be either "new" or "template"');
}
// Conditional validations
if (body.deliveryType === 'scheduled' && !body.scheduledAt) {
errors.push('Scheduled date is required for scheduled notifications');
}
if (body.channels && body.channels.includes('email') && !body.emailSubject) {
errors.push('Email subject is required when email channel is selected');
}
// Content validations
if (body.contentType === 'template' && !body.selectedTemplate) {
errors.push('Template selection is required when using template content');
}
if (body.contentType === 'new') {
if (body.channels && body.channels.includes('email') && !body.emailContent) {
errors.push('Email content is required when using email channel with new content');
}
if (body.channels && body.channels.includes('push') && (!body.pushTitle || !body.pushBody)) {
errors.push('Push title and body are required when using push channel with new content');
}
}
// Audience validations
if (body.audienceType === 'specific' && (!body.specificUsers || body.specificUsers.trim() === '')) {
errors.push('Specific users are required when audience type is specific');
}
if (body.audienceType === 'segmented' && (!body.userSegments || body.userSegments.length === 0)) {
errors.push('At least one user segment is required when audience type is segmented');
}
return errors;
}
// Simple audience count estimation
async function estimateAudienceCount(audienceData, tx) {
try {
if (audienceData.audienceType === 'all') {
// For testing, return 1 (since we're using test recipients)
return 1;
} else if (audienceData.audienceType === 'specific') {
// Count lines in specificUsers
const userLines = audienceData.specificUsers
.split('\n')
.filter(line => line.trim() !== '');
return userLines.length;
} else if (audienceData.audienceType === 'segmented') {
// For segmented audience, return an estimate
// In a real implementation, this would query based on segments
return audienceData.userSegments?.length || 0;
}
return 0;
} catch (error) {
console.error("Error estimating audience count:", error);
return 0;
}
}
export default defineEventHandler(async (event) => {
try {
// Read and validate request body
const body = await readBody(event);
console.log("Request body:", body);
// Basic input validation
const validationErrors = validateBasicInput(body);
if (validationErrors.length > 0) {
throw createError({
statusCode: 400,
statusMessage: "Validation failed",
data: {
errors: validationErrors
}
});
}
// Get current user (assuming auth middleware provides this)
const user = event.context.user;
if (!user || !user.userID) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
// Set default values for optional fields
const notificationData = {
title: body.title,
type: body.type,
priority: body.priority,
category: body.category,
channels: body.channels,
emailSubject: body.emailSubject || null,
deliveryType: body.deliveryType,
scheduledAt: body.scheduledAt || null,
timezone: body.timezone || 'UTC',
audienceType: body.audienceType,
specificUsers: body.specificUsers || null,
userSegments: body.userSegments || [],
excludeUnsubscribed: body.excludeUnsubscribed !== false, // default true
contentType: body.contentType,
selectedTemplate: body.selectedTemplate || null,
emailContent: body.emailContent || null,
callToActionText: body.callToActionText || null,
callToActionUrl: body.callToActionUrl || null,
pushTitle: body.pushTitle || null,
pushBody: body.pushBody || null,
};
// Use Prisma transaction for consistency
const result = await prisma.$transaction(async (tx) => {
// 1. Get category
const category = await tx.notification_categories.findFirst({
where: { value: notificationData.category },
});
if (!category) {
throw createError({
statusCode: 400,
statusMessage: "Invalid category",
});
}
// 2. Get template data if using template
let templateData = null;
if (notificationData.contentType === "template") {
templateData = await tx.notification_templates.findFirst({
where: {
value: notificationData.selectedTemplate,
is_active: true,
},
});
if (!templateData) {
throw createError({
statusCode: 400,
statusMessage: "Invalid or inactive template",
});
}
}
// 3. Calculate estimated reach
const estimatedReach = await estimateAudienceCount(notificationData, tx);
// 4. Create notification record
const notification = await tx.notifications.create({
data: {
title: notificationData.title,
type: notificationData.type,
priority: notificationData.priority,
category_id: category.id,
delivery_type: notificationData.deliveryType,
scheduled_at: notificationData.scheduledAt ? new Date(notificationData.scheduledAt) : null,
timezone: notificationData.timezone,
enable_tracking: true, // Simplified: always enable tracking
audience_type: notificationData.audienceType,
specific_users: notificationData.specificUsers,
exclude_unsubscribed: notificationData.excludeUnsubscribed,
respect_do_not_disturb: true, // Simplified: always respect DND
content_type: notificationData.contentType,
template_id: templateData?.id || null,
email_subject: notificationData.emailSubject || templateData?.subject || null,
email_content: notificationData.emailContent || templateData?.email_content || null,
call_to_action_text: notificationData.callToActionText || null,
call_to_action_url: notificationData.callToActionUrl || null,
push_title: notificationData.pushTitle || templateData?.push_title || null,
push_body: notificationData.pushBody || templateData?.push_body || null,
estimated_reach: estimatedReach,
created_by: user.userID.toString(),
status: notificationData.deliveryType === "immediate" ? "sending" : "scheduled",
},
});
// 5. Insert notification channels
await tx.notification_channels.createMany({
data: notificationData.channels.map((channel) => ({
notification_id: notification.id,
channel_type: channel,
})),
});
// 6. Insert user segments if segmented audience
if (notificationData.audienceType === "segmented" && notificationData.userSegments?.length > 0) {
for (const segment of notificationData.userSegments) {
const segmentData = await tx.user_segments.findFirst({
where: {
value: segment,
is_active: true,
},
});
if (segmentData) {
await tx.notification_user_segments.create({
data: {
notification_id: notification.id,
segment_id: segmentData.id,
},
});
}
}
}
// 7. Add recipients to notification_recipients table and queue them
// First, determine who will receive the notification
const recipientsList = [];
// Get users based on audience type
if (notificationData.audienceType === 'all') {
// For testing, use the configured sender email
const senderEmail = process.env.SMTP_USER || 'test@example.com';
recipientsList.push({
user_id: "test-user-1",
email: senderEmail, // Use your email for testing
});
}
else if (notificationData.audienceType === 'specific' && notificationData.specificUsers) {
// Parse specific users from the text field (email addresses or IDs)
const userIds = notificationData.specificUsers
.split('\n')
.filter(line => line.trim() !== '')
.map(line => line.trim());
// For each specified user, add to recipients
for (const userId of userIds) {
const isEmail = userId.includes('@');
recipientsList.push({
user_id: isEmail ? userId.split('@')[0] : userId, // Extract username or use ID
email: isEmail ? userId : `${userId}@example.com`, // Use provided email or generate fake one
});
}
}
else if (notificationData.audienceType === 'segmented' && notificationData.userSegments?.length > 0) {
// For demo purposes, just add placeholder users for each segment
for (let i = 0; i < notificationData.userSegments.length; i++) {
const segmentValue = notificationData.userSegments[i];
recipientsList.push({
user_id: `${segmentValue}-user-${i+1}`,
email: `${segmentValue}${i+1}@example.com`,
});
}
}
// Create recipients and queue entries using batch inserts for better performance
console.log(`📦 Processing ${recipientsList.length} recipients across ${notificationData.channels.length} channel(s)...`);
// Prepare batch data for recipients
const recipientsData = [];
for (const recipient of recipientsList) {
for (const channel of notificationData.channels) {
recipientsData.push({
notification_id: notification.id,
user_id: recipient.user_id,
email: channel === 'email' ? recipient.email : null,
channel_type: channel,
status: 'pending',
});
}
}
// Batch insert all recipients at once
await tx.notification_recipients.createMany({
data: recipientsData,
});
// Fetch created recipients to get their IDs for queue
const createdRecipients = await tx.notification_recipients.findMany({
where: { notification_id: notification.id },
orderBy: { id: 'asc' }
});
// Determine when this notification should be scheduled
let scheduledFor;
if (notificationData.deliveryType === 'immediate') {
scheduledFor = new Date(Date.now() + 60000); // 1 minute from now
} else if (notificationData.deliveryType === 'scheduled' && notificationData.scheduledAt) {
scheduledFor = new Date(notificationData.scheduledAt);
} else {
scheduledFor = new Date(Date.now() + 300000); // Fallback - 5 minutes from now
}
const priority = notificationData.priority === 'critical' ? 1
: notificationData.priority === 'high' ? 2
: notificationData.priority === 'medium' ? 3
: 5;
// Prepare batch data for queue
const queueData = createdRecipients.map(rec => ({
notification_id: notification.id,
recipient_id: rec.id,
scheduled_for: scheduledFor,
priority,
status: 'queued',
}));
// Batch insert all queue items at once
await tx.notification_queue.createMany({
data: queueData,
});
console.log(`✅ Created ${createdRecipients.length} recipients and queue items in batch`);
return {
id: notification.id,
estimatedReach,
recipientsCreated: createdRecipients.length,
};
});
// If this is an immediate notification, trigger background queue processing (non-blocking)
if (notificationData.deliveryType === "immediate") {
// Don't await - let it run in background
processEmailQueue().catch(err => {
console.error('Background queue processing error:', err);
});
}
// Return success response
return {
success: true,
data: {
id: result.id,
message:
notificationData.deliveryType === "immediate"
? "Notification queued for immediate delivery"
: "Notification has been scheduled",
estimatedReach: result.estimatedReach,
recipientsQueued: result.recipientsCreated || 0,
queueStatus: "Recipients have been added to the notification queue",
},
};
} catch (error) {
console.error("Notification creation error:", error);
// Handle Prisma errors
if (error.code && error.code.startsWith('P')) {
throw createError({
statusCode: 400,
statusMessage: "Database operation failed",
data: {
error: error.message,
code: error.code
}
});
}
// Handle known errors with status codes
if (error.statusCode) {
throw error;
}
// Generic server error
throw createError({
statusCode: 500,
statusMessage: "Failed to create notification",
data: {
error: error.message
}
});
} finally {
}
});