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:
Haqeem Solehan
2025-10-16 16:08:35 +08:00
parent b124ff8092
commit 5cf1c161db
118 changed files with 488 additions and 1 deletions

View 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 {
}
});

View 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 },
});
}
});