import prisma from "~/server/utils/prisma"; // 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', 'in_app']; 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'); } 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 email channel is selected'); } if (body.channels && body.channels.includes('push') && (!body.pushTitle || !body.pushBody)) { errors.push('Push title and body are required when push channel is selected'); } } return errors; } export default defineEventHandler(async (event) => { console.log("Request body:", event.body); 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 } }); } console.log("Basic validation passed"); // Get current user (assuming auth middleware provides this) const user = event.context.user; if (!user || !user.userID) { throw createError({ statusCode: 401, statusMessage: "Authentication required", }); } console.log("User authenticated:", user.userID); // 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, expiresAt: body.expiresAt || null, deliveryType: body.deliveryType, scheduledAt: body.scheduledAt || null, timezone: body.timezone || 'UTC', enableAbTesting: body.enableAbTesting || false, abTestSplit: body.abTestSplit || 50, abTestName: body.abTestName || null, enableTracking: body.enableTracking !== false, // default true audienceType: body.audienceType, specificUsers: body.specificUsers || null, userSegments: body.userSegments || [], userStatus: body.userStatus || null, registrationPeriod: body.registrationPeriod || null, excludeUnsubscribed: body.excludeUnsubscribed !== false, // default true respectDoNotDisturb: body.respectDoNotDisturb !== 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, pushImageUrl: body.pushImageUrl || null, }; // Use Prisma transaction for better error handling console.log("Starting database transaction..."); const result = await prisma.$transaction(async (tx) => { console.log("Transaction started, looking for category:", notificationData.category); // 1. Get category const category = await tx.notification_categories.findFirst({ where: { value: notificationData.category }, }); if (!category) { console.error("Category not found:", notificationData.category); throw createError({ statusCode: 400, statusMessage: "Invalid category", }); } console.log("Category found:", category.id); // 2. Get template data if using template let templateData = null; if (notificationData.contentType === "template") { console.log("Looking for template:", notificationData.selectedTemplate); templateData = await tx.notification_templates.findFirst({ where: { value: notificationData.selectedTemplate, is_active: true, }, }); if (!templateData) { console.error("Template not found:", notificationData.selectedTemplate); throw createError({ statusCode: 400, statusMessage: "Invalid or inactive template", }); } console.log("Template found:", templateData.id); } // 3. Calculate estimated reach console.log("Calculating estimated reach..."); const estimatedReach = await calculateEstimatedReach(notificationData, tx); console.log("Estimated reach:", estimatedReach); // 4. Create notification record console.log("Creating 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, expires_at: notificationData.expiresAt ? new Date(notificationData.expiresAt) : null, enable_ab_testing: notificationData.enableAbTesting, ab_test_split: notificationData.abTestSplit, ab_test_name: notificationData.abTestName, enable_tracking: notificationData.enableTracking, audience_type: notificationData.audienceType, specific_users: notificationData.specificUsers, user_status: notificationData.userStatus, registration_period: notificationData.registrationPeriod, exclude_unsubscribed: notificationData.excludeUnsubscribed, respect_do_not_disturb: notificationData.respectDoNotDisturb, 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, push_image_url: notificationData.pushImageUrl || null, estimated_reach: estimatedReach, created_by: user.userID.toString(), status: notificationData.deliveryType === "immediate" ? "sending" : "scheduled", }, }); console.log("Notification created successfully:", notification.id); // 5. Insert notification channels console.log("Inserting notification channels:", notificationData.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) { console.log("Processing user segments:", notificationData.userSegments); for (const segment of notificationData.userSegments) { const segmentData = await tx.user_segments.findFirst({ where: { value: segment, is_active: true, }, }); if (segmentData) { console.log("Adding segment:", segmentData.id); await tx.notification_user_segments.create({ data: { notification_id: notification.id, segment_id: segmentData.id, }, }); } else { console.warn("Segment not found:", segment); } } } // 7. Generate recipients and queue for sending console.log("Processing recipients for delivery type:", notificationData.deliveryType); if (notificationData.deliveryType === "immediate") { await processImmediateNotification(notification.id, notificationData, tx); } else { await scheduleNotification(notification.id, notificationData, tx); } console.log("Transaction completed successfully"); return { id: notification.id, estimatedReach, }; }); // Return success response return { success: true, data: { id: result.id, message: notificationData.deliveryType === "immediate" ? "Notification is being sent" : "Notification has been scheduled", estimatedReach: result.estimatedReach, }, }; } catch (error) { console.error("Notification creation error:", error); console.error("Error details:", { message: error.message, stack: error.stack, cause: error.cause, code: error.code, statusCode: error.statusCode }); // Handle Prisma errors if (error.code && error.code.startsWith('P')) { console.error("Prisma error code:", error.code); 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 { await prisma.$disconnect(); } }); // Helper function to calculate estimated reach async function calculateEstimatedReach(notificationData, tx = null) { const db = tx || prisma; try { if (notificationData.audienceType === "specific") { // Count specific users const usersList = notificationData.specificUsers ?.split("\n") .map((u) => u.trim()) .filter((u) => u) || []; return usersList.length; } let whereClause = {}; if (notificationData.audienceType === "segmented") { // Apply segment filters if (notificationData.userStatus) { whereClause.userStatus = notificationData.userStatus; } if (notificationData.registrationPeriod) { const days = getRegistrationPeriodDays( notificationData.registrationPeriod ); const fromDate = new Date(); fromDate.setDate(fromDate.getDate() - days); whereClause.userCreatedDate = { gte: fromDate, }; } } // Note: For MySQL and user_notification_preferences filtering, // this would need to be implemented with raw queries or more complex Prisma logic // For now, we'll get a basic count and adjust the query as needed const count = await db.user.count({ where: { userStatus: "active", ...whereClause, }, }); return count; } catch (error) { console.error("Error calculating estimated reach:", error); return 0; } } // Helper function to process immediate notifications async function processImmediateNotification( notificationId, notificationData, tx ) { // Get target users based on audience type const recipients = await getTargetUsers(notificationData, tx); // Create recipient records and queue entries for (const recipient of recipients) { for (const channel of notificationData.channels) { // Check user preferences const canSend = await checkUserPreferences( recipient.userID, channel, notificationData, tx ); if (canSend) { // Determine A/B test variant const abVariant = notificationData.enableAbTesting ? Math.random() * 100 < notificationData.abTestSplit ? "A" : "B" : null; // Insert recipient record const notificationRecipient = await tx.notification_recipients.create({ data: { notification_id: notificationId, user_id: recipient.userID.toString(), email: recipient.userEmail, channel_type: channel, ab_test_variant: abVariant, }, }); // Queue for immediate sending await tx.notification_queue.create({ data: { notification_id: notificationId, recipient_id: notificationRecipient.id, scheduled_for: new Date(), priority: getPriorityValue(notificationData.priority), }, }); } } } } // Helper function to schedule notifications async function scheduleNotification(notificationId, notificationData, tx) { // Similar to processImmediateNotification but with scheduled_for = scheduledAt const recipients = await getTargetUsers(notificationData, tx); for (const recipient of recipients) { for (const channel of notificationData.channels) { const canSend = await checkUserPreferences( recipient.userID, channel, notificationData, tx ); if (canSend) { const abVariant = notificationData.enableAbTesting ? Math.random() * 100 < notificationData.abTestSplit ? "A" : "B" : null; const notificationRecipient = await tx.notification_recipients.create({ data: { notification_id: notificationId, user_id: recipient.userID.toString(), email: recipient.userEmail, channel_type: channel, ab_test_variant: abVariant, }, }); // Calculate scheduled time considering user's DND preferences const scheduledFor = await calculateScheduledTime( notificationData.scheduledAt, recipient, notificationData ); await tx.notification_queue.create({ data: { notification_id: notificationId, recipient_id: notificationRecipient.id, scheduled_for: new Date(scheduledFor), priority: getPriorityValue(notificationData.priority), }, }); } } } } // Helper functions function getRegistrationPeriodDays(period) { const periodMap = { last_7_days: 7, last_30_days: 30, last_90_days: 90, last_year: 365, }; return periodMap[period] || 365; } function getPriorityValue(priority) { const priorityMap = { low: 1, medium: 5, high: 8, critical: 10, }; return priorityMap[priority] || 5; } async function getTargetUsers(notificationData, tx) { const db = tx || prisma; if (notificationData.audienceType === "all") { const users = await db.user.findMany({ where: { userStatus: "active" }, select: { userID: true, userEmail: true, userFullName: true, }, }); return users; } if (notificationData.audienceType === "specific") { const usersList = notificationData.specificUsers ?.split("\n") .map((u) => u.trim()) .filter((u) => u) || []; if (usersList.length === 0) return []; const users = await db.user.findMany({ where: { OR: [ { userEmail: { in: usersList } }, { userID: { in: usersList.map((u) => parseInt(u)).filter((u) => !isNaN(u)), }, }, ], }, select: { userID: true, userEmail: true, userFullName: true, }, }); return users; } // Segmented audience - build dynamic query based on segments let whereClause = { userStatus: "active" }; // Add segment-specific conditions here based on your user_segments criteria // This is a simplified version - you'd need to implement the actual segment logic const users = await db.user.findMany({ where: whereClause, select: { userID: true, userEmail: true, userFullName: true, }, }); return users; } async function checkUserPreferences(userId, channel, notificationData, tx) { const db = tx || prisma; try { // Check if user has disabled this channel/category const preference = await db.user_notification_preferences.findFirst({ where: { user_id: userId.toString(), channel_type: channel, OR: [ { category_value: null }, { category_value: notificationData.category }, ], }, orderBy: [ { category_value: "desc" }, // Category-specific preferences override general ones ], }); if (preference && !preference.is_enabled) { return false; } return true; } catch (error) { console.error("Error checking user preferences:", error); return true; // Default to allowing if there's an error } } async function calculateScheduledTime( scheduledAt, recipient, notificationData ) { if (!notificationData.respectDoNotDisturb) { return scheduledAt; } // Get user's DND preferences and adjust scheduling accordingly // This is a simplified version - you'd implement the actual DND logic return scheduledAt; }