Update various configuration files, components, and assets; enhance notification system and API endpoints; improve documentation and styles across the application.
This commit is contained in:
411
server/api/notifications/index.post.js
Normal file
411
server/api/notifications/index.post.js
Normal file
@@ -0,0 +1,411 @@
|
||||
import prisma from "~/server/utils/prisma";
|
||||
import { processEmailQueue } from "~/server/utils/emailService";
|
||||
|
||||
// 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'];
|
||||
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');
|
||||
}
|
||||
|
||||
// Content validations
|
||||
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 using email channel with new content');
|
||||
}
|
||||
|
||||
if (body.channels && body.channels.includes('push') && (!body.pushTitle || !body.pushBody)) {
|
||||
errors.push('Push title and body are required when using push channel with new content');
|
||||
}
|
||||
}
|
||||
|
||||
// Audience validations
|
||||
if (body.audienceType === 'specific' && (!body.specificUsers || body.specificUsers.trim() === '')) {
|
||||
errors.push('Specific users are required when audience type is specific');
|
||||
}
|
||||
|
||||
if (body.audienceType === 'segmented' && (!body.userSegments || body.userSegments.length === 0)) {
|
||||
errors.push('At least one user segment is required when audience type is segmented');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Simple audience count estimation
|
||||
async function estimateAudienceCount(audienceData, tx) {
|
||||
try {
|
||||
if (audienceData.audienceType === 'all') {
|
||||
// For testing, return 1 (since we're using test recipients)
|
||||
return 1;
|
||||
} else if (audienceData.audienceType === 'specific') {
|
||||
// Count lines in specificUsers
|
||||
const userLines = audienceData.specificUsers
|
||||
.split('\n')
|
||||
.filter(line => line.trim() !== '');
|
||||
return userLines.length;
|
||||
} else if (audienceData.audienceType === 'segmented') {
|
||||
// For segmented audience, return an estimate
|
||||
// In a real implementation, this would query based on segments
|
||||
return audienceData.userSegments?.length || 0;
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
console.error("Error estimating audience count:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get current user (assuming auth middleware provides this)
|
||||
const user = event.context.user;
|
||||
if (!user || !user.userID) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Authentication required",
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
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, // 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,
|
||||
};
|
||||
|
||||
// Use Prisma transaction for consistency
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 1. Get category
|
||||
const category = await tx.notification_categories.findFirst({
|
||||
where: { value: notificationData.category },
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid category",
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Get template data if using template
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Calculate estimated reach
|
||||
const estimatedReach = await estimateAudienceCount(notificationData, tx);
|
||||
|
||||
// 4. Create 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,
|
||||
enable_tracking: true, // Simplified: always enable tracking
|
||||
audience_type: notificationData.audienceType,
|
||||
specific_users: notificationData.specificUsers,
|
||||
exclude_unsubscribed: notificationData.excludeUnsubscribed,
|
||||
respect_do_not_disturb: true, // Simplified: always respect DND
|
||||
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: user.userID.toString(),
|
||||
status: notificationData.deliveryType === "immediate" ? "sending" : "scheduled",
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Insert notification 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) {
|
||||
for (const segment of notificationData.userSegments) {
|
||||
const segmentData = await tx.user_segments.findFirst({
|
||||
where: {
|
||||
value: segment,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (segmentData) {
|
||||
await tx.notification_user_segments.create({
|
||||
data: {
|
||||
notification_id: notification.id,
|
||||
segment_id: segmentData.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Add recipients to notification_recipients table and queue them
|
||||
// First, determine who will receive the notification
|
||||
const recipientsList = [];
|
||||
|
||||
// Get users based on audience type
|
||||
if (notificationData.audienceType === 'all') {
|
||||
// For testing, use the configured sender email
|
||||
const senderEmail = process.env.SMTP_USER || 'test@example.com';
|
||||
recipientsList.push({
|
||||
user_id: "test-user-1",
|
||||
email: senderEmail, // Use your email for testing
|
||||
});
|
||||
}
|
||||
else if (notificationData.audienceType === 'specific' && notificationData.specificUsers) {
|
||||
// Parse specific users from the text field (email addresses or IDs)
|
||||
const userIds = notificationData.specificUsers
|
||||
.split('\n')
|
||||
.filter(line => line.trim() !== '')
|
||||
.map(line => line.trim());
|
||||
|
||||
// For each specified user, add to recipients
|
||||
for (const userId of userIds) {
|
||||
const isEmail = userId.includes('@');
|
||||
recipientsList.push({
|
||||
user_id: isEmail ? userId.split('@')[0] : userId, // Extract username or use ID
|
||||
email: isEmail ? userId : `${userId}@example.com`, // Use provided email or generate fake one
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (notificationData.audienceType === 'segmented' && notificationData.userSegments?.length > 0) {
|
||||
// For demo purposes, just add placeholder users for each segment
|
||||
for (let i = 0; i < notificationData.userSegments.length; i++) {
|
||||
const segmentValue = notificationData.userSegments[i];
|
||||
recipientsList.push({
|
||||
user_id: `${segmentValue}-user-${i+1}`,
|
||||
email: `${segmentValue}${i+1}@example.com`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create recipients and queue entries using batch inserts for better performance
|
||||
console.log(`📦 Processing ${recipientsList.length} recipients across ${notificationData.channels.length} channel(s)...`);
|
||||
|
||||
// Prepare batch data for recipients
|
||||
const recipientsData = [];
|
||||
for (const recipient of recipientsList) {
|
||||
for (const channel of notificationData.channels) {
|
||||
recipientsData.push({
|
||||
notification_id: notification.id,
|
||||
user_id: recipient.user_id,
|
||||
email: channel === 'email' ? recipient.email : null,
|
||||
channel_type: channel,
|
||||
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' }
|
||||
});
|
||||
|
||||
// Determine when this notification should be scheduled
|
||||
let scheduledFor;
|
||||
if (notificationData.deliveryType === 'immediate') {
|
||||
scheduledFor = new Date(Date.now() + 60000); // 1 minute from now
|
||||
} else if (notificationData.deliveryType === 'scheduled' && notificationData.scheduledAt) {
|
||||
scheduledFor = new Date(notificationData.scheduledAt);
|
||||
} else {
|
||||
scheduledFor = new Date(Date.now() + 300000); // Fallback - 5 minutes from now
|
||||
}
|
||||
|
||||
const priority = notificationData.priority === 'critical' ? 1
|
||||
: notificationData.priority === 'high' ? 2
|
||||
: notificationData.priority === 'medium' ? 3
|
||||
: 5;
|
||||
|
||||
// Prepare batch data for queue
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
// If this is an immediate notification, trigger background queue processing (non-blocking)
|
||||
if (notificationData.deliveryType === "immediate") {
|
||||
// Don't await - let it run in background
|
||||
processEmailQueue().catch(err => {
|
||||
console.error('Background queue processing error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Return success response
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: result.id,
|
||||
message:
|
||||
notificationData.deliveryType === "immediate"
|
||||
? "Notification queued for immediate delivery"
|
||||
: "Notification has been scheduled",
|
||||
estimatedReach: result.estimatedReach,
|
||||
recipientsQueued: result.recipientsCreated || 0,
|
||||
queueStatus: "Recipients have been added to the notification queue",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Notification creation error:", error);
|
||||
|
||||
// Handle Prisma errors
|
||||
if (error.code && error.code.startsWith('P')) {
|
||||
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 {
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user