412 lines
14 KiB
JavaScript
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 {
|
|
}
|
|
});
|
|
|