import prisma from "~/server/utils/prisma"; import { processEmailQueue } from "~/server/utils/emailService"; const ENV = useRuntimeConfig(); // Simple API key auth function requireApiKey(event) { const headers = getRequestHeaders(event); const provided = headers["x-api-key"] || headers["X-API-Key"]; const expected = ENV.notificationApiKey; // Use private runtime config, not public if (!expected) { throw createError({ statusCode: 500, statusMessage: "Notification API is not configured (missing NUXT_NOTIFICATION_API_KEY environment variable)", }); } if (!provided || provided !== expected) { throw createError({ statusCode: 401, statusMessage: "Unauthorized" }); } } // Basic input validation (aligned with internal create API) function validateInput(body) { const errors = []; if (!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("Invalid priority"); if (!body?.category?.trim()) errors.push("Category is required"); if (!Array.isArray(body?.channels) || body.channels.length === 0) errors.push("At least one channel is required"); else { const validChannels = ["email", "push", "sms"]; const invalid = body.channels.filter((c) => !validChannels.includes(c)); if (invalid.length) errors.push(`Invalid channels: ${invalid.join(", ")}`); } if ( !body?.deliveryType || !["immediate", "scheduled"].includes(body.deliveryType) ) errors.push("Invalid deliveryType"); if ( !body?.audienceType || !["all", "specific", "segmented"].includes(body.audienceType) ) errors.push("Invalid audienceType"); if (!body?.contentType || !["new", "template"].includes(body.contentType)) errors.push("Invalid contentType"); if (body.deliveryType === "scheduled" && !body.scheduledAt) errors.push("scheduledAt is required for scheduled delivery"); if (body.channels?.includes("email") && !body.emailSubject) errors.push("emailSubject is required when using email channel"); if (body.contentType === "template" && !body.selectedTemplate) errors.push("selectedTemplate is required for template content"); if (body.contentType === "new") { if (body.channels?.includes("email") && !body.emailContent) errors.push("emailContent is required for email channel"); if (body.channels?.includes("push") && (!body.pushTitle || !body.pushBody)) errors.push("pushTitle and pushBody required for push channel"); } if (body.audienceType === "specific") { if (!Array.isArray(body.specificUsers) || body.specificUsers.length === 0) errors.push( "specificUsers must be a non-empty array for specific audience" ); } if ( body.audienceType === "segmented" && (!Array.isArray(body.userSegments) || body.userSegments.length === 0) ) errors.push("At least one user segment is required"); return errors; } export default defineEventHandler(async (event) => { try { requireApiKey(event); const body = await readBody(event); const validationErrors = validateInput(body); if (validationErrors.length > 0) { throw createError({ statusCode: 400, statusMessage: "Validation failed", data: { errors: validationErrors }, }); } 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, 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, }; const result = await prisma.$transaction(async (tx) => { // Category const category = await tx.notification_categories.findFirst({ where: { value: notificationData.category }, }); if (!category) { throw createError({ statusCode: 400, statusMessage: "Invalid category", }); } // Template (if any) 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", }); } // Estimate audience size (simple heuristic matching internal API) let estimatedReach = 0; if (notificationData.audienceType === "all") estimatedReach = 1; // demo default else if ( notificationData.audienceType === "specific" && notificationData.specificUsers ) { estimatedReach = Array.isArray(notificationData.specificUsers) ? notificationData.specificUsers.length : notificationData.specificUsers.split("\n").filter((l) => l.trim()).length; } else if (notificationData.audienceType === "segmented") { estimatedReach = notificationData.userSegments?.length || 0; } // Create notification 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, audience_type: notificationData.audienceType, specific_users: Array.isArray(notificationData.specificUsers) ? notificationData.specificUsers.join("\n") : notificationData.specificUsers, exclude_unsubscribed: notificationData.excludeUnsubscribed, respect_do_not_disturb: true, 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: "public-api", status: notificationData.deliveryType === "immediate" ? "sending" : "scheduled", }, }); // Channels await tx.notification_channels.createMany({ data: notificationData.channels.map((c) => ({ notification_id: notification.id, channel_type: c, })), }); // Segments if ( notificationData.audienceType === "segmented" && notificationData.userSegments?.length > 0 ) { for (const seg of notificationData.userSegments) { const segRow = await tx.user_segments.findFirst({ where: { value: seg, is_active: true }, }); if (segRow) { await tx.notification_user_segments.create({ data: { notification_id: notification.id, segment_id: segRow.id }, }); } } } // Recipients list (demo-friendly) const recipients = []; if (notificationData.audienceType === "all") { const senderEmail = process.env.SMTP_USER || "test@example.com"; recipients.push({ user_id: "public-user-1", email: senderEmail }); } else if ( notificationData.audienceType === "specific" && notificationData.specificUsers ) { const ids = Array.isArray(notificationData.specificUsers) ? notificationData.specificUsers : notificationData.specificUsers.split("\n").map((l) => l.trim()).filter(Boolean); for (const id of ids) { const isEmail = id.includes("@"); recipients.push({ user_id: isEmail ? id.split("@")[0] : id, email: isEmail ? id : `${id}@example.com`, }); } } else if (notificationData.audienceType === "segmented") { for (let i = 0; i < (notificationData.userSegments?.length || 0); i++) { const segVal = notificationData.userSegments[i]; recipients.push({ user_id: `${segVal}-user-${i + 1}`, email: `${segVal}${i + 1}@example.com`, }); } } // Create recipients + queue using batch inserts for better performance console.log(`📦 Processing ${recipients.length} recipients across ${notificationData.channels.length} channel(s)...`); // Prepare batch data for recipients const recipientsData = []; for (const r of recipients) { for (const ch of notificationData.channels) { recipientsData.push({ notification_id: notification.id, user_id: r.user_id, email: ch === "email" ? r.email : null, channel_type: ch, 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' } }); // Prepare batch data for queue let scheduledFor; if (notificationData.deliveryType === "immediate") scheduledFor = new Date(Date.now() + 60000); else if (notificationData.scheduledAt) scheduledFor = new Date(notificationData.scheduledAt); else scheduledFor = new Date(Date.now() + 300000); const priority = notificationData.priority === "critical" ? 1 : notificationData.priority === "high" ? 2 : notificationData.priority === "medium" ? 3 : 5; 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, }; }); // Process queue in background (non-blocking) if (body.deliveryType === "immediate") { // Don't await - let it run in background processEmailQueue().catch(err => { console.error('Background queue processing error:', err); }); } return { success: true, data: { id: result.id, message: body.deliveryType === "immediate" ? "Notification queued for immediate delivery" : "Notification has been scheduled", estimatedReach: result.estimatedReach, recipientsQueued: result.recipientsCreated || 0, }, }; } catch (error) { console.log("Error:", error); if (error.statusCode) throw error; throw createError({ statusCode: 500, statusMessage: "Failed to create notification", data: { error: error.message }, }); } finally { } });