606 lines
19 KiB
JavaScript
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;
|
|
}
|