Refactor notification system: add API endpoints for creating and listing notifications, enhance asset management with new icons and logo, and update .gitignore to exclude unnecessary files.
This commit is contained in:
348
server/api/public/create-notification.post.js
Normal file
348
server/api/public/create-notification.post.js
Normal file
@@ -0,0 +1,348 @@
|
||||
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 {
|
||||
}
|
||||
});
|
||||
131
server/api/public/get-notification-list.get.js
Normal file
131
server/api/public/get-notification-list.get.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import { z } from "zod";
|
||||
import prisma from "~/server/utils/prisma";
|
||||
|
||||
const ENV = useRuntimeConfig();
|
||||
|
||||
function requireApiKey(event) {
|
||||
const headers = getRequestHeaders(event);
|
||||
const provided = headers["x-api-key"] || headers["X-API-Key"];
|
||||
const expected = ENV.notificationApiKey;
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
const listSchema = z.object({
|
||||
page: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v ? parseInt(v) : 1))
|
||||
.default("1"),
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v ? parseInt(v) : 20))
|
||||
.default("20"),
|
||||
status: z.string().optional(),
|
||||
priority: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
sortBy: z.string().default("created_at"),
|
||||
sortOrder: z.enum(["asc", "desc"]).default("desc"),
|
||||
});
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
requireApiKey(event);
|
||||
const params = listSchema.parse(getQuery(event) || {});
|
||||
|
||||
const where = {};
|
||||
if (params.status) where.status = params.status;
|
||||
if (params.priority) where.priority = params.priority;
|
||||
if (params.category)
|
||||
where.notification_categories = { value: params.category };
|
||||
if (params.search) {
|
||||
where.OR = [
|
||||
{ title: { contains: params.search } },
|
||||
{ email_subject: { contains: params.search } },
|
||||
{ push_title: { contains: params.search } },
|
||||
];
|
||||
}
|
||||
|
||||
const total = await prisma.notifications.count({ where });
|
||||
|
||||
const notifications = await prisma.notifications.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
priority: true,
|
||||
status: true,
|
||||
delivery_type: true,
|
||||
scheduled_at: true,
|
||||
created_at: true,
|
||||
notification_categories: { select: { name: true } },
|
||||
notification_channels: { select: { channel_type: true } },
|
||||
notification_recipients: { select: { status: true } },
|
||||
},
|
||||
orderBy: { [params.sortBy]: params.sortOrder },
|
||||
skip: (params.page - 1) * params.limit,
|
||||
take: params.limit,
|
||||
});
|
||||
|
||||
const items = notifications.map((n) => {
|
||||
const totalRecipients = n.notification_recipients.length;
|
||||
const delivered = n.notification_recipients.filter(
|
||||
(r) => r.status === "delivered"
|
||||
).length;
|
||||
const successRate =
|
||||
totalRecipients > 0
|
||||
? Math.round((delivered / totalRecipients) * 100)
|
||||
: 0;
|
||||
return {
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
category: n.notification_categories?.name || "Uncategorized",
|
||||
channels: n.notification_channels.map((c) => c.channel_type),
|
||||
priority: n.priority,
|
||||
status: n.status,
|
||||
recipients: totalRecipients,
|
||||
successRate,
|
||||
createdAt: n.created_at,
|
||||
};
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(total / params.limit);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
notifications: items,
|
||||
pagination: {
|
||||
page: params.page,
|
||||
totalPages,
|
||||
totalItems: total,
|
||||
hasMore: params.page < totalPages,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid query parameters",
|
||||
data: error.errors,
|
||||
});
|
||||
}
|
||||
if (error.statusCode) throw error;
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Failed to fetch notifications",
|
||||
data: { error: error.message },
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user