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 { } });