349 lines
12 KiB
JavaScript
349 lines
12 KiB
JavaScript
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 {
|
|
}
|
|
});
|