606 lines
19 KiB
JavaScript

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