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:
Haqeem Solehan
2025-10-16 16:05:39 +08:00
commit b124ff8092
336 changed files with 94392 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
import { defineEventHandler, readBody } from 'h3';
// Define an interface for the expected request body (subset of AsnafProfile)
interface AsnafAnalysisRequest {
monthlyIncome: string;
otherIncome: string;
totalIncome: string;
occupation: string;
maritalStatus: string;
dependents: Array<any>; // Or a more specific type if you have one for dependents
// Add any other fields you deem necessary for OpenAI to analyze
}
interface AidSuggestion {
nama: string;
peratusan: string;
}
// Define an interface for the expected OpenAI response structure (and our API response)
interface AsnafAnalysisResponse {
hadKifayahPercentage: string;
kategoriAsnaf: string;
kategoriKeluarga: string;
cadanganKategori: string;
statusKelayakan: string;
cadanganBantuan: AidSuggestion[];
ramalanJangkaMasaPulih: string;
rumusan: string;
}
export default defineEventHandler(async (event): Promise<AsnafAnalysisResponse> => {
const body = await readBody<AsnafAnalysisRequest>(event);
// --- Placeholder for Actual OpenAI API Call ---
// In a real application, you would:
// 1. Retrieve your OpenAI API key securely (e.g., from environment variables)
const openAIApiKey = process.env.OPENAI_API_KEY;
if (!openAIApiKey) {
console.error('OpenAI API key not configured. Please set OPENAI_API_KEY in your .env file.');
throw createError({ statusCode: 500, statusMessage: 'OpenAI API key not configured' });
}
// 2. Construct the prompt for OpenAI using the data from `body`.
// IMPORTANT: Sanitize or carefully construct any data from `body` included in the prompt to prevent prompt injection.
const prompt = `You are an expert Zakat administrator. Based on the following applicant data: monthlyIncome: ${body.monthlyIncome}, totalIncome: ${body.totalIncome}, occupation: ${body.occupation}, maritalStatus: ${body.maritalStatus}, dependents: ${body.dependents.length}.
Return JSON with keys: hadKifayahPercentage, kategoriAsnaf, kategoriKeluarga, cadanganKategori, statusKelayakan, cadanganBantuan, ramalanJangkaMasaPulih, rumusan.
For 'cadanganBantuan', provide a JSON array of objects, where each object has a 'nama' (string, name of the aid) and 'peratusan' (string, e.g., '85%', representing suitability). Suggest 2-3 most relevant aid types.
Example for cadanganBantuan: [{"nama": "Bantuan Kewangan Bulanan", "peratusan": "90%"}, {"nama": "Bantuan Makanan Asas", "peratusan": "75%"}].
Full JSON Example: {"hadKifayahPercentage": "75%", ..., "cadanganBantuan": [{"nama": "Bantuan Kewangan Bulanan", "peratusan": "90%"}], ...}`;
// Adjust the prompt to be more detailed and specific to your needs and desired JSON output structure.
// 3. Make the API call to OpenAI
try {
const openAIResponse = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${openAIApiKey}`,
},
body: JSON.stringify({
model: 'gpt-3.5-turbo', // Or your preferred model like gpt-4
messages: [{ role: 'user', content: prompt }],
// For more consistent JSON output, consider using a model version that officially supports JSON mode if available
// and set response_format: { type: "json_object" }, (check OpenAI documentation for model compatibility)
}),
});
if (!openAIResponse.ok) {
const errorData = await openAIResponse.text();
console.error('OpenAI API Error details:', errorData);
throw createError({ statusCode: openAIResponse.status, statusMessage: `Failed to get analysis from OpenAI: ${openAIResponse.statusText}` });
}
const openAIData = await openAIResponse.json();
// Parse the content from the response - structure might vary slightly based on OpenAI model/API version
// It's common for the JSON string to be in openAIData.choices[0].message.content
if (openAIData.choices && openAIData.choices[0] && openAIData.choices[0].message && openAIData.choices[0].message.content) {
const analysisResult = JSON.parse(openAIData.choices[0].message.content) as AsnafAnalysisResponse;
return analysisResult;
} else {
console.error('OpenAI response structure not as expected:', openAIData);
throw createError({ statusCode: 500, statusMessage: 'Unexpected response structure from OpenAI' });
}
} catch (error) {
console.error('Error during OpenAI API call or parsing:', error);
// Avoid exposing detailed internal errors to the client if they are not createError objects
if (typeof error === 'object' && error !== null && 'statusCode' in error) {
// We can infer error has statusCode here, but to be super safe with TS:
const e = error as { statusCode: number };
if (e.statusCode) throw e;
}
throw createError({ statusCode: 500, statusMessage: 'Internal server error during AI analysis' });
}
// --- End of Actual OpenAI API Call ---
// The simulated response below this line should be REMOVED once the actual OpenAI call is implemented and working.
/*
console.log('Received for analysis in server route:', body);
await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate API delay
const totalIncomeNumeric = parseFloat(body.totalIncome);
let percentage = '50%';
if (totalIncomeNumeric < 1000) percentage = '30%';
else if (totalIncomeNumeric < 2000) percentage = '65%';
else if (totalIncomeNumeric < 3000) percentage = '85%';
else percentage = '110%';
return {
hadKifayahPercentage: percentage,
kategoriAsnaf: 'Simulated - Miskin',
kategoriKeluarga: 'Simulated - Miskin (50-100% HK)',
cadanganKategori: 'Simulated - Miskin',
statusKelayakan: 'Simulated - Layak (Miskin)',
cadanganBantuan: [
{ nama: 'Simulated - Bantuan Kewangan Bulanan', peratusan: '80%' },
{ nama: 'Simulated - Bantuan Pendidikan Anak', peratusan: '65%' }
],
ramalanJangkaMasaPulih: 'Simulated - 6 bulan',
rumusan: 'Simulated - Pemohon memerlukan perhatian segera.'
};
*/
});

View File

@@ -0,0 +1,93 @@
import sha256 from "crypto-js/sha256.js";
import jwt from "jsonwebtoken";
const ENV = useRuntimeConfig();
export default defineEventHandler(async (event) => {
try {
const { username, password } = await readBody(event);
if (!username || !password) {
return {
statusCode: 400,
message: "Username and password are required",
};
}
const user = await prisma.user.findFirst({
where: {
userUsername: username,
},
});
if (!user) {
return {
statusCode: 404,
message: "User does not exist",
};
}
const hashedPassword = sha256(password).toString();
if (user.userPassword !== hashedPassword) {
return {
statusCode: 401,
message: "Invalid password",
};
}
// Get user roles
const roles = await prisma.userrole.findMany({
where: {
userRoleUserID: user.userID,
},
select: {
role: {
select: {
roleName: true,
},
},
},
});
const roleNames = roles.map((r) => r.role.roleName);
const accessToken = generateAccessToken({
username: user.userUsername,
roles: roleNames,
});
const refreshToken = generateRefreshToken({
username: user.userUsername,
roles: roleNames,
});
// Set cookie httpOnly
event.res.setHeader("Set-Cookie", [
`accessToken=${accessToken}; HttpOnly; Secure; SameSite=Lax; Path=/`,
`refreshToken=${refreshToken}; HttpOnly; Secure; SameSite=Lax; Path=/`,
]);
return {
statusCode: 200,
message: "Login success",
data: {
username: user.userUsername,
roles: roleNames,
},
};
} catch (error) {
console.log(error);
return {
statusCode: 500,
message: "Internal server error",
};
}
});
function generateAccessToken(user) {
return jwt.sign(user, ENV.auth.secretAccess, { expiresIn: "1d" });
}
function generateRefreshToken(user) {
return jwt.sign(user, ENV.auth.secretRefresh, { expiresIn: "30d" });
}

View File

@@ -0,0 +1,19 @@
export default defineEventHandler(async (event) => {
try {
event.res.setHeader("Set-Cookie", [
`accessToken=; HttpOnly; Secure; SameSite=Lax; Path=/`,
`refreshToken=; HttpOnly; Secure; SameSite=Lax; Path=/`,
]);
return {
statusCode: 200,
message: "Logout success",
};
} catch (error) {
console.log(error);
return {
statusCode: 400,
message: "Server error",
};
}
});

View File

@@ -0,0 +1,34 @@
export default defineEventHandler(async (event) => {
try {
const { userID } = event.context.user;
if (userID == null) {
return {
statusCode: 401,
message: "Unauthorized",
};
}
const validatedUser = await prisma.user.findFirst({
where: {
userID: parseInt(userID),
},
});
if (!validatedUser) {
return {
statusCode: 401,
message: "Unauthorized",
};
}
return {
statusCode: 200,
message: "Authorized",
};
} catch (error) {
return {
statusCode: 401,
message: "Unauthorized",
};
}
});

View File

@@ -0,0 +1,24 @@
import fs from "fs";
import path from "path";
export default defineEventHandler(async (event) => {
const query = await getQuery(event);
try {
// Get vue code from path in query
const filePath = path.join(process.cwd() + "/server/", query.path + ".js");
const code = fs.readFileSync(filePath, "utf8");
return {
statusCode: 200,
message: "Code successfully loaded",
data: code,
};
} catch (error) {
// console.log(error);
return {
statusCode: 500,
message: "File not found",
};
}
});

View File

@@ -0,0 +1,191 @@
// import esline vue
import { ESLint } from "eslint";
export default defineEventHandler(async (event) => {
const body = await readBody(event);
try {
if (body.code === undefined) {
return {
statusCode: 400,
message: "Bad Request",
};
}
// run linter
const code = body.code;
const validateNitroCode = (code) => {
// Check if this is a server route file
const isServerRoute = code.includes("defineEventHandler");
if (isServerRoute) {
let lineNumber = 1;
// 1. Validate event handler structure
if (!code.includes("export default defineEventHandler")) {
throw {
message:
"Nitro route handlers must use 'export default defineEventHandler'",
line: 1,
column: 0,
};
}
// 2. Check for proper request handling
const hasRequestBody = code.includes("await readBody(event)");
const hasRequestQuery = code.includes("getQuery(event)");
const usesEventWithoutImport =
code.includes("event.") && !hasRequestBody && !hasRequestQuery;
if (usesEventWithoutImport) {
// Find the line where event is improperly used
const lines = code.split("\n");
for (let i = 0; i < lines.length; i++) {
if (
lines[i].includes("event.") &&
!lines[i].includes("readBody") &&
!lines[i].includes("getQuery")
) {
throw {
message:
"Use 'readBody(event)' for POST data or 'getQuery(event)' for query parameters",
line: i + 1,
column: lines[i].indexOf("event."),
};
}
}
}
// 3. Validate response structure
const responseRegex = /return\s+{([^}]+)}/g;
let match;
let lastIndex = 0;
while ((match = responseRegex.exec(code)) !== null) {
lineNumber += (code.slice(lastIndex, match.index).match(/\n/g) || [])
.length;
lastIndex = match.index;
const responseContent = match[1];
// Check for required response properties
if (!responseContent.includes("statusCode")) {
throw {
message: "API responses must include a 'statusCode' property",
line: lineNumber,
column: match.index - code.lastIndexOf("\n", match.index),
};
}
// Validate status code usage
const statusMatch = responseContent.match(/statusCode:\s*(\d+)/);
if (statusMatch) {
const statusCode = parseInt(statusMatch[1]);
if (![200, 201, 400, 401, 403, 404, 500].includes(statusCode)) {
throw {
message: `Invalid status code: ${statusCode}. Use standard HTTP status codes.`,
line: lineNumber,
column: statusMatch.index,
};
}
}
}
// 4. Check error handling
if (code.includes("try") && !code.includes("catch")) {
throw {
message:
"Missing error handling. Add a catch block for try statements.",
line:
code.split("\n").findIndex((line) => line.includes("try")) + 1,
column: 0,
};
}
// 5. Validate async/await usage
const asyncLines = code.match(/async.*=>/g) || [];
const awaitLines = code.match(/await\s+/g) || [];
if (awaitLines.length > 0 && asyncLines.length === 0) {
throw {
message: "Using 'await' requires an async function",
line:
code.split("\n").findIndex((line) => line.includes("await")) + 1,
column: 0,
};
}
// // 6. Check for proper imports
// const requiredImports = new Set();
// if (hasRequestBody) requiredImports.add("readBody");
// if (hasRequestQuery) requiredImports.add("getQuery");
// const importLines = code.match(/import.*from/g) || [];
// requiredImports.forEach((imp) => {
// if (!importLines.some((line) => line.includes(imp))) {
// throw {
// message: `Missing import for '${imp}' utility`,
// line: 1,
// column: 0,
// };
// }
// });
}
};
try {
validateNitroCode(code);
const eslint = new ESLint({
overrideConfig: {
parser: "@babel/eslint-parser",
extends: ["@kiwicom"],
parserOptions: {
requireConfigFile: false,
ecmaVersion: 2020,
sourceType: "module",
},
},
useEslintrc: false,
});
const results = await eslint.lintText(code);
if (results[0].messages.length > 0) {
const messages = results[0].messages[0];
if (messages.fatal === true) {
return {
statusCode: 400,
message: "Bad Linter Test",
data: messages,
};
}
return {
statusCode: 200,
message: "Good Linter test",
data: messages,
};
}
} catch (error) {
console.log(error);
return {
statusCode: 400,
message: "Bad Linter Test",
data: {
message: error.message,
line: error.line || 1,
column: error.column || 0,
},
};
}
} catch (error) {
console.log(error);
return {
statusCode: 500,
message: "Internal Server Error",
errror: error,
};
}
});

View File

@@ -0,0 +1,77 @@
import fs from "fs";
import path from "path";
export default defineEventHandler(async (event) => {
try {
// get api folder path from server root folder and its files and folders inside it
const apiFolderPath = path.join(process.cwd() + "/server/api");
const apis = fs.readdirSync(apiFolderPath);
const apiList = getFilesAndFolders(apiFolderPath);
const apiUrls = getApiUrls(apiList);
const jsonObject = JSON.parse(JSON.stringify(apiUrls));
return {
statusCode: 200,
message: "API List successfully fetched",
data: jsonObject,
};
} catch (error) {
return {
statusCode: 500,
message: error.message,
};
}
});
function getFilesAndFolders(folderPath) {
const folderFiles = fs.readdirSync(folderPath);
const files = [];
const folders = [];
const apiURL = "/api";
folderFiles.forEach((file) => {
const filePath = path.join(folderPath + "/" + file);
if (file == "devtool") return;
if (fs.lstatSync(filePath).isDirectory()) {
folders.push(getFilesAndFolders(filePath));
} else {
const processPath = path.join(process.cwd() + "/server/api");
const apiUrl = filePath
.replace(processPath, apiURL)
.replace(/\\/g, "/")
.replace(".js", "");
const fileName = file.replace(".js", "");
const parentFolder = folderPath
.replace(processPath, "")
.replace(/\\/g, "");
files.push({
name: fileName,
parentName: parentFolder,
url: apiUrl,
});
}
});
return { files, folders };
}
function getApiUrls(folder) {
const apiUrls = [];
folder.files.forEach((file) => {
apiUrls.push({
name: file.name,
parentName: file.parentName,
url: file.url,
});
});
folder.folders.forEach((nestedFolder) => {
apiUrls.push(...getApiUrls(nestedFolder));
});
return apiUrls;
}

View File

@@ -0,0 +1,27 @@
import prettier from "prettier";
export default defineEventHandler(async (event) => {
const body = await readBody(event);
try {
if (body.code === undefined) {
return {
statusCode: 400,
message: "Bad Request",
};
}
const code = prettier.format(body.code, { semi: false, parser: "babel" });
return {
statusCode: 200,
message: "Code successfully formatted",
data: code,
};
} catch (error) {
return {
statusCode: 500,
message: "Internal Server Error",
};
}
});

View File

@@ -0,0 +1,71 @@
import fs from "fs";
import path from "path";
export default defineEventHandler(async (event) => {
const body = await readBody(event);
try {
const codeDefault = `
export default defineEventHandler(async (event) => {
// const query = await getQuery(event); // Get Params from URL
// const body = await readBody(event); // Get Body Data
return {
statusCode: 200,
message: "API Route Created",
};
});`;
// Overwrite vue code from path in body with new code
const filePath = path.join(process.cwd() + "/server/", body.path + ".js");
if (body.type == "update") {
fs.writeFileSync(filePath, body.code, "utf8");
} else if (body.type == "add") {
// if the folder doesn't exist, create it
if (!fs.existsSync(path.dirname(filePath))) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
fs.writeFileSync(filePath, codeDefault, "utf8");
} else if (body.type == "edit") {
// if the folder doesn't exist, create it
if (!fs.existsSync(path.dirname(filePath))) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
// Copy the file from the default path to the new path
const oldPath = path.join(
process.cwd() + "/server/",
body.oldPath + ".js"
);
// Copy file
fs.copyFileSync(oldPath, filePath);
// Delete old file
fs.unlinkSync(oldPath);
} else if (body.type == "delete") {
// Delete file from path
fs.unlinkSync(filePath);
return {
statusCode: 200,
message: "Code successfully deleted",
};
}
return {
statusCode: 200,
message: "Code successfully saved",
};
} catch (error) {
console.log(error);
return {
statusCode: 500,
message: "Internal Server Error",
};
}
});

View File

@@ -0,0 +1,90 @@
import fs from "fs";
import path from "path";
export default defineEventHandler(async (event) => {
const method = getMethod(event);
if (method !== "POST") {
return {
statusCode: 405,
message: "Method not allowed",
};
}
try {
const body = await readBody(event);
const { themeName, themeCSS } = body;
if (!themeName || !themeCSS) {
return {
statusCode: 400,
message: "Theme name and CSS are required",
};
}
// Validate theme name (alphanumeric and hyphens only)
if (!/^[a-zA-Z0-9-_]+$/.test(themeName)) {
return {
statusCode: 400,
message: "Theme name can only contain letters, numbers, hyphens, and underscores",
};
}
// Path to theme.css file
const themeCSSPath = path.join(process.cwd(), 'assets', 'style', 'css', 'base', 'theme.css');
// Check if theme.css exists
if (!fs.existsSync(themeCSSPath)) {
return {
statusCode: 404,
message: "theme.css file not found",
};
}
// Read current theme.css content
let currentContent = fs.readFileSync(themeCSSPath, 'utf8');
// Check if theme already exists
const themePattern = new RegExp(`html\\[data-theme="${themeName}"\\]`, 'g');
if (themePattern.test(currentContent)) {
return {
statusCode: 409,
message: `Theme "${themeName}" already exists`,
};
}
// Format the new theme CSS
const formattedThemeCSS = themeCSS.trim();
// Ensure the CSS starts with the correct selector if not provided
let finalThemeCSS;
if (!formattedThemeCSS.includes(`html[data-theme="${themeName}"]`)) {
finalThemeCSS = `html[data-theme="${themeName}"] {\n${formattedThemeCSS}\n}`;
} else {
finalThemeCSS = formattedThemeCSS;
}
// Add the new theme to the end of the file
const newContent = currentContent + '\n\n' + finalThemeCSS + '\n';
// Write the updated content back to the file
fs.writeFileSync(themeCSSPath, newContent, 'utf8');
return {
statusCode: 200,
message: "Custom theme added successfully",
data: {
themeName,
success: true
},
};
} catch (error) {
console.error("Add custom theme error:", error);
return {
statusCode: 500,
message: "Internal server error",
error: error.message,
};
}
});

View File

@@ -0,0 +1,22 @@
import fs from "fs";
import path from "path";
export default defineEventHandler(async (event) => {
// Get .env file, parse and return
const envFile = path.join(process.cwd(), ".env");
if (!fs.existsSync(envFile)) {
return {
statusCode: 404,
message: "File not found",
};
}
const env = fs.readFileSync(envFile, "utf-8");
return {
statusCode: 200,
message: "Success",
data: env,
};
});

View File

@@ -0,0 +1,44 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
const method = getMethod(event);
try {
if (method === "GET") {
// Get only the loading logo and site name for faster loading
const settings = await prisma.site_settings.findFirst({
select: {
siteLoadingLogo: true,
siteName: true,
},
orderBy: { settingID: "desc" },
});
return {
statusCode: 200,
message: "Success",
data: {
siteLoadingLogo: settings?.siteLoadingLogo || '',
siteName: settings?.siteName || 'corradAF',
},
};
}
return {
statusCode: 405,
message: "Method not allowed",
};
} catch (error) {
console.error("Loading logo API error:", error);
return {
statusCode: 500,
message: "Internal server error",
error: error.message,
};
} finally {
await prisma.$disconnect();
}
});

View File

@@ -0,0 +1,217 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export default defineEventHandler(async (event) => {
const method = getMethod(event);
try {
if (method === "GET") {
// Get site settings
let settings = await prisma.site_settings.findFirst({
orderBy: { settingID: "desc" },
});
// If no settings exist, create default ones
if (!settings) {
settings = await prisma.site_settings.create({
data: {
siteName: "corradAF",
siteDescription: "corradAF Base Project",
themeMode: "biasa",
showSiteNameInHeader: true,
seoRobots: "index, follow",
seoTwitterCard: "summary_large_image",
settingCreatedDate: new Date(),
settingModifiedDate: new Date(),
},
});
}
// Transform data to match new structure
const transformedSettings = {
siteName: settings.siteName || "corradAF",
siteNameFontSize: settings.siteNameFontSize || 18,
siteDescription: settings.siteDescription || "corradAF Base Project",
siteLogo: settings.siteLogo || "",
siteLoadingLogo: settings.siteLoadingLogo || "",
siteFavicon: settings.siteFavicon || "",
siteLoginLogo: settings.siteLoginLogo || "",
showSiteNameInHeader: settings.showSiteNameInHeader !== false,
customCSS: settings.customCSS || "",
selectedTheme: settings.themeMode || "biasa", // Use themeMode as selectedTheme
customThemeFile: settings.customThemeFile || "",
currentFont: settings.currentFont || "",
fontSource: settings.fontSource || "",
// SEO fields
seoTitle: settings.seoTitle || "",
seoDescription: settings.seoDescription || "",
seoKeywords: settings.seoKeywords || "",
seoAuthor: settings.seoAuthor || "",
seoOgImage: settings.seoOgImage || "",
seoTwitterCard: settings.seoTwitterCard || "summary_large_image",
seoCanonicalUrl: settings.seoCanonicalUrl || "",
seoRobots: settings.seoRobots || "index, follow",
seoGoogleAnalytics: settings.seoGoogleAnalytics || "",
seoGoogleTagManager: settings.seoGoogleTagManager || "",
seoFacebookPixel: settings.seoFacebookPixel || ""
};
return {
statusCode: 200,
message: "Success",
data: transformedSettings,
};
}
if (method === "POST") {
let body;
try {
body = await readBody(event);
} catch (bodyError) {
console.error("Error reading request body:", bodyError);
return {
statusCode: 400,
message: "Invalid request body",
error: bodyError.message,
};
}
// Validate required fields
if (!body || typeof body !== 'object') {
return {
statusCode: 400,
message: "Request body must be a valid JSON object",
};
}
// Check if settings exist
const existingSettings = await prisma.site_settings.findFirst();
// Prepare data for database (use themeMode instead of selectedTheme)
// Filter out undefined values to avoid database errors
const dbData = {};
// Only add fields that are not undefined
if (body.siteName !== undefined) dbData.siteName = body.siteName;
if (body.siteNameFontSize !== undefined) dbData.siteNameFontSize = body.siteNameFontSize;
if (body.siteDescription !== undefined) dbData.siteDescription = body.siteDescription;
if (body.siteLogo !== undefined) dbData.siteLogo = body.siteLogo;
if (body.siteLoadingLogo !== undefined) dbData.siteLoadingLogo = body.siteLoadingLogo;
if (body.siteFavicon !== undefined) dbData.siteFavicon = body.siteFavicon;
if (body.siteLoginLogo !== undefined) dbData.siteLoginLogo = body.siteLoginLogo;
if (body.showSiteNameInHeader !== undefined) dbData.showSiteNameInHeader = body.showSiteNameInHeader;
if (body.customCSS !== undefined) dbData.customCSS = body.customCSS;
if (body.selectedTheme !== undefined) dbData.themeMode = body.selectedTheme;
if (body.customThemeFile !== undefined) dbData.customThemeFile = body.customThemeFile;
if (body.currentFont !== undefined) dbData.currentFont = body.currentFont;
if (body.fontSource !== undefined) dbData.fontSource = body.fontSource;
if (body.seoTitle !== undefined) dbData.seoTitle = body.seoTitle;
if (body.seoDescription !== undefined) dbData.seoDescription = body.seoDescription;
if (body.seoKeywords !== undefined) dbData.seoKeywords = body.seoKeywords;
if (body.seoAuthor !== undefined) dbData.seoAuthor = body.seoAuthor;
if (body.seoOgImage !== undefined) dbData.seoOgImage = body.seoOgImage;
if (body.seoTwitterCard !== undefined) dbData.seoTwitterCard = body.seoTwitterCard;
if (body.seoCanonicalUrl !== undefined) dbData.seoCanonicalUrl = body.seoCanonicalUrl;
if (body.seoRobots !== undefined) dbData.seoRobots = body.seoRobots;
if (body.seoGoogleAnalytics !== undefined) dbData.seoGoogleAnalytics = body.seoGoogleAnalytics;
if (body.seoGoogleTagManager !== undefined) dbData.seoGoogleTagManager = body.seoGoogleTagManager;
if (body.seoFacebookPixel !== undefined) dbData.seoFacebookPixel = body.seoFacebookPixel;
dbData.settingModifiedDate = new Date();
let settings;
if (existingSettings) {
// Update existing settings
settings = await prisma.site_settings.update({
where: { settingID: existingSettings.settingID },
data: dbData,
});
} else {
// Create new settings
settings = await prisma.site_settings.create({
data: {
...dbData,
settingCreatedDate: new Date(),
},
});
}
// Transform response to match new structure
const transformedSettings = {
siteName: settings.siteName || "corradAF",
siteNameFontSize: settings.siteNameFontSize || 18,
siteDescription: settings.siteDescription || "corradAF Base Project",
siteLogo: settings.siteLogo || "",
siteLoadingLogo: settings.siteLoadingLogo || "",
siteFavicon: settings.siteFavicon || "",
siteLoginLogo: settings.siteLoginLogo || "",
showSiteNameInHeader: settings.showSiteNameInHeader !== false,
customCSS: settings.customCSS || "",
selectedTheme: settings.themeMode || "biasa", // Use themeMode as selectedTheme
customThemeFile: settings.customThemeFile || "",
currentFont: settings.currentFont || "",
fontSource: settings.fontSource || "",
// SEO fields
seoTitle: settings.seoTitle || "",
seoDescription: settings.seoDescription || "",
seoKeywords: settings.seoKeywords || "",
seoAuthor: settings.seoAuthor || "",
seoOgImage: settings.seoOgImage || "",
seoTwitterCard: settings.seoTwitterCard || "summary_large_image",
seoCanonicalUrl: settings.seoCanonicalUrl || "",
seoRobots: settings.seoRobots || "index, follow",
seoGoogleAnalytics: settings.seoGoogleAnalytics || "",
seoGoogleTagManager: settings.seoGoogleTagManager || "",
seoFacebookPixel: settings.seoFacebookPixel || ""
};
return {
statusCode: 200,
message: "Settings updated successfully",
data: transformedSettings,
};
}
return {
statusCode: 405,
message: "Method not allowed",
};
} catch (error) {
console.error("Site settings API error:", error);
// Provide more specific error messages
if (error.code === 'P2002') {
return {
statusCode: 400,
message: "Duplicate entry error",
error: error.message,
};
}
if (error.code === 'P2025') {
return {
statusCode: 404,
message: "Record not found",
error: error.message,
};
}
if (error.code && error.code.startsWith('P')) {
return {
statusCode: 400,
message: "Database error",
error: error.message,
code: error.code,
};
}
return {
statusCode: 500,
message: "Internal server error",
error: error.message,
};
} finally {
await prisma.$disconnect();
}
});

View File

@@ -0,0 +1,134 @@
import fs from "fs";
import path from "path";
import { v4 as uuidv4 } from "uuid";
export default defineEventHandler(async (event) => {
const method = getMethod(event);
if (method !== "POST") {
return {
statusCode: 405,
message: "Method not allowed",
};
}
try {
const form = await readMultipartFormData(event);
if (!form || form.length === 0) {
return {
statusCode: 400,
message: "No file uploaded",
};
}
const file = form[0];
const fileType = form.find(field => field.name === 'type')?.data?.toString() || 'logo';
// Validate file type
const allowedTypes = {
logo: ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/svg+xml'],
'loading-logo': ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/svg+xml'],
'login-logo': ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/svg+xml'],
favicon: ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png'],
'og-image': ['image/jpeg', 'image/jpg', 'image/png'],
theme: ['text/css', 'application/octet-stream']
};
if (!allowedTypes[fileType] || !allowedTypes[fileType].includes(file.type)) {
return {
statusCode: 400,
message: `Invalid file type for ${fileType}. Allowed types: ${allowedTypes[fileType].join(', ')}`,
};
}
let uploadDir, fileUrl;
// Determine upload directory based on file type
if (fileType === 'theme') {
// Theme files go to assets/style/css
uploadDir = path.join(process.cwd(), 'assets', 'style', 'css');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// Generate unique filename for theme
const fileExtension = path.extname(file.filename || '');
const uniqueFilename = `custom-theme-${uuidv4()}${fileExtension}`;
const filePath = path.join(uploadDir, uniqueFilename);
// Save file
fs.writeFileSync(filePath, file.data);
// Return relative path for theme files
fileUrl = `/assets/style/css/${uniqueFilename}`;
} else {
// Logo, loading-logo, favicon, and og-image files go to public/uploads
uploadDir = path.join(process.cwd(), 'public', 'uploads', 'site-settings');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const fileExtension = path.extname(file.filename || '');
let baseFilename;
switch (fileType) {
case 'logo':
baseFilename = 'site-logo';
break;
case 'loading-logo':
baseFilename = 'loading-logo';
break;
case 'login-logo':
baseFilename = 'login-logo';
break;
case 'favicon':
baseFilename = 'favicon';
break;
case 'og-image':
baseFilename = 'og-image';
break;
default:
// This case should ideally not be reached if fileType is validated earlier
// and is one of the image types.
// However, as a fallback, use the fileType itself or a generic name.
// For safety, and to avoid using uuidv4 for these specific types as requested,
// we should ensure this path isn't taken for the specified image types.
// If an unexpected fileType gets here, it might be better to error or use a UUID.
// For now, we'll stick to the primary requirement of fixed names for specified types.
// If we need UUID for other non-logo image types, that logic can be added.
// console.warn(`Unexpected fileType received: ${fileType} for non-theme upload.`);
// For simplicity, if it's an image type not explicitly handled, it will get a name like 'unknown-type.ext'
baseFilename = fileType;
}
const filenameWithExt = `${baseFilename}${fileExtension}`;
const filePath = path.join(uploadDir, filenameWithExt);
// Save file (overwrites if exists)
fs.writeFileSync(filePath, file.data);
// Return file URL
fileUrl = `/uploads/site-settings/${filenameWithExt}`;
}
return {
statusCode: 200,
message: "File uploaded successfully",
data: {
filename: path.basename(fileUrl),
url: fileUrl,
type: fileType,
size: file.data.length,
},
};
} catch (error) {
console.error("Upload error:", error);
return {
statusCode: 500,
message: "Internal server error",
error: error.message,
};
}
});

View File

@@ -0,0 +1,48 @@
import fs from "fs";
import path from "path";
export default defineEventHandler(async (event) => {
const query = await getQuery(event);
let code = "";
// console.log(query.path);
try {
// Get vue code from path in query
const filePath = path.join(process.cwd() + "/pages/", query.path + ".vue");
try {
code = fs.readFileSync(filePath, "utf8");
return {
statusCode: 200,
message: "Code successfully loaded",
data: code,
};
} catch (error) {}
// Check if there is path with index.vue
const filePathIndex = path.join(
process.cwd() + "/pages/",
query.path + "/index.vue"
);
code = fs.readFileSync(filePathIndex, "utf8");
// Only get the template part of the code and make sure its from the first template tag to the last template tag
code = code.substring(
code.indexOf("<template>") + 10,
code.lastIndexOf("</template>")
);
return {
statusCode: 200,
message: "Code successfully loaded",
data: code,
mode: "index",
};
} catch (error) {
return {
statusCode: 500,
message: "File not found",
};
}
});

View File

@@ -0,0 +1,42 @@
import fs from "fs";
import path from "path";
export default defineEventHandler(async (event) => {
const query = await getQuery(event);
let code = "";
// console.log(query.path);
try {
// Get vue code from path in query
const filePath = path.join(process.cwd() + "/pages/", query.path + ".vue");
try {
code = fs.readFileSync(filePath, "utf8");
return {
statusCode: 200,
message: "Code successfully loaded",
data: code,
};
} catch (error) {}
// Check if there is path with index.vue
const filePathIndex = path.join(
process.cwd() + "/pages/",
query.path + "/index.vue"
);
code = fs.readFileSync(filePathIndex, "utf8");
return {
statusCode: 200,
message: "Code successfully loaded",
data: code,
mode: "index",
};
} catch (error) {
return {
statusCode: 500,
message: "File not found",
};
}
});

View File

@@ -0,0 +1,450 @@
import { ESLint } from "eslint";
export default defineEventHandler(async (event) => {
const body = await readBody(event);
try {
if (body.code === undefined) {
return {
statusCode: 400,
message: "Bad Request",
};
}
const code = body.code;
// Extract script and template content once
const scriptContent =
code.match(/<script\b[^>]*>([\s\S]*?)<\/script>/)?.[1] || "";
const templateContent = code.match(/<template>([\s\S]*)<\/template>/)?.[1];
// Validate FormKit inputs
const validateFormKit = (content) => {
// List of valid FormKit input types
const validFormKitTypes = [
"text",
"email",
"url",
"tel",
"password",
"number",
"date",
"datetime-local",
"time",
"month",
"week",
"search",
"color",
"file",
"range",
"checkbox",
"radio",
"select",
"textarea",
"submit",
"button",
"mask",
"form",
];
// Find all FormKit components
const formKitRegex = /<FormKit[^>]*>/g;
let formKitMatch;
// Start counting from template tag
let lineNumber = content
.slice(0, content.indexOf("<template"))
.split("\n").length;
let lastIndex = 0;
while ((formKitMatch = formKitRegex.exec(content)) !== null) {
// Calculate correct line number including the lines before template
lineNumber += (
content.slice(lastIndex, formKitMatch.index).match(/\n/g) || []
).length;
lastIndex = formKitMatch.index;
const formKitTag = formKitMatch[0];
// Extract type attribute
const typeMatch = formKitTag.match(/type=["']([^"']+)["']/);
if (!typeMatch) {
throw {
message: "FormKit component missing required 'type' attribute",
line: lineNumber,
column:
formKitMatch.index -
content.lastIndexOf("\n", formKitMatch.index),
};
}
const inputType = typeMatch[1];
if (!validFormKitTypes.includes(inputType)) {
throw {
message: `Invalid FormKit type: "${inputType}". Please use a valid input type.`,
line: lineNumber,
column:
formKitMatch.index -
content.lastIndexOf("\n", formKitMatch.index),
};
}
// Check for options in select, radio, and checkbox types
if (["select", "radio", "checkbox"].includes(inputType)) {
// Look for :options or v-model
const hasOptions =
formKitTag.includes(":options=") || formKitTag.includes("v-model=");
const hasSlotContent =
content
.slice(
formKitMatch.index,
content.indexOf(">", formKitMatch.index)
)
.includes(">") &&
content
.slice(
formKitMatch.index,
content.indexOf("</FormKit>", formKitMatch.index)
)
.includes("<option");
if (!hasOptions && !hasSlotContent) {
throw {
message: `FormKit ${inputType} requires options. Add :options prop or option slots.`,
line: lineNumber,
column:
formKitMatch.index -
content.lastIndexOf("\n", formKitMatch.index),
};
}
}
}
};
// Add new function to validate mustache syntax
const validateMustacheSyntax = (content) => {
const stack = [];
let lineNumber = 1;
let lastIndex = 0;
for (let i = 0; i < content.length; i++) {
if (content[i] === "\n") {
lineNumber++;
lastIndex = i + 1;
}
if (content[i] === "{" && content[i + 1] === "{") {
stack.push({
position: i,
line: lineNumber,
column: i - lastIndex,
});
i++; // Skip next '{'
} else if (content[i] === "}" && content[i + 1] === "}") {
if (stack.length === 0) {
throw {
message:
"Unexpected closing mustache brackets '}}' without matching opening brackets",
line: lineNumber,
column: i - lastIndex,
};
}
stack.pop();
i++; // Skip next '}'
}
}
if (stack.length > 0) {
const unclosed = stack[0];
throw {
message:
"Unclosed mustache brackets '{{'. Missing closing brackets '}}",
line: unclosed.line,
column: unclosed.column,
};
}
};
// Check template content and FormKit validation
if (templateContent) {
try {
validateMustacheSyntax(templateContent);
validateFormKit(templateContent);
} catch (error) {
return {
statusCode: 400,
message: "Template Syntax Error",
data: {
message: error.message,
line: error.line,
column: error.column,
},
};
}
// Check for undefined variables
const definedVariables = new Set();
// Add common Vue variables
const commonVueVars = [
"$route",
"$router",
"$refs",
"$emit",
"$slots",
"$attrs",
];
commonVueVars.forEach((v) => definedVariables.add(v));
// Extract refs and other variables from script
const refRegex = /(?:const|let|var)\s+(\w+)\s*=/g;
let varMatch;
while ((varMatch = refRegex.exec(scriptContent)) !== null) {
definedVariables.add(varMatch[1]);
}
// Extract defineProps if any
const propsMatch = scriptContent.match(/defineProps\(\s*{([^}]+)}\s*\)/);
if (propsMatch) {
const propsContent = propsMatch[1];
const propNames = propsContent.match(/(\w+)\s*:/g);
propNames?.forEach((prop) => {
definedVariables.add(prop.replace(":", "").trim());
});
}
// Check template for undefined variables
const mustacheRegex = /{{([^}]+)}}/g;
let lineNumber = 1;
let lastIndex = 0;
let mustacheMatch;
while ((mustacheMatch = mustacheRegex.exec(templateContent)) !== null) {
// Calculate line number
lineNumber += (
templateContent.slice(lastIndex, mustacheMatch.index).match(/\n/g) ||
[]
).length;
lastIndex = mustacheMatch.index;
const expression = mustacheMatch[1].trim();
// Split expression and check each variable
const variables = expression.split(/[\s.()[\]]+/);
for (const variable of variables) {
// Skip numbers, operators, and empty strings
if (
!variable ||
variable.match(/^[\d+\-*/&|!%<>=?:]+$/) ||
variable === "true" ||
variable === "false"
) {
continue;
}
if (!definedVariables.has(variable)) {
return {
statusCode: 400,
message: "Template Reference Error",
data: {
message: `Variable "${variable}" is not defined`,
line: lineNumber,
column:
mustacheMatch.index -
templateContent.lastIndexOf("\n", mustacheMatch.index),
},
};
}
}
}
}
// Validate template structure
const validateTemplateStructure = (code) => {
// Add new validation for script tags inside template
const templateContent1 = code.match(
/<template>([\s\S]*)<\/template>/
)?.[1];
if (templateContent1) {
const scriptInTemplate = templateContent1.match(/<script\b[^>]*>/i);
if (scriptInTemplate) {
const lineNumber = templateContent1
.slice(0, scriptInTemplate.index)
.split("\n").length;
const column =
scriptInTemplate.index -
templateContent1.lastIndexOf("\n", scriptInTemplate.index);
throw {
message: "Script tags are not allowed inside template section",
line: lineNumber,
column: column,
};
}
}
// Check for root level template and script tags
const rootTemplateCount = (
code.match(/^[\s\S]*<template>[\s\S]*<\/template>/g) || []
).length;
const rootScriptCount = (
code.match(/^[\s\S]*<script>[\s\S]*<\/script>/g) || []
).length;
if (rootTemplateCount > 1 || rootScriptCount > 1) {
throw new Error(
"Vue components must have only one root <template> and one <script> tag"
);
}
// Extract template content for further validation
const templateContent2 = code.match(
/<template>([\s\S]*)<\/template>/
)?.[1];
if (templateContent2) {
const tagStack = [];
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9:-]*)\s*([^>]*?)(\/?)>/g;
let match;
let lineNumber = 1;
let lastIndex = 0;
while ((match = tagRegex.exec(templateContent2)) !== null) {
const [fullTag, tagName, attributes, selfClosing] = match;
// Calculate line number
lineNumber += (
templateContent2.slice(lastIndex, match.index).match(/\n/g) || []
).length;
lastIndex = match.index;
// Skip comments
if (templateContent2.slice(match.index).startsWith("<!--")) {
const commentEnd = templateContent2.indexOf("-->", match.index);
if (commentEnd !== -1) {
tagRegex.lastIndex = commentEnd + 3;
continue;
}
}
if (!fullTag.endsWith(">")) {
throw {
message: `Malformed tag found: ${fullTag}`,
line: lineNumber,
column:
match.index - templateContent2.lastIndexOf("\n", match.index),
};
}
if (selfClosing || fullTag.endsWith("/>")) continue;
if (!fullTag.startsWith("</")) {
tagStack.push({
name: tagName,
line: lineNumber,
column:
match.index - templateContent2.lastIndexOf("\n", match.index),
});
} else {
if (tagStack.length === 0) {
throw {
message: `Unexpected closing tag </${tagName}> found without matching opening tag`,
line: lineNumber,
column:
match.index - templateContent2.lastIndexOf("\n", match.index),
};
}
const lastTag = tagStack[tagStack.length - 1];
if (lastTag.name !== tagName) {
throw {
message: `Mismatched tags: expected closing tag for "${lastTag.name}" but found "${tagName}"`,
line: lineNumber,
column:
match.index - templateContent2.lastIndexOf("\n", match.index),
};
}
tagStack.pop();
}
}
if (tagStack.length > 0) {
const unclosedTag = tagStack[tagStack.length - 1];
throw {
message: `Unclosed tag: ${unclosedTag.name}`,
line: unclosedTag.line,
column: unclosedTag.column,
};
}
}
return true;
};
try {
validateTemplateStructure(code);
} catch (structureError) {
return {
statusCode: 400,
message: "Template Structure Error",
data: {
message: structureError.message,
line: structureError.line || 1,
column: structureError.column || 0,
},
};
}
// ESLint configuration
const eslint = new ESLint({
overrideConfig: {
extends: ["plugin:vue/vue3-recommended"],
parserOptions: {
parser: "espree",
ecmaVersion: 2022,
sourceType: "module",
},
},
useEslintrc: false,
});
const results = await eslint.lintText(code);
if (results[0].messages.length > 0) {
const message = results[0].messages[0];
if (message.fatal === true) {
return {
statusCode: 400,
message: "Bad Linter Test",
data: {
message: message.message,
line: message.line,
column: message.column,
},
};
}
return {
statusCode: 200,
message: "Good Linter test",
data: {
message: message.message,
line: message.line,
column: message.column,
},
};
}
return {
statusCode: 200,
message: "Code validation passed",
};
} catch (error) {
console.log(error);
return {
statusCode: 500,
message: "Internal Server Error",
error: error.message,
};
}
});

View File

@@ -0,0 +1,27 @@
import prettier from "prettier";
export default defineEventHandler(async (event) => {
const body = await readBody(event);
try {
if (body.code === undefined) {
return {
statusCode: 400,
message: "Bad Request",
};
}
const code = prettier.format(body.code, { semi: false, parser: "vue" });
return {
statusCode: 200,
message: "Code successfully formatted",
data: code,
};
} catch (error) {
return {
statusCode: 500,
message: "Internal Server Error",
};
}
});

View File

@@ -0,0 +1,22 @@
import fs from "fs";
import path from "path";
export default defineEventHandler(async (event) => {
const body = await readBody(event);
try {
// Overwrite vue code from path in body with new code
const filePath = path.join(process.cwd() + "/pages/", body.path + ".vue");
fs.writeFileSync(filePath, body.code, "utf8");
return {
statusCode: 200,
message: "Code successfully saved",
};
} catch (error) {
return {
statusCode: 500,
message: "Internal Server Error",
};
}
});

View File

@@ -0,0 +1,29 @@
import templates from "@@/templates/index.js";
export default defineEventHandler(async (event) => {
try {
const query = await getQuery(event);
const id = query.id;
if (!templates || templates?.data.length == 0)
return {
statusCode: 404,
message: "Template data not found",
};
// Search template by id
const template = templates.data.find((item) => item.id == id);
return {
statusCode: 200,
message: "Template data successfully fetched",
data: template,
};
} catch (error) {
console.log(error);
return {
statusCode: 500,
message: "Internal server error",
};
}
});

View File

@@ -0,0 +1,57 @@
import fs from "fs";
import path from "path";
import templates from "@@/templates/index.js";
export default defineEventHandler(async (event) => {
try {
const query = await getQuery(event);
const pagePath = query.path;
const templateId = query.templateId;
// Get pageName path and check if it exists
const filePath = path.join(process.cwd() + "/pages/", pagePath + ".vue");
console.log(filePath);
if (!fs.existsSync(filePath)) {
return {
statusCode: 500,
message: "File path not found",
};
}
// Get template id from templates
const template = templates.data.find(
(template) => template.id === templateId
);
// Get template path and check if it exists
const templatePath = path.join(
process.cwd() + "/templates/",
template.filename + ".vue"
);
if (!fs.existsSync(templatePath)) {
return {
statusCode: 500,
message: "Template not found",
};
}
// Get template code
const templateCode = fs.readFileSync(templatePath, "utf8");
// Write template code to pageName path
fs.writeFileSync(filePath, templateCode, "utf8");
return {
statusCode: 200,
message: "Template successfully imported",
};
} catch (error) {
console.log(error);
return {
statusCode: 500,
message: "Internal server error",
};
}
});

View File

@@ -0,0 +1,23 @@
import templates from "@@/templates/index.js";
export default defineEventHandler(async (event) => {
try {
if (!templates || templates?.data.length == 0)
return {
statusCode: 404,
message: "Template data not found",
};
return {
statusCode: 200,
message: "List template data successfully fetched",
data: templates.data,
};
} catch (error) {
console.log(error);
return {
statusCode: 500,
message: "Internal server error",
};
}
});

View File

@@ -0,0 +1,23 @@
import templates from "@@/templates/index.js";
export default defineEventHandler(async (event) => {
try {
if (!templates || templates?.tags.length == 0)
return {
statusCode: 404,
message: "Template tags not found",
};
return {
statusCode: 200,
message: "List template tags successfully fetched",
data: templates.tags,
};
} catch (error) {
console.log(error);
return {
statusCode: 500,
message: "Internal server error",
};
}
});

View File

View File

@@ -0,0 +1,51 @@
import fs from "fs";
import path from "path";
export default defineEventHandler(async (event) => {
const body = await readBody(event);
try {
// Check if last character is not slash
if (body.formData.path.slice(-1) != "/") {
body.formData.path = body.formData.path + "/";
}
// Check if the path already exists
if (fs.existsSync(path.join(process.cwd(), "pages", body.formData.path))) {
return {
statusCode: 500,
message: "Path already exists. Please choose another path.",
};
}
// Create new file path with index.vue
const newFilePath = path.join(
process.cwd(),
"pages",
body.formData.path,
"index.vue"
);
// Create the folder if doesn't exist
fs.mkdirSync(path.dirname(newFilePath), { recursive: true });
// Create template content
const templateContent = buildNuxtTemplate({
title: body.formData.title || body.formData.name,
name: body.formData.name,
});
// Write file with template
fs.writeFileSync(newFilePath, templateContent);
return {
statusCode: 200,
message: "Menu successfully added!",
};
} catch (error) {
return {
statusCode: 500,
message: error.message,
};
}
});

View File

@@ -0,0 +1,61 @@
import fs from "fs";
import path from "path";
import navigationData from "~/navigation";
export default defineEventHandler(async (event) => {
const body = await readBody(event);
try {
// Get file path
const filePath = path.join(process.cwd() + "/pages/", body.filePath);
// Delete path
fs.rmSync(filePath, { recursive: true, force: true });
// Remove menu from navigation
removeMenuFromNavigation(body.filePath);
return {
statusCode: 200,
message: "Menu successfully deleted and removed from navigation!",
};
} catch (error) {
console.error(error);
return {
statusCode: 500,
message: error.message,
};
}
});
function removeMenuFromNavigation(menuPath) {
const removeMenuItem = (items) => {
for (let i = 0; i < items.length; i++) {
if (items[i].path === menuPath) {
items.splice(i, 1);
return true;
}
if (items[i].child && items[i].child.length > 0) {
if (removeMenuItem(items[i].child)) {
return true;
}
}
}
return false;
};
navigationData.forEach((section) => {
if (section.child) {
removeMenuItem(section.child);
}
});
// Save updated navigation data
const navigationFilePath = path.join(process.cwd(), "navigation", "index.js");
const navigationContent = `export default ${JSON.stringify(
navigationData,
null,
2
)};`;
fs.writeFileSync(navigationFilePath, navigationContent, "utf8");
}

View File

@@ -0,0 +1,65 @@
import fs from "fs";
import path from "path";
export default defineEventHandler(async (event) => {
const body = await readBody(event);
// Normalize paths
const oldPath = body.filePath.endsWith("/")
? body.filePath
: body.filePath + "/";
const newPath = body.formData.path.endsWith("/")
? body.formData.path
: body.formData.path + "/";
// Get file paths
const oldFilePath = path.join(process.cwd(), "pages", oldPath, "index.vue");
const newFilePath = path.join(process.cwd(), "pages", newPath, "index.vue");
try {
// Create template content
const templateContent = buildNuxtTemplate({
title: body.formData.title || body.formData.name,
name: body.formData.name,
});
if (oldPath !== newPath) {
// Create the new folder if it doesn't exist
fs.mkdirSync(path.dirname(newFilePath), { recursive: true });
// Write the new file
fs.writeFileSync(newFilePath, templateContent);
// Delete the old file
fs.unlinkSync(oldFilePath);
// Remove empty directories
let dirToCheck = path.dirname(oldFilePath);
while (dirToCheck !== path.join(process.cwd(), "pages")) {
if (fs.readdirSync(dirToCheck).length === 0) {
fs.rmdirSync(dirToCheck);
dirToCheck = path.dirname(dirToCheck);
} else {
break;
}
}
} else {
// Update existing file
fs.writeFileSync(oldFilePath, templateContent);
}
return {
statusCode: 200,
message:
oldPath !== newPath
? "Menu successfully moved and updated"
: "Menu successfully updated",
};
} catch (error) {
console.error(error);
return {
statusCode: 500,
message: error.message,
};
}
});

View File

@@ -0,0 +1,5 @@
export default defineEventHandler(async (event) => {
const body = await readBody(event);
// try {
});

View File

@@ -0,0 +1,25 @@
import fs from "fs";
import path from "path";
export default defineEventHandler(async (event) => {
const body = await readBody(event);
try {
// get menu path
const menuPath = path.join(process.cwd() + "/navigation/", "index.js");
fs.writeFileSync(
menuPath,
`export default ${JSON.stringify(body.menuData, null, 2)}`
);
return {
statusCode: 200,
message: "Menu successfully saved",
};
} catch (error) {
return {
statusCode: 500,
message: error.message,
};
}
});

View File

@@ -0,0 +1,33 @@
export default defineEventHandler(async (event) => {
try {
const roles = await prisma.role.findMany({
select: {
roleID: true,
roleName: true,
},
where: {
roleStatus: {
not: "DELETED",
},
},
});
if (roles) {
return {
statusCode: 200,
message: "Roles successfully fetched",
data: roles,
};
} else {
return {
statusCode: 404,
message: "No Roles found",
};
}
} catch (error) {
return {
statusCode: 500,
message: error.message,
};
}
});

View File

@@ -0,0 +1,33 @@
export default defineEventHandler(async (event) => {
try {
const users = await prisma.user.findMany({
select: {
userID: true,
userUsername: true,
},
where: {
userStatus: {
not: "DELETED",
},
},
});
if (users) {
return {
statusCode: 200,
message: "Users successfully fetched",
data: users,
};
} else {
return {
statusCode: 404,
message: "No Users found",
};
}
} catch (error) {
return {
statusCode: 500,
message: error.message,
};
}
});

View File

@@ -0,0 +1,36 @@
export default defineEventHandler(async (event) => {
try {
const { tableName } = getQuery(event);
if (!tableName) {
return {
statusCode: 400,
message: "Table name is required",
};
}
// const JSONSchemaTable = getPrismaSchemaTable(tableName);
// console.log(JSONSchemaTable);
const getData = await prisma.$queryRawUnsafe(`SELECT * FROM ${tableName}`);
if (getData.length === 0) {
return {
statusCode: 404,
message: "Data not found",
};
}
return {
statusCode: 200,
message: "Data successfully fetched",
data: getData,
};
} catch (error) {
console.log(error.message);
return {
statusCode: 500,
message: "Internal Server Error",
};
}
});

View File

@@ -0,0 +1,37 @@
import fs from "fs";
import path from "path";
export default defineEventHandler(async (event) => {
try {
const { type } = getQuery(event);
if (!type) {
return {
statusCode: 400,
message: "Type is required",
};
}
if (type !== "table" && type !== "field") {
return {
statusCode: 400,
message: "Invalid type",
};
}
let schema = null;
if (type == "table") schema = getPrismaSchemaTable();
return {
statusCode: 200,
message: "Schema successfully fetched",
data: schema,
};
} catch (error) {
console.log(error.message);
return {
statusCode: 500,
message: "Internal Server Error",
};
}
});

View File

@@ -0,0 +1,34 @@
import { exec } from "node:child_process";
export default defineEventHandler(async (event) => {
try {
let error = false;
// Run command yarn prisma studio
exec("npx prisma studio", (error, stdout, stderr) => {
if (error) {
console.error(`exec error: ${error}`);
error = true;
}
console.log(`stdout: ${stdout}`);
console.error(`stderr: ${stderr}`);
});
if (error)
return {
statusCode: 500,
message: "Internal Server Error",
};
return {
statusCode: 200,
message: "Prisma Studio successfully launched",
};
} catch (error) {
console.log(error.message);
return {
statusCode: 500,
message: "Internal Server Error",
};
}
});

View File

@@ -0,0 +1,106 @@
{
"columnTypes": [
{
"group": "Numbers",
"options": [
"TINYINT",
"SMALLINT",
"MEDIUMINT",
"INT",
"BIGINT",
"DECIMAL",
"FLOAT",
"DOUBLE"
]
},
{
"group": "Date and Time",
"options": ["DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR"]
},
{
"group": "Strings",
"options": [
"CHAR",
"VARCHAR",
"TINYTEXT",
"TEXT",
"MEDIUMTEXT",
"LONGTEXT",
"JSON"
]
},
{
"group": "Lists",
"options": ["ENUM", "SET"]
},
{
"group": "Binary",
"options": [
"BIT",
"BINARY",
"VARBINARY",
"TINYBLOB",
"BLOB",
"MEDIUMBLOB",
"LONGBLOB"
]
},
{
"group": "Geometry",
"options": [
"GEOMETRY",
"POINT",
"LINESTRING",
"POLYGON",
"MULTIPOINT",
"MULTILINESTRING",
"MULTIPOLYGON",
"GEOMETRYCOLLECTION"
]
}
],
"dataTypes": [
"",
"INT",
"TINYINT",
"SMALLINT",
"MEDIUMINT",
"BIGINT",
"DECIMAL",
"NUMERIC",
"FLOAT",
"DOUBLE",
"CHAR",
"VARCHAR",
"TEXT",
"ENUM",
"SET",
"BINARY",
"VARBINARY",
"BLOB",
"DATE",
"TIME",
"DATETIME",
"TIMESTAMP",
"YEAR",
"BOOL",
"BOOLEAN",
"JSON",
"JSONB",
"XML",
"UUID",
"GEOMETRY",
"POINT",
"LINESTRING",
"POLYGON"
],
"tableField": [
"name",
"type",
"length",
"defaultValue",
"nullable",
"primaryKey",
"actions"
]
}

View File

@@ -0,0 +1,81 @@
import fileConfig from "./configuration.json";
export default defineEventHandler(async (event) => {
try {
// read configuration file if it exists and return error if it doesn't
if (!fileConfig) {
return {
statusCode: 404,
message: "Configuration file not found",
};
}
// Get all tables with primary key
const tables = await getAllTableWithPK();
if (!tables) {
return {
statusCode: 500,
message: "Please check your database connection",
};
}
// Remove columnTypes [{"group": "Foreign Keys", "options": [{"label": "TABLE_NAME (COLUMN_NAME)", "value": "TABLE_NAME"}]}] from fileconfig before appending
fileConfig.columnTypes = fileConfig.columnTypes.filter(
(columnType) => columnType.group !== "Foreign Keys"
);
// Append columnTypes from fileconfig with tables
fileConfig.columnTypes.push({
...tables,
});
return {
statusCode: 200,
message: "Configuration file successfully loaded",
data: fileConfig,
};
} catch (error) {
console.log(error.message);
return {
statusCode: 500,
message: "Internal Server Error",
};
}
});
async function getAllTableWithPK() {
try {
const tables = await prisma.$queryRaw` SELECT
table_name,
column_name
FROM
information_schema.columns
WHERE table_schema = DATABASE()
AND column_key = 'PRI'`;
if (!tables) return false;
// Reformat to {group: "table_name", options: [{label: "TABLE_NAME (COLUMN_NAME)", value: "TABLE_NAME"}]}
const remapTables = tables.reduce((acc, table) => {
const group = "Foreign Keys";
const option = {
label: `${table.TABLE_NAME} (${table.COLUMN_NAME})`,
value: `[[${table.TABLE_NAME}]]`,
};
const existingGroup = acc.find((item) => item.group === group);
if (existingGroup) {
existingGroup.options.push(option);
} else {
acc.push({ group, options: [option] });
}
return acc;
}, []);
return remapTables[0];
} catch (error) {
console.log(error.message);
return false;
}
}

View File

@@ -0,0 +1,171 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
import os from "os";
export default defineEventHandler(async (event) => {
try {
const { tableName, tableSchema, autoIncrementColumn } =
await readBody(event);
if (!tableName || !tableSchema) {
return {
statusCode: 400,
message: "Bad Request",
};
}
// Create Table
const isTableCreated = await createTable(
tableName,
tableSchema,
autoIncrementColumn
);
if (isTableCreated.statusCode !== 200)
return {
statusCode: 500,
message: isTableCreated.message,
};
// Run Prisma Command
const isPrismaCommandRun = await runPrismaCommand();
if (!isPrismaCommandRun)
return {
statusCode: 500,
message: "Prisma Command Failed",
};
return {
statusCode: 200,
message: "Table Created",
};
} catch (error) {
console.log(error);
return {
statusCode: 500,
message: "Internal Server Error",
};
}
});
async function createTable(tableName, tableSchema) {
try {
let rawSchema = ``;
for (let i = 0; i < tableSchema.length; i++) {
const column = tableSchema[i];
// Sanitize rawSchema
if (column.type.includes("[[") && column.type.includes("]]")) {
const FKTableName = column.type.replace("[[", "").replace("]]", "");
const primaryKey = await prisma.$queryRawUnsafe(
"SHOW COLUMNS from " + FKTableName + " where `Key` = 'PRI'"
);
rawSchema += `${column.name} INT NOT NULL, FOREIGN KEY (${column.name}) REFERENCES ${FKTableName}(${primaryKey[0].Field})`;
} else {
rawSchema += `${column.name}
${column.type}${column.length ? "(" + column.length + ")" : ""}
${column.defaultValue ? " DEFAULT " + column.defaultValue : ""}
${column.nullable ? " NULL" : " NOT NULL "}
${column.primaryKey ? " PRIMARY KEY AUTO_INCREMENT" : ""}`;
}
if (i < tableSchema.length - 1) rawSchema += ", ";
}
const sqlStatement = `CREATE TABLE ${tableName} (${rawSchema})`;
console.log(sqlStatement);
const createTable = await prisma.$queryRawUnsafe(sqlStatement);
if (!createTable)
return {
statusCode: 500,
message: "Table Creation Failed",
};
return {
statusCode: 200,
message: "Table Created",
};
} catch (error) {
console.log(error.message);
// Get Message
if (error.message.includes("already exists")) {
return {
statusCode: 500,
message: `Table '${tableName}' already exists!`,
};
}
if (error.message.includes("1064")) {
return {
statusCode: 500,
message: "Please ensure the SQL syntax is correct!",
};
}
return {
statusCode: 500,
message: "Table Creation Failed",
};
}
}
async function runPrismaCommand() {
try {
console.log("---------- Run Prisma Command ----------");
const __dirname = dirname(fileURLToPath(import.meta.url));
const directory = resolve(__dirname, "../..");
// Command to execute
const command = "npx prisma db pull && npx prisma generate";
// Determine the appropriate shell command based on the platform
let shellCommand;
let spawnOptions;
switch (os.platform()) {
case "win32":
shellCommand = `Start-Process cmd -ArgumentList '/c cd "${directory}" && ${command}' -Verb RunAs`;
spawnOptions = {
shell: "powershell.exe",
args: ["-Command", shellCommand],
};
break;
case "darwin":
case "linux":
shellCommand = `cd "${directory}" && ${command}`;
spawnOptions = {
shell: "sh",
args: ["-c", shellCommand],
};
break;
default:
console.error("Unsupported platform:", os.platform());
return false;
}
// Spawn child process using the appropriate shell command
const childProcess = spawn(spawnOptions.shell, spawnOptions.args, {
stdio: "inherit",
});
// Listen for child process events
return new Promise((resolve, reject) => {
childProcess.on("close", (code) => {
if (code === 0) {
console.log("Prisma commands executed successfully");
resolve(true);
} else {
console.error(`Child process exited with code ${code}`);
reject(new Error(`Child process exited with code ${code}`));
}
});
});
} catch (error) {
console.error("Error running Prisma commands:", error);
return false;
}
}

View File

@@ -0,0 +1,90 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
import os from "os";
export default defineEventHandler(async (event) => {
const tableName = event.context.params.table;
try {
// Drop the table
await prisma.$executeRawUnsafe(`DROP TABLE IF EXISTS ${tableName}`);
// Run Prisma Command to update the schema
const isPrismaCommandRun = await runPrismaCommand();
if (!isPrismaCommandRun) {
return {
statusCode: 500,
message: "Prisma Command Failed after table deletion",
};
}
return {
statusCode: 200,
message: `Table '${tableName}' has been successfully deleted.`,
};
} catch (error) {
console.error("Error deleting table:", error);
return {
statusCode: 500,
message: `Failed to delete table '${tableName}'. Error: ${error.message}`,
};
}
});
async function runPrismaCommand() {
try {
console.log("---------- Run Prisma Command ----------");
const __dirname = dirname(fileURLToPath(import.meta.url));
const directory = resolve(__dirname, "../..");
// Command to execute
const command = "npx prisma db pull && npx prisma generate";
// Determine the appropriate shell command based on the platform
let shellCommand;
let spawnOptions;
switch (os.platform()) {
case "win32":
shellCommand = `Start-Process cmd -ArgumentList '/c cd "${directory}" && ${command}' -Verb RunAs`;
spawnOptions = {
shell: "powershell.exe",
args: ["-Command", shellCommand],
};
break;
case "darwin":
case "linux":
shellCommand = `cd "${directory}" && ${command}`;
spawnOptions = {
shell: "sh",
args: ["-c", shellCommand],
};
break;
default:
console.error("Unsupported platform:", os.platform());
return false;
}
// Spawn child process using the appropriate shell command
const childProcess = spawn(spawnOptions.shell, spawnOptions.args, {
stdio: "inherit",
});
// Listen for child process events
return new Promise((resolve, reject) => {
childProcess.on("close", (code) => {
if (code === 0) {
console.log("Prisma commands executed successfully");
resolve(true);
} else {
console.error(`Child process exited with code ${code}`);
reject(new Error(`Child process exited with code ${code}`));
}
});
});
} catch (error) {
console.error("Error running Prisma commands:", error);
return false;
}
}

View File

@@ -0,0 +1,113 @@
export default defineEventHandler(async (event) => {
try {
const { tableName } = getQuery(event);
if (!tableName) {
return {
statusCode: 400,
message: "Table name is required",
};
}
const result = await prisma.$queryRaw`SELECT DATABASE() AS db_name`;
// console.log(result[0].db_name);
if (result.length === 0) {
return {
statusCode: 500,
message: "Please check your database connection",
};
}
let sqlRaw = ` SELECT
c.COLUMN_NAME,
c.DATA_TYPE,
c.CHARACTER_MAXIMUM_LENGTH,
c.COLUMN_DEFAULT,
c.IS_NULLABLE,
c.COLUMN_KEY,
kcu.REFERENCED_TABLE_NAME,
kcu.REFERENCED_COLUMN_NAME
FROM
INFORMATION_SCHEMA.COLUMNS c
LEFT JOIN
INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu ON
c.TABLE_SCHEMA = kcu.TABLE_SCHEMA AND
c.TABLE_NAME = kcu.TABLE_NAME AND
c.COLUMN_NAME = kcu.COLUMN_NAME
WHERE
c.TABLE_SCHEMA = '${result[0].db_name}' AND
c.TABLE_NAME = '${tableName}';`;
// console.log(sqlRaw);
const getTableDetails = await prisma.$queryRawUnsafe(sqlRaw);
// console.log(getTableDetails);
/*
[{
"actions": "",
"defaultValue": "",
"length": "",
"name": "PID",
"nullable": "",
"primaryKey": true,
"type": "INT"
},
{
"actions": "",
"defaultValue": "",
"length": "",
"name": "Pproduct",
"nullable": true,
"primaryKey": "",
"type": "VARCHAR"
},
{
"actions": "",
"defaultValue": "",
"length": "",
"name": "userID",
"nullable": "",
"primaryKey": "",
"type": "[[user]]"
}]
*/
let tableDetailsData = [];
// Loop through the result and convert bigInt to number
for (let i = 0; i < getTableDetails.length; i++) {
const table = getTableDetails[i];
tableDetailsData.push({
name: table.COLUMN_NAME,
type: table.REFERENCED_TABLE_NAME
? `[[${table.REFERENCED_TABLE_NAME}]]`
: table.DATA_TYPE.toUpperCase(),
length: bigIntToNumber(table.CHARACTER_MAXIMUM_LENGTH),
defaultValue: table.COLUMN_DEFAULT,
nullable: table.IS_NULLABLE === "YES",
primaryKey: table.COLUMN_KEY === "PRI",
actions: {},
});
}
return {
statusCode: 200,
message: "Success",
data: tableDetailsData,
};
} catch (error) {
console.log(error.message);
return {
statusCode: 500,
message: "Internal Server Error",
};
}
});
function bigIntToNumber(bigInt) {
if (bigInt === null) return null;
return Number(bigInt.toString());
}

View File

@@ -0,0 +1,191 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "url";
import { dirname, resolve } from "path";
import os from "os";
export default defineEventHandler(async (event) => {
try {
const { tableName, tableSchema, autoIncrementColumn } =
await readBody(event);
if (!tableName || !tableSchema) {
return {
statusCode: 400,
message: "Bad Request",
};
}
// Get existing table structure
const existingColumns = await prisma.$queryRaw`
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_KEY
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ${tableName}
`;
// Compare and modify table structure
for (const column of tableSchema) {
const existingColumn = existingColumns.find(
(c) => c.COLUMN_NAME === column.name
);
if (existingColumn) {
// Modify existing column
await modifyColumn(tableName, column, existingColumn);
} else {
// Add new column
await addColumn(tableName, column);
}
}
// Remove columns that are not in the new schema
for (const existingColumn of existingColumns) {
if (!tableSchema.find((c) => c.name === existingColumn.COLUMN_NAME)) {
await removeColumn(tableName, existingColumn.COLUMN_NAME);
}
}
// Update auto-increment column if necessary
if (autoIncrementColumn) {
await updateAutoIncrement(tableName, autoIncrementColumn);
}
// Run Prisma Command to update the schema
const isPrismaCommandRun = await runPrismaCommand();
if (!isPrismaCommandRun) {
return {
statusCode: 500,
message: "Prisma Command Failed",
};
}
return {
statusCode: 200,
message: "Table modified successfully",
};
} catch (error) {
console.error(error);
return {
statusCode: 500,
message: "Internal Server Error",
};
}
});
async function modifyColumn(tableName, newColumn, existingColumn) {
let alterStatement = `ALTER TABLE ${tableName} MODIFY COLUMN ${newColumn.name} ${newColumn.type}`;
if (newColumn.length) {
alterStatement += `(${newColumn.length})`;
}
alterStatement += newColumn.nullable ? " NULL" : " NOT NULL";
if (newColumn.defaultValue) {
alterStatement += ` DEFAULT ${newColumn.defaultValue}`;
}
await prisma.$executeRawUnsafe(alterStatement);
}
async function addColumn(tableName, column) {
let alterStatement = `ALTER TABLE ${tableName} ADD COLUMN ${column.name} ${column.type}`;
if (column.length) {
alterStatement += `(${column.length})`;
}
alterStatement += column.nullable ? " NULL" : " NOT NULL";
if (column.defaultValue) {
alterStatement += ` DEFAULT ${column.defaultValue}`;
}
await prisma.$executeRawUnsafe(alterStatement);
}
async function removeColumn(tableName, columnName) {
await prisma.$executeRawUnsafe(
`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`
);
}
async function updateAutoIncrement(tableName, autoIncrementColumn) {
await prisma.$executeRawUnsafe(
`ALTER TABLE ${tableName} MODIFY ${autoIncrementColumn} INT AUTO_INCREMENT`
);
}
async function runPrismaCommand(retries = 3) {
try {
console.log("---------- Run Prisma Command ----------");
const __dirname = dirname(fileURLToPath(import.meta.url));
const directory = resolve(__dirname, "../..");
// Command to execute
const command = "npx prisma db pull && npx prisma generate";
// Determine the appropriate shell command based on the platform
let shellCommand;
let spawnOptions;
switch (os.platform()) {
case "win32":
shellCommand = `Start-Process cmd -ArgumentList '/c cd "${directory}" && ${command}' -Verb RunAs`;
spawnOptions = {
shell: "powershell.exe",
args: ["-Command", shellCommand],
};
break;
case "darwin":
case "linux":
shellCommand = `cd "${directory}" && ${command}`;
spawnOptions = {
shell: "sh",
args: ["-c", shellCommand],
};
break;
default:
console.error("Unsupported platform:", os.platform());
return false;
}
// Spawn child process using the appropriate shell command
const childProcess = spawn(spawnOptions.shell, spawnOptions.args, {
stdio: "inherit",
});
// Listen for child process events
return new Promise((resolve, reject) => {
childProcess.on("close", (code) => {
if (code === 0) {
console.log("Prisma commands executed successfully");
resolve(true);
} else {
console.error(`Child process exited with code ${code}`);
reject(new Error(`Child process exited with code ${code}`));
}
});
});
} catch (error) {
console.error("Error running Prisma commands:", error);
return false;
}
}
function spawnCommand(command, args, cwd) {
return new Promise((resolve, reject) => {
const process = spawn(command, args, {
cwd,
stdio: "inherit",
shell: true,
});
process.on("close", (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Command failed with exit code ${code}`));
}
});
});
}

View File

@@ -0,0 +1,87 @@
export default defineEventHandler(async (event) => {
const body = await readBody(event);
try {
// Check if the role already exists
const allRole = await prisma.role.findMany({
where: {
roleStatus: "ACTIVE",
},
});
const roleExist = allRole.find((role) => {
return role?.roleName.toLowerCase() === body?.name.toLowerCase();
});
if (roleExist) {
return {
statusCode: 400,
message: "Role already exists",
};
}
// add new role
const role = await prisma.role.create({
data: {
roleName: body.name,
roleDescription: body.description || "",
roleStatus: "ACTIVE",
roleCreatedDate: new Date(),
},
});
if (role) {
// Add User to the role if users are provided
if (body.users && Array.isArray(body.users)) {
const userRoles = await Promise.all(
body.users.map(async (el) => {
const user = await prisma.user.findFirst({
where: {
userUsername: el.value,
},
});
if (user) {
return prisma.userrole.create({
data: {
userRoleUserID: user.userID,
userRoleRoleID: role.roleID,
userRoleCreatedDate: new Date(),
},
});
}
return null;
})
);
const validUserRoles = userRoles.filter(Boolean);
return {
statusCode: 200,
message: "Role successfully added!",
data: {
role,
assignedUsers: validUserRoles.length,
totalUsers: body.users.length,
},
};
}
return {
statusCode: 200,
message: "Role successfully added!",
data: { role },
};
} else {
return {
statusCode: 500,
message: "Something went wrong! Please contact your administrator.",
};
}
} catch (error) {
return {
statusCode: 500,
message: error.message,
};
}
});

View File

@@ -0,0 +1,28 @@
export default defineEventHandler(async (event) => {
const body = await readBody(event);
try {
// Delete user
const user = await prisma.role.updateMany({
where: {
roleID: body.id,
},
data: {
roleStatus: "DELETED",
roleModifiedDate: new Date(),
},
});
if (user) {
return {
statusCode: 200,
message: "User deleted successfully",
};
}
} catch (error) {
return {
statusCode: 500,
message: error.message,
};
}
});

View File

@@ -0,0 +1,77 @@
export default defineEventHandler(async (event) => {
const body = await readBody(event);
try {
// Edit role
const role = await prisma.role.update({
where: {
roleID: body.id,
},
data: {
roleName: body.name,
roleDescription: body.description,
roleModifiedDate: new Date(),
},
});
if (role) {
// Delete all user roles for this role
await prisma.userrole.deleteMany({
where: {
userRoleRoleID: body.id,
},
});
// Add User to the role if users are provided
if (body.users && Array.isArray(body.users)) {
const userRoles = await Promise.all(
body.users.map(async (el) => {
const user = await prisma.user.findFirst({
where: {
userUsername: el.value,
},
});
if (user) {
return prisma.userrole.create({
data: {
userRoleUserID: user.userID,
userRoleRoleID: body.id,
userRoleCreatedDate: new Date(),
},
});
}
return null;
})
);
const validUserRoles = userRoles.filter(Boolean);
return {
statusCode: 200,
message: "Role successfully edited!",
data: {
role,
assignedUsers: validUserRoles.length,
totalUsers: body.users.length,
},
};
}
return {
statusCode: 200,
message: "Role successfully edited!",
data: { role },
};
} else {
return {
statusCode: 500,
message: "Something went wrong! Please contact your administrator.",
};
}
} catch (error) {
return {
statusCode: 500,
message: error.message,
};
}
});

View File

@@ -0,0 +1,59 @@
export default defineEventHandler(async (event) => {
// Get all users from database
try {
const roles = await prisma.role.findMany({
select: {
roleID: true,
roleName: true,
roleDescription: true,
roleStatus: true,
roleCreatedDate: true,
roleModifiedDate: true,
},
where: {
roleStatus: {
not: "DELETED",
},
roleID: {
not: 1,
},
},
});
if (roles) {
for (let i = 0; i < roles.length; i++) {
let userOfRole = await prisma.userrole.findMany({
select: {
user: {
select: {
userUsername: true,
},
},
},
where: {
userRoleRoleID: roles[i].roleID,
},
});
roles[i].users = userOfRole;
}
return {
statusCode: 200,
message: "Roles successfully fetched",
data: roles,
};
} else {
return {
statusCode: 404,
message: "No Roles found",
};
}
} catch (error) {
return {
statusCode: 500,
message: error.message,
};
}
});

View File

@@ -0,0 +1,138 @@
import sha256 from "crypto-js/sha256.js";
export default defineEventHandler(async (event) => {
const body = await readBody(event);
const password = sha256("abc123").toString();
let secretKey = generateSecretKey();
try {
// Get user from database
const allUser = await prisma.user.findMany({
where: {
userStatus: "ACTIVE",
},
});
// Check if the user already exists
const userExist = allUser.find((user) => {
return user?.userUsername.toLowerCase() === body?.username.toLowerCase();
});
if (userExist)
return {
statusCode: 400,
message: "Username already exists",
};
// Validate secret key
do {
secretKey = generateSecretKey();
} while (
allUser.find((user) => {
return user?.userSecretKey === secretKey;
})
);
// Add New User
const user = await prisma.user.create({
data: {
userSecretKey: secretKey,
userUsername: body.username,
userPassword: password,
userFullName: body?.fullname || "",
userEmail: body?.email || "",
userPhone: body?.phone || "",
userStatus: "ACTIVE",
userCreatedDate: new Date(),
},
});
if (user) {
// Add user roles if provided
if (body.role && Array.isArray(body.role)) {
const userRoles = await Promise.all(
body.role.map(async (role) => {
const existingRole = await prisma.role.findFirst({
where: {
roleID: role.value,
},
});
if (existingRole) {
return prisma.userrole.create({
data: {
userRoleUserID: user.userID,
userRoleRoleID: role.value,
userRoleCreatedDate: new Date(),
},
});
}
return null;
})
);
const validUserRoles = userRoles.filter(Boolean);
return {
statusCode: 200,
message: "User successfully added!",
data: {
user,
assignedRoles: validUserRoles.length,
totalRoles: body.role.length,
},
};
}
return {
statusCode: 200,
message: "User successfully added!",
data: { user },
};
} else {
return {
statusCode: 500,
message: "Something went wrong! Please contact your administrator.",
};
}
} catch (error) {
return {
statusCode: 500,
message: error.message,
};
}
});
function generateSecretKey() {
// Generate Secret Key number and alphabet. Format : xxxx-xxxx-xxxx-xxxx
let secretKey = "";
let possible =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (let i = 0; i < 4; i++) {
for (let j = 0; j < 4; j++) {
secretKey += possible.charAt(Math.floor(Math.random() * possible.length));
}
if (i < 3) {
secretKey += "-";
}
}
return secretKey;
}
async function checkRoleID(roleID) {
const role = await prisma.role.findFirst({
where: {
roleID: roleID,
},
});
if (!role) {
return false;
} else {
return true;
}
}

View File

@@ -0,0 +1,28 @@
export default defineEventHandler(async (event) => {
const body = await readBody(event);
try {
// Delete user
const user = await prisma.user.updateMany({
where: {
userUsername: body.username,
},
data: {
userStatus: "DELETED",
userModifiedDate: new Date(),
},
});
if (user) {
return {
statusCode: 200,
message: "User deleted successfully",
};
}
} catch (error) {
return {
statusCode: 500,
message: error.message,
};
}
});

View File

@@ -0,0 +1,86 @@
export default defineEventHandler(async (event) => {
const body = await readBody(event);
try {
// Update user
const user = await prisma.user.updateMany({
where: {
userUsername: body.username,
},
data: {
userFullName: body?.fullname || "",
userEmail: body?.email || "",
userPhone: body?.phone || "",
userStatus: body.status,
userModifiedDate: new Date(),
},
});
if (user.count > 0) {
const getUserID = await prisma.user.findFirst({
where: {
userUsername: body.username,
},
});
if (getUserID) {
// Delete all user roles
await prisma.userrole.deleteMany({
where: {
userRoleUserID: getUserID.userID,
},
});
// Add new user roles
if (body.role && Array.isArray(body.role)) {
const userRoles = await Promise.all(
body.role.map(async (role) => {
const existingRole = await prisma.role.findFirst({
where: {
roleID: role.value,
},
});
if (existingRole) {
return prisma.userrole.create({
data: {
userRoleUserID: getUserID.userID,
userRoleRoleID: role.value,
userRoleCreatedDate: new Date(),
},
});
}
return null;
})
);
const validUserRoles = userRoles.filter(Boolean);
return {
statusCode: 200,
message: "User updated successfully",
data: {
assignedRoles: validUserRoles.length,
totalRoles: body.role.length,
},
};
}
return {
statusCode: 200,
message: "User updated successfully",
};
}
}
return {
statusCode: 404,
message: "User not found",
};
} catch (error) {
return {
statusCode: 500,
message: error.message,
};
}
});

View File

@@ -0,0 +1,60 @@
export default defineEventHandler(async (event) => {
// Get all users from database except userStatus = DELETED
try {
const users = await prisma.user.findMany({
select: {
userID: true,
userUsername: true,
userFullName: true,
userEmail: true,
userPhone: true,
userStatus: true,
userCreatedDate: true,
userModifiedDate: true,
},
where: {
userStatus: {
not: "DELETED",
},
},
});
if (users) {
// Get all roles for each user
for (let i = 0; i < users.length; i++) {
let roleOfUser = await prisma.userrole.findMany({
select: {
role: {
select: {
roleID: true,
roleName: true,
},
},
},
where: {
userRoleUserID: users[i].userID,
},
});
users[i].roles = roleOfUser;
}
return {
statusCode: 200,
message: "Users successfully fetched",
data: users,
};
} else {
return {
statusCode: 404,
message: "No users found",
};
}
} catch (error) {
return {
statusCode: 500,
message: error.message,
};
}
});

View File

@@ -0,0 +1,28 @@
import jwt from "jsonwebtoken";
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
const METABASE_SECRET_KEY = config.metabase.secretKey;
const payload = {
resource: { dashboard: 2 },
params: {},
exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 minute expiration
};
try {
const token = jwt.sign(payload, METABASE_SECRET_KEY);
return {
success: true,
token: token,
siteUrl: config.metabase.siteUrl
};
} catch (error) {
throw createError({
statusCode: 500,
statusMessage: 'Failed to generate Metabase token'
});
}
});

View File

@@ -0,0 +1,127 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Get notification ID from route parameters
const notificationId = getRouterParam(event, 'id')
if (!notificationId) {
throw createError({
statusCode: 400,
statusMessage: 'Notification ID is required'
})
}
// Get current user (assuming auth middleware provides this)
const user = event.context.user
if (!user || !user.userID) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
})
}
// Use Prisma transaction
const result = await prisma.$transaction(async (tx) => {
// First, check if the notification exists and belongs to the user
const notification = await tx.notifications.findFirst({
where: {
id: notificationId,
created_by: user.userID.toString()
},
select: {
id: true,
title: true,
status: true
}
});
if (!notification) {
throw createError({
statusCode: 404,
statusMessage: 'Notification not found or you do not have permission to delete it'
})
}
// Check if notification can be deleted (only draft, scheduled, failed, or cancelled notifications)
const deletableStatuses = ['draft', 'scheduled', 'failed', 'cancelled']
if (!deletableStatuses.includes(notification.status)) {
const statusMessage = notification.status === 'sending'
? `Cannot delete notification while it's being sent. Please wait for it to complete or fail, then try again.`
: notification.status === 'sent'
? `Cannot delete notification that has already been sent. Sent notifications are kept for audit purposes.`
: `Cannot delete notification with status: ${notification.status}. Only draft, scheduled, failed, or cancelled notifications can be deleted.`;
throw createError({
statusCode: 400,
statusMessage: statusMessage
})
}
// If notification was scheduled, remove it from queue first
if (notification.status === 'scheduled') {
await tx.notification_queue.deleteMany({
where: {
notification_id: notificationId
}
});
}
// Delete related records first (if not handled by CASCADE)
await tx.notification_recipients.deleteMany({
where: {
notification_id: notificationId
}
});
await tx.notification_channels.deleteMany({
where: {
notification_id: notificationId
}
});
await tx.notification_user_segments.deleteMany({
where: {
notification_id: notificationId
}
});
// Delete the notification
const deletedNotification = await tx.notifications.delete({
where: {
id: notificationId
}
});
return {
id: deletedNotification.id,
title: notification.title
};
});
return {
success: true,
data: {
id: result.id,
title: result.title,
message: 'Notification deleted successfully'
}
}
} catch (error) {
console.error('Error deleting notification:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to delete notification',
data: {
error: error.message
}
})
} finally {
}
})

View File

@@ -0,0 +1,223 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Get notification ID from route parameters
const notificationId = getRouterParam(event, "id");
if (!notificationId) {
throw createError({
statusCode: 400,
statusMessage: "Notification ID is required",
});
}
// Get current user (assuming auth middleware provides this)
const user = event.context.user;
if (!user) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
// Fetch notification with all related data using Prisma
const notification = await prisma.notifications.findFirst({
where: {
id: notificationId,
created_by: user.id,
},
include: {
notification_categories: {
select: {
name: true,
value: true,
},
},
notification_templates: {
select: {
name: true,
subject: true,
email_content: true,
push_title: true,
push_body: true,
variables: true,
},
},
notification_channels: {
select: {
channel_type: true,
},
},
notification_user_segments: {
include: {
user_segments: {
select: {
value: true,
},
},
},
},
notification_recipients: {
select: {
status: true,
opened_at: true,
clicked_at: true,
},
},
},
});
if (!notification) {
throw createError({
statusCode: 404,
statusMessage:
"Notification not found or you do not have permission to access it",
});
}
// Calculate analytics
const totalRecipients = notification.notification_recipients.length;
const sentCount = notification.notification_recipients.filter(
(r) => r.status === "sent"
).length;
const deliveredCount = notification.notification_recipients.filter(
(r) => r.status === "delivered"
).length;
const failedCount = notification.notification_recipients.filter(
(r) => r.status === "failed"
).length;
const openedCount = notification.notification_recipients.filter(
(r) => r.opened_at !== null
).length;
const clickedCount = notification.notification_recipients.filter(
(r) => r.clicked_at !== null
).length;
// Calculate success rate
const successRate =
totalRecipients > 0
? Math.round((deliveredCount / totalRecipients) * 100)
: 0;
// Format the response
const formattedNotification = {
id: notification.id,
title: notification.title,
type: notification.type,
priority: notification.priority,
status: notification.status,
category: {
name: notification.notification_categories?.name || "Uncategorized",
value: notification.notification_categories?.value,
},
channels: notification.notification_channels.map((c) => c.channel_type),
deliveryType: notification.delivery_type,
scheduledAt: notification.scheduled_at,
timezone: notification.timezone,
expiresAt: notification.expires_at,
// A/B Testing
enableAbTesting: notification.enable_ab_testing,
abTestSplit: notification.ab_test_split,
abTestName: notification.ab_test_name,
// Tracking
enableTracking: notification.enable_tracking,
// Audience
audienceType: notification.audience_type,
specificUsers: notification.specific_users,
userSegments: notification.notification_user_segments.map(
(s) => s.user_segments.value
),
userStatus: notification.user_status,
registrationPeriod: notification.registration_period,
excludeUnsubscribed: notification.exclude_unsubscribed,
respectDoNotDisturb: notification.respect_do_not_disturb,
// Content
contentType: notification.content_type,
template: notification.notification_templates
? {
id: notification.template_id,
name: notification.notification_templates.name,
subject: notification.notification_templates.subject,
emailContent: notification.notification_templates.email_content,
pushTitle: notification.notification_templates.push_title,
pushBody: notification.notification_templates.push_body,
variables: notification.notification_templates.variables,
}
: null,
// Email Content
emailSubject: notification.email_subject,
emailContent: notification.email_content,
callToActionText: notification.call_to_action_text,
callToActionUrl: notification.call_to_action_url,
// Push Content
pushTitle: notification.push_title,
pushBody: notification.push_body,
pushImageUrl: notification.push_image_url,
// Analytics
analytics: {
estimatedReach: notification.estimated_reach,
actualSent: notification.actual_sent,
totalRecipients,
sentCount,
deliveredCount,
failedCount,
openedCount,
clickedCount,
successRate,
openRate:
totalRecipients > 0
? Math.round((openedCount / totalRecipients) * 100)
: 0,
clickRate:
totalRecipients > 0
? Math.round((clickedCount / totalRecipients) * 100)
: 0,
},
// Metadata
createdBy: notification.created_by,
createdAt: notification.created_at,
updatedAt: notification.updated_at,
sentAt: notification.sent_at,
};
return {
success: true,
data: formattedNotification,
};
} catch (error) {
console.error("Error fetching notification:", error);
if (error.code?.startsWith("P")) {
throw createError({
statusCode: 400,
statusMessage: "Database operation failed",
data: {
error: error.message,
code: error.code,
},
});
}
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch notification",
data: {
error: error.message,
},
});
} finally {
}
});

View File

@@ -0,0 +1,150 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
const id = event.context.params.id;
const body = await readBody(event);
// Validate if notification exists
const existingNotification = await prisma.notifications.findUnique({
where: { id },
include: {
notification_channels: true,
notification_user_segments: true
}
});
if (!existingNotification) {
throw createError({
statusCode: 404,
statusMessage: 'Notification not found'
});
}
// Prepare update data
const updateData = {
title: body.title,
type: body.type,
priority: body.priority,
category_id: body.category_id,
status: body.status,
delivery_type: body.delivery_type,
scheduled_at: body.scheduled_at ? new Date(body.scheduled_at) : undefined,
timezone: body.timezone,
expires_at: body.expires_at ? new Date(body.expires_at) : undefined,
enable_ab_testing: body.enable_ab_testing,
ab_test_split: body.ab_test_split,
ab_test_name: body.ab_test_name,
enable_tracking: body.enable_tracking,
audience_type: body.audience_type,
specific_users: body.specific_users,
user_status: body.user_status,
registration_period: body.registration_period,
exclude_unsubscribed: body.exclude_unsubscribed,
respect_do_not_disturb: body.respect_do_not_disturb,
content_type: body.content_type,
template_id: body.template_id,
email_subject: body.email_subject,
email_content: body.email_content,
call_to_action_text: body.call_to_action_text,
call_to_action_url: body.call_to_action_url,
push_title: body.push_title,
push_body: body.push_body,
push_image_url: body.push_image_url,
estimated_reach: body.estimated_reach,
updated_at: new Date()
};
// Remove undefined values
Object.keys(updateData).forEach(key =>
updateData[key] === undefined && delete updateData[key]
);
// Start transaction
const updatedNotification = await prisma.$transaction(async (tx) => {
// Update notification
const notification = await tx.notifications.update({
where: { id },
data: updateData,
include: {
notification_channels: true,
notification_categories: true,
notification_templates: true,
notification_user_segments: {
include: {
user_segments: true
}
}
}
});
// Update channels if provided
if (Array.isArray(body.channels)) {
// Validate channel data structure
const invalidChannels = body.channels.filter(channel => !channel.type);
if (invalidChannels.length > 0) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid channel data',
data: {
error: 'Each channel must have a type property',
invalidChannels,
receivedChannels: body.channels
}
});
}
// Delete existing channels
await tx.notification_channels.deleteMany({
where: { notification_id: id }
});
// Create new channels
if (body.channels.length > 0) {
await tx.notification_channels.createMany({
data: body.channels.map(channel => ({
notification_id: id,
channel_type: channel.type,
is_enabled: channel.is_enabled !== false
}))
});
}
}
// Update user segments if provided
if (Array.isArray(body.user_segments)) {
// Delete existing segments
await tx.notification_user_segments.deleteMany({
where: { notification_id: id }
});
// Create new segments
if (body.user_segments.length > 0) {
await tx.notification_user_segments.createMany({
data: body.user_segments.map(segmentId => ({
notification_id: id,
segment_id: segmentId
}))
});
}
}
return notification;
});
return {
success: true,
data: updatedNotification
};
} catch (error) {
console.error('Error updating notification:', error);
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.statusMessage || 'Failed to update notification',
data: {
error: error.message
}
});
}
});

View File

@@ -0,0 +1,149 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event);
// Simple validation
const audienceType = body.audienceType || 'all';
const specificUsers = body.specificUsers || '';
const userSegments = Array.isArray(body.userSegments) ? body.userSegments : [];
const excludeUnsubscribed = body.excludeUnsubscribed !== false;
let totalCount = 0;
let users = [];
// Calculate total count based on audience type
if (audienceType === 'all') {
totalCount = await prisma.user.count({
where: {
userStatus: 'active'
}
});
// Get sample users for preview
users = await prisma.user.findMany({
where: {
userStatus: 'active'
},
select: {
userID: true,
userEmail: true,
userFullName: true
},
take: 10,
orderBy: {
userCreatedDate: 'desc'
}
});
}
else if (audienceType === 'specific' && specificUsers) {
const usersList = specificUsers
.split('\n')
.map(u => u.trim())
.filter(Boolean);
if (usersList.length > 0) {
users = await prisma.user.findMany({
where: {
OR: [
{ userEmail: { in: usersList } },
{ userID: { in: usersList.map(id => parseInt(id)).filter(id => !isNaN(id)) } }
]
},
select: {
userID: true,
userEmail: true,
userFullName: true
}
});
totalCount = users.length;
}
}
else if (audienceType === 'segmented') {
// For segmented audience, we'll use a simplified approach
// In a real implementation, we'd fetch users based on the segments
if (userSegments.includes('new_users')) {
// New users calculation - simplified example
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
totalCount = await prisma.user.count({
where: {
userStatus: 'active',
userCreatedDate: {
gte: thirtyDaysAgo
}
}
});
users = await prisma.user.findMany({
where: {
userStatus: 'active',
userCreatedDate: {
gte: thirtyDaysAgo
}
},
select: {
userID: true,
userEmail: true,
userFullName: true
},
take: 10,
orderBy: {
userCreatedDate: 'desc'
}
});
} else {
// Default segment behavior
totalCount = await prisma.user.count({
where: {
userStatus: 'active'
}
});
users = await prisma.user.findMany({
where: {
userStatus: 'active'
},
select: {
userID: true,
userEmail: true,
userFullName: true
},
take: 10,
orderBy: {
userCreatedDate: 'desc'
}
});
}
}
// Format users for response
const formattedUsers = users.map(user => ({
id: user.userID,
name: user.userFullName?.trim() || 'Unknown',
email: user.userEmail,
segment: audienceType === 'specific' ? 'Specific User' :
audienceType === 'segmented' ? 'Segmented User' : 'All Users'
}));
return {
success: true,
data: {
users: formattedUsers,
totalCount,
previewCount: formattedUsers.length
}
};
} catch (error) {
console.error('Error previewing audience:', error);
throw createError({
statusCode: 500,
statusMessage: 'Failed to preview audience',
data: {
error: error.message
}
});
}
});

View File

@@ -0,0 +1,122 @@
import { z } from "zod";
import prisma from "~/server/utils/prisma";
// Query parameter validation schema
const batchQuerySchema = z.object({
page: z
.string()
.transform((val) => parseInt(val) || 1)
.optional(),
limit: z
.string()
.transform((val) => parseInt(val) || 10)
.optional(),
status: z.string().optional(),
});
export default defineEventHandler(async (event) => {
try {
// Parse and validate query parameters
const queryParams = getQuery(event) || {};
const params = batchQuerySchema.parse(queryParams);
// Build where clause for filtering
const where = {
delivery_type: "batch", // Identify batch notifications
};
if (params.status) {
where.status = params.status;
}
// Get total count for pagination
const total = await prisma.notifications.count({ where });
// Calculate pagination metadata
const totalPages = Math.ceil(total / params.limit);
// Fetch batch notifications with related data
const batches = await prisma.notifications.findMany({
where,
select: {
id: true,
title: true,
status: true,
scheduled_at: true,
created_at: true,
priority: true,
notification_recipients: {
select: {
id: true,
status: true,
},
},
notification_queue: {
select: {
id: true,
status: true,
},
},
},
orderBy: {
created_at: "desc",
},
skip: (params.page - 1) * params.limit,
take: params.limit,
});
// Format batches for response
const formattedBatches = batches.map((batch) => {
// Calculate progress
const totalRecipients = batch.notification_recipients.length;
const processed = batch.notification_recipients.filter(
(r) => r.status === "sent" || r.status === "delivered"
).length;
// Map status to UI-friendly status
let status = batch.status;
if (status === "draft") status = "draft";
else if (status === "scheduled") status = "scheduled";
else if (status === "processing") status = "sending";
else if (status === "completed") status = "sent";
return {
id: batch.id,
name: batch.title,
description: `Batch notification with ${totalRecipients} recipients`,
status: status,
processed: processed,
total: totalRecipients,
time: batch.created_at,
};
});
return {
success: true,
data: {
batches: formattedBatches,
pagination: {
page: params.page,
totalPages,
total,
hasMore: params.page < totalPages,
},
},
};
} catch (error) {
console.error("Error fetching batches:", error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch batch notifications",
data: {
error: error.message,
},
});
} finally {
}
});

View File

@@ -0,0 +1,98 @@
import { z } from "zod";
import prisma from "~/server/utils/prisma";
// Validation schema for batch creation
const createBatchSchema = z.object({
name: z.string().min(1, "Batch name is required"),
type: z.string().min(1, "Message type is required"),
description: z.string().optional(),
priority: z.string().default("medium"),
template: z.string().optional(),
segment: z.string().optional(),
scheduledAt: z.string().optional(),
});
export default defineEventHandler(async (event) => {
try {
// Parse and validate request body
const body = await readBody(event);
const batchData = createBatchSchema.parse(body);
// Create a new batch notification
const newBatch = await prisma.notifications.create({
data: {
title: batchData.name,
type: batchData.type,
priority: batchData.priority,
delivery_type: "batch",
status: batchData.scheduledAt ? "scheduled" : "draft",
scheduled_at: batchData.scheduledAt ? new Date(batchData.scheduledAt) : null,
audience_type: batchData.segment ? "segment" : "all",
content_type: "template",
template_id: batchData.template || null,
created_by: "system", // In a real application, this would be the user ID
created_at: new Date(),
updated_at: new Date(),
},
});
// If a segment is specified, create the segment association
if (batchData.segment) {
await prisma.notification_user_segments.create({
data: {
notification_id: newBatch.id,
segment_id: batchData.segment,
created_at: new Date(),
},
});
}
// Log the batch creation
await prisma.notification_logs.create({
data: {
notification_id: newBatch.id,
action: "Batch Created",
actor_id: "system", // In a real application, this would be the user ID
status: newBatch.status,
details: `Batch notification "${batchData.name}" created`,
created_at: new Date(),
},
});
return {
success: true,
data: {
id: newBatch.id,
name: newBatch.title,
status: newBatch.status,
message: "Batch created successfully",
},
};
} catch (error) {
console.error("Error creating batch:", error);
// Handle validation errors
if (error.name === "ZodError") {
throw createError({
statusCode: 400,
statusMessage: "Invalid batch data",
data: {
errors: error.errors,
},
});
}
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: "Failed to create batch",
data: {
error: error.message,
},
});
} finally {
}
});

View File

@@ -0,0 +1,71 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Since there's no explicit batch table in the schema, we'll simulate this by
// counting notifications with batch-related properties
// Get current date and set to start of day
const today = new Date();
today.setHours(0, 0, 0, 0);
// Define what constitutes a "batch" notification (likely based on delivery_type or audience_type)
const batchWhere = {
delivery_type: "batch",
};
// Get batch stats
const [pending, processing, completed, failed] = await Promise.all([
// Pending batches (draft or scheduled)
prisma.notifications.count({
where: {
...batchWhere,
status: {
in: ["draft", "scheduled"],
},
},
}),
// Processing batches
prisma.notifications.count({
where: {
...batchWhere,
status: "processing",
},
}),
// Completed batches
prisma.notifications.count({
where: {
...batchWhere,
status: "completed",
},
}),
// Failed batches
prisma.notifications.count({
where: {
...batchWhere,
status: "failed",
},
}),
]);
return {
success: true,
data: {
pending,
processing,
completed,
failed,
},
};
} catch (error) {
console.error("Error fetching batch stats:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch batch statistics",
data: {
error: error.message,
},
});
} finally {
}
});

View File

@@ -0,0 +1,37 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Fetch notification categories from the database
const categories = await prisma.notification_categories.findMany({
select: {
name: true,
value: true,
description: true,
},
orderBy: {
name: 'asc'
}
});
// Transform the data to match the expected format
const formattedCategories = categories.map(category => ({
label: category.name,
value: category.value,
description: category.description
}));
return {
success: true,
data: formattedCategories
}
} catch (error) {
console.error('Error fetching categories:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch categories'
})
} finally {
// Disconnect Prisma client to avoid connection leaks
}
})

View File

@@ -0,0 +1,76 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
const now = new Date();
const last30Days = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
// Get all channel types
const channelTypes = ['email', 'push', 'sms'];
const channelPerformance = [];
for (const channelType of channelTypes) {
// Get stats for this channel
const [totalSent, successful, failed, pending] = await Promise.all([
prisma.notification_recipients.count({
where: {
channel_type: channelType,
created_at: { gte: last30Days }
}
}),
prisma.notification_recipients.count({
where: {
channel_type: channelType,
status: 'sent',
created_at: { gte: last30Days }
}
}),
prisma.notification_recipients.count({
where: {
channel_type: channelType,
status: 'failed',
created_at: { gte: last30Days }
}
}),
prisma.notification_recipients.count({
where: {
channel_type: channelType,
status: 'pending',
created_at: { gte: last30Days }
}
}),
]);
const successRate = totalSent > 0
? ((successful / totalSent) * 100).toFixed(1)
: 0;
const failureRate = totalSent > 0
? ((failed / totalSent) * 100).toFixed(1)
: 0;
channelPerformance.push({
channel: channelType,
totalSent,
successful,
failed,
pending,
successRate: parseFloat(successRate),
failureRate: parseFloat(failureRate),
});
}
return {
success: true,
data: channelPerformance
};
} catch (error) {
console.error("Error fetching channel performance:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch channel performance",
data: { error: error.message },
});
} finally {
}
});

View File

@@ -0,0 +1,170 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
const now = new Date();
const last24Hours = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const last7Days = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const last30Days = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
// Fetch comprehensive stats
const [
totalNotifications,
totalSent,
totalScheduled,
totalDraft,
sentLast24h,
sentLast7Days,
totalRecipients,
successfulDeliveries,
failedDeliveries,
queuedJobs,
channelStats,
categoryStats,
] = await Promise.all([
// Total notifications
prisma.notifications.count(),
// Total sent notifications
prisma.notifications.count({
where: { status: "sending" }
}),
// Total scheduled
prisma.notifications.count({
where: { status: "scheduled" }
}),
// Total drafts
prisma.notifications.count({
where: { status: "draft" }
}),
// Sent in last 24 hours
prisma.notifications.count({
where: {
status: "sending",
sent_at: { gte: last24Hours }
}
}),
// Sent in last 7 days
prisma.notifications.count({
where: {
status: "sending",
sent_at: { gte: last7Days }
}
}),
// Total recipients
prisma.notification_recipients.count(),
// Successful deliveries
prisma.notification_recipients.count({
where: { status: "sent" }
}),
// Failed deliveries
prisma.notification_recipients.count({
where: { status: "failed" }
}),
// Queued jobs
prisma.notification_queue.count({
where: { status: "queued" }
}),
// Channel distribution
prisma.notification_channels.groupBy({
by: ['channel_type'],
_count: {
channel_type: true
}
}),
// Category distribution
prisma.notifications.groupBy({
by: ['category_id'],
_count: {
category_id: true
},
take: 5,
orderBy: {
_count: {
category_id: 'desc'
}
}
}),
]);
// Calculate delivery rate
const totalDeliveryAttempts = successfulDeliveries + failedDeliveries;
const deliveryRate = totalDeliveryAttempts > 0
? ((successfulDeliveries / totalDeliveryAttempts) * 100).toFixed(1)
: 100;
// Calculate growth rate (last 7 days vs previous 7 days)
const previous7Days = new Date(last7Days.getTime() - 7 * 24 * 60 * 60 * 1000);
const sentPrevious7Days = await prisma.notifications.count({
where: {
status: "sending",
sent_at: {
gte: previous7Days,
lt: last7Days
}
}
});
const growthRate = sentPrevious7Days > 0
? (((sentLast7Days - sentPrevious7Days) / sentPrevious7Days) * 100).toFixed(1)
: 0;
// Get category names
const categoryIds = categoryStats.map(c => c.category_id).filter(Boolean);
const categories = await prisma.notification_categories.findMany({
where: { id: { in: categoryIds } },
select: { id: true, name: true }
});
const categoryMap = Object.fromEntries(categories.map(c => [c.id, c.name]));
return {
success: true,
data: {
overview: {
total: totalNotifications,
sent: totalSent,
scheduled: totalScheduled,
draft: totalDraft,
sentLast24h,
sentLast7Days,
growthRate: parseFloat(growthRate),
},
delivery: {
totalRecipients,
successful: successfulDeliveries,
failed: failedDeliveries,
deliveryRate: parseFloat(deliveryRate),
queued: queuedJobs,
},
channels: channelStats.map(ch => ({
channel: ch.channel_type,
count: ch._count.channel_type,
})),
topCategories: categoryStats.map(cat => ({
categoryId: cat.category_id,
categoryName: categoryMap[cat.category_id] || 'Unknown',
count: cat._count.category_id,
})),
},
};
} catch (error) {
console.error("Error fetching dashboard overview:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch dashboard data",
data: { error: error.message },
});
} finally {
}
});

View File

@@ -0,0 +1,65 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
const query = getQuery(event);
const limit = parseInt(query.limit) || 10;
// Fetch recent notifications with related data
const recentNotifications = await prisma.notifications.findMany({
take: limit,
orderBy: {
created_at: 'desc'
},
include: {
notification_categories: {
select: {
name: true,
value: true
}
},
notification_channels: {
select: {
channel_type: true
}
},
_count: {
select: {
notification_recipients: true
}
}
}
});
// Format the response
const formatted = recentNotifications.map(notif => ({
id: notif.id,
title: notif.title,
type: notif.type,
priority: notif.priority,
status: notif.status,
category: notif.notification_categories?.name || 'Unknown',
channels: notif.notification_channels.map(ch => ch.channel_type),
recipientCount: notif._count.notification_recipients,
estimatedReach: notif.estimated_reach,
actualSent: notif.actual_sent,
createdAt: notif.created_at,
sentAt: notif.sent_at,
scheduledAt: notif.scheduled_at,
createdBy: notif.created_by,
}));
return {
success: true,
data: formatted
};
} catch (error) {
console.error("Error fetching recent notifications:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch recent notifications",
data: { error: error.message },
});
} finally {
}
});

View File

@@ -0,0 +1,82 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Get current user from auth middleware
const user = event.context.user;
if (!user) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
// Get all email provider configurations
const emailConfigs = await prisma.notification_delivery_config.findMany({
where: {
channel_type: 'email'
},
select: {
id: true,
is_enabled: true,
provider: true,
provider_config: true,
status: true,
success_rate: true,
created_at: true,
updated_at: true
}
});
if (!emailConfigs || emailConfigs.length === 0) {
return {
success: true,
data: {
providers: [],
activeProvider: null
}
};
}
// Convert to provider-keyed object
const providersData = {};
emailConfigs.forEach(config => {
providersData[config.provider.toLowerCase().replace(/\s+/g, '-')] = {
enabled: config.is_enabled,
provider: config.provider,
status: config.status,
successRate: config.success_rate,
config: config.provider_config
};
});
// Find active provider (fallback to first enabled or first in list)
const activeConfig = emailConfigs.find(c => c.is_enabled) || emailConfigs[0];
return {
success: true,
data: {
providers: providersData,
activeProvider: activeConfig.provider.toLowerCase().replace(/\s+/g, '-'),
// For backward compatibility
enabled: activeConfig.is_enabled,
provider: activeConfig.provider.toLowerCase().replace(/\s+/g, '-'),
status: activeConfig.status,
successRate: activeConfig.success_rate,
config: activeConfig.provider_config
}
};
} catch (error) {
console.error('Error fetching email configuration:', error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch email configuration'
});
} finally {
}
});

View File

@@ -0,0 +1,118 @@
import { z } from 'zod';
import prisma from "~/server/utils/prisma";
import { readBody } from 'h3';
const emailConfigSchema = z.object({
enabled: z.boolean(),
provider: z.string(),
config: z.record(z.any()).optional()
});
export default defineEventHandler(async (event) => {
try {
// Get current user from auth middleware
const user = event.context.user;
const userId = user?.userID;
if (!userId) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required (no user id)",
});
}
const now = new Date();
// Validate request body
const body = emailConfigSchema.parse(await readBody(event));
// Normalize provider name for database lookup
const providerName = body.provider === 'mailtrap' ? 'Mailtrap' :
body.provider === 'aws-ses' ? 'AWS SES' :
body.provider;
// If enabling this provider, disable all others for this channel
if (body.enabled) {
await prisma.notification_delivery_config.updateMany({
where: {
channel_type: 'email',
provider: { not: providerName }
},
data: {
is_enabled: false,
updated_at: now,
updated_by: userId
}
});
}
// Check if config already exists for this provider
const existingConfig = await prisma.notification_delivery_config.findFirst({
where: {
channel_type: 'email',
provider: providerName
}
});
let emailConfig;
if (existingConfig) {
// Update existing config
emailConfig = await prisma.notification_delivery_config.update({
where: {
id: existingConfig.id
},
data: {
is_enabled: body.enabled,
provider_config: body.config || {},
status: body.enabled ? 'Connected' : 'Disabled',
updated_at: now,
updated_by: userId
}
});
} else {
// Create new config
emailConfig = await prisma.notification_delivery_config.create({
data: {
channel_type: 'email',
is_enabled: body.enabled,
provider: providerName,
provider_config: body.config || {},
status: body.enabled ? 'Connected' : 'Disabled',
success_rate: 0,
created_by: userId,
updated_by: userId,
updated_at: now
}
});
}
return {
success: true,
data: {
enabled: emailConfig.is_enabled,
provider: emailConfig.provider,
status: emailConfig.status,
successRate: emailConfig.success_rate,
config: emailConfig.provider_config
}
};
} catch (error) {
console.error('Error updating email configuration:', error);
if (error instanceof z.ZodError) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid request data',
data: error.errors
});
}
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to update email configuration'
});
} finally {
}
});

View File

@@ -0,0 +1,65 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Get current user from auth middleware
const user = event.context.user;
if (!user) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
// Get push notification configuration
const pushConfig = await prisma.notification_delivery_config.findFirst({
where: {
channel_type: 'push'
},
select: {
is_enabled: true,
provider: true,
provider_config: true,
status: true,
success_rate: true,
created_at: true,
updated_at: true
}
});
if (!pushConfig) {
return {
success: true,
data: {
enabled: false,
provider: 'firebase',
status: 'Not Configured',
successRate: 0
}
};
}
return {
success: true,
data: {
enabled: pushConfig.is_enabled,
provider: pushConfig.provider,
status: pushConfig.status,
successRate: pushConfig.success_rate,
config: pushConfig.provider_config
}
};
} catch (error) {
console.error('Error fetching push configuration:', error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch push configuration'
});
} finally {
}
});

View File

@@ -0,0 +1,80 @@
import { z } from 'zod';
import prisma from "~/server/utils/prisma";
const pushConfigSchema = z.object({
enabled: z.boolean(),
provider: z.string(),
config: z.record(z.any()).optional()
});
export default defineEventHandler(async (event) => {
try {
// Get current user from auth middleware
const user = event.context.user;
if (!user) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
// Validate request body
const body = await readValidatedBody(event, pushConfigSchema.parse);
// Update or create push notification configuration
const pushConfig = await prisma.notification_delivery_config.upsert({
where: {
channel_type: 'push'
},
update: {
is_enabled: body.enabled,
provider: body.provider,
provider_config: body.config || {},
status: body.enabled ? 'Connected' : 'Disabled',
updated_at: new Date(),
updated_by: user.id
},
create: {
channel_type: 'push',
is_enabled: body.enabled,
provider: body.provider,
provider_config: body.config || {},
status: body.enabled ? 'Connected' : 'Disabled',
success_rate: 0,
created_by: user.id,
updated_by: user.id
}
});
return {
success: true,
data: {
enabled: pushConfig.is_enabled,
provider: pushConfig.provider,
status: pushConfig.status,
successRate: pushConfig.success_rate,
config: pushConfig.provider_config
}
};
} catch (error) {
console.error('Error updating push configuration:', error);
if (error instanceof z.ZodError) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid request data',
data: error.errors
});
}
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to update push configuration'
});
} finally {
}
});

View File

@@ -0,0 +1,66 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Get current user from auth middleware
const user = event.context.user;
if (!user) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
// Get delivery settings
const settings = await prisma.notification_delivery_settings.findFirst({
select: {
auto_retry: true,
enable_fallback: true,
max_retries: true,
retry_delay: true,
priority: true,
enable_reports: true,
created_at: true,
updated_at: true
}
});
if (!settings) {
return {
success: true,
data: {
autoRetry: true,
enableFallback: true,
maxRetries: 3,
retryDelay: 30,
priority: 'normal',
enableReports: true
}
};
}
return {
success: true,
data: {
autoRetry: settings.auto_retry,
enableFallback: settings.enable_fallback,
maxRetries: settings.max_retries,
retryDelay: settings.retry_delay,
priority: settings.priority,
enableReports: settings.enable_reports
}
};
} catch (error) {
console.error('Error fetching delivery settings:', error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch delivery settings'
});
} finally {
}
});

View File

@@ -0,0 +1,87 @@
import { z } from 'zod';
import prisma from "~/server/utils/prisma";
const settingsSchema = z.object({
autoRetry: z.boolean(),
enableFallback: z.boolean(),
maxRetries: z.number().int().min(0).max(5),
retryDelay: z.number().int().min(1).max(300),
priority: z.enum(['low', 'normal', 'high']),
enableReports: z.boolean()
});
export default defineEventHandler(async (event) => {
try {
// Get current user from auth middleware
const user = event.context.user;
if (!user) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
// Validate request body
const body = await readValidatedBody(event, settingsSchema.parse);
// Update or create delivery settings
const settings = await prisma.notification_delivery_settings.upsert({
where: {
id: 1 // Only one settings record
},
update: {
auto_retry: body.autoRetry,
enable_fallback: body.enableFallback,
max_retries: body.maxRetries,
retry_delay: body.retryDelay,
priority: body.priority,
enable_reports: body.enableReports,
updated_at: new Date(),
updated_by: user.id
},
create: {
id: 1,
auto_retry: body.autoRetry,
enable_fallback: body.enableFallback,
max_retries: body.maxRetries,
retry_delay: body.retryDelay,
priority: body.priority,
enable_reports: body.enableReports,
created_by: user.id,
updated_by: user.id
}
});
return {
success: true,
data: {
autoRetry: settings.auto_retry,
enableFallback: settings.enable_fallback,
maxRetries: settings.max_retries,
retryDelay: settings.retry_delay,
priority: settings.priority,
enableReports: settings.enable_reports
}
};
} catch (error) {
console.error('Error updating delivery settings:', error);
if (error instanceof z.ZodError) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid request data',
data: error.errors
});
}
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to update delivery settings'
});
} finally {
}
});

View File

@@ -0,0 +1,54 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
const user = event.context.user;
if (!user) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
const smsConfig = await prisma.notification_delivery_config.findFirst({
where: { channel_type: 'sms' },
select: {
is_enabled: true,
provider: true,
provider_config: true,
status: true,
success_rate: true,
created_at: true,
updated_at: true
}
});
if (!smsConfig) {
return {
success: true,
data: {
enabled: false,
provider: 'twilio',
status: 'Not Configured',
successRate: 0
}
};
}
return {
success: true,
data: {
enabled: smsConfig.is_enabled,
provider: smsConfig.provider,
status: smsConfig.status,
successRate: smsConfig.success_rate,
config: smsConfig.provider_config
}
};
} catch (error) {
console.error('Error fetching SMS configuration:', error);
if (error.statusCode) throw error;
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch SMS configuration'
});
} finally {
}
});

View File

@@ -0,0 +1,67 @@
import { z } from 'zod';
import prisma from "~/server/utils/prisma";
const smsConfigSchema = z.object({
enabled: z.boolean(),
provider: z.string(),
config: z.record(z.any()).optional()
});
export default defineEventHandler(async (event) => {
try {
const user = event.context.user;
if (!user) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
const body = await readValidatedBody(event, smsConfigSchema.parse);
const smsConfig = await prisma.notification_delivery_config.upsert({
where: { channel_type: 'sms' },
update: {
is_enabled: body.enabled,
provider: body.provider,
provider_config: body.config || {},
status: body.enabled ? 'Connected' : 'Disabled',
updated_at: new Date(),
updated_by: user.id
},
create: {
channel_type: 'sms',
is_enabled: body.enabled,
provider: body.provider,
provider_config: body.config || {},
status: body.enabled ? 'Connected' : 'Disabled',
success_rate: 0,
created_by: user.id,
updated_by: user.id
}
});
return {
success: true,
data: {
enabled: smsConfig.is_enabled,
provider: smsConfig.provider,
status: smsConfig.status,
successRate: smsConfig.success_rate,
config: smsConfig.provider_config
}
};
} catch (error) {
console.error('Error updating SMS configuration:', error);
if (error instanceof z.ZodError) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid request data',
data: error.errors
});
}
if (error.statusCode) throw error;
throw createError({
statusCode: 500,
statusMessage: 'Failed to update SMS configuration'
});
} finally {
}
});

View File

@@ -0,0 +1,85 @@
import { prisma } from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Get current user from auth middleware
const user = event.context.user;
if (!user) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
// Get delivery statistics from the last 30 days
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// Get email statistics
const emailStats = await prisma.notification_delivery.groupBy({
by: ['is_success'],
where: {
channel_type: 'email',
created_at: {
gte: thirtyDaysAgo
}
},
_count: {
id: true
}
});
// Get push notification statistics
const pushStats = await prisma.notification_delivery.groupBy({
by: ['is_success'],
where: {
channel_type: 'push',
created_at: {
gte: thirtyDaysAgo
}
},
_count: {
id: true
}
});
// Calculate totals
const emailCount = emailStats.reduce((sum, stat) => sum + stat._count.id, 0);
const pushCount = pushStats.reduce((sum, stat) => sum + stat._count.id, 0);
const emailSuccess = emailStats.find(stat => stat.is_success)?._count.id || 0;
const pushSuccess = pushStats.find(stat => stat.is_success)?._count.id || 0;
// Calculate success rate
const totalDeliveries = emailCount + pushCount;
const totalSuccessful = emailSuccess + pushSuccess;
const successRate = totalDeliveries > 0
? (totalSuccessful / totalDeliveries) * 100
: 100;
return {
success: true,
data: {
emailsSent: emailCount,
pushSent: pushCount,
successRate: Number(successRate.toFixed(2)),
failed: totalDeliveries - totalSuccessful
}
};
} catch (error) {
console.error('Error fetching delivery stats:', {
message: error.message,
code: error.code,
meta: error.meta,
stack: error.stack
});
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: `Failed to fetch delivery statistics: ${error.message}`
});
}
});

View File

@@ -0,0 +1,235 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Read request body
const body = await readBody(event);
// Get current user
const user = event.context.user;
if (!user || !user.userID) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
});
}
// Prepare data for saving
const draftData = {
title: body.title || 'Untitled Draft',
type: body.type || 'single',
priority: body.priority || 'medium',
category: body.category,
channels: Array.isArray(body.channels) ? body.channels : [],
emailSubject: body.emailSubject || null,
deliveryType: body.deliveryType || 'immediate',
scheduledAt: body.scheduledAt || null,
timezone: body.timezone || 'UTC',
audienceType: body.audienceType || 'all',
specificUsers: body.specificUsers || null,
userSegments: Array.isArray(body.userSegments) ? body.userSegments : [],
excludeUnsubscribed: body.excludeUnsubscribed !== false, // default true
contentType: body.contentType || 'new',
selectedTemplate: body.selectedTemplate || null,
emailContent: body.emailContent || null,
callToActionText: body.callToActionText || null,
callToActionUrl: body.callToActionUrl || null,
pushTitle: body.pushTitle || null,
pushBody: body.pushBody || null,
draftId: body.draftId // For updating existing drafts
};
// Use Prisma transaction
const result = await prisma.$transaction(async (tx) => {
let notificationId = draftData.draftId;
// Get category if provided
let categoryId = null;
if (draftData.category) {
const category = await tx.notification_categories.findFirst({
where: { value: draftData.category }
});
if (category) {
categoryId = category.id;
}
}
// Get template if provided
let templateId = null;
if (draftData.contentType === 'template' && draftData.selectedTemplate) {
const template = await tx.notification_templates.findFirst({
where: {
value: draftData.selectedTemplate,
is_active: true
}
});
if (template) {
templateId = template.id;
}
}
if (notificationId) {
// Check if the draft exists and belongs to the user
const existingDraft = await tx.notifications.findFirst({
where: {
id: notificationId,
created_by: user.userID.toString(),
status: 'draft'
}
});
if (!existingDraft) {
throw createError({
statusCode: 404,
statusMessage: 'Draft not found or you do not have permission to update it'
});
}
// Update existing draft
const updatedDraft = await tx.notifications.update({
where: { id: notificationId },
data: {
title: draftData.title,
type: draftData.type,
priority: draftData.priority,
category_id: categoryId,
delivery_type: draftData.deliveryType,
scheduled_at: draftData.scheduledAt ? new Date(draftData.scheduledAt) : null,
timezone: draftData.timezone,
audience_type: draftData.audienceType,
specific_users: draftData.specificUsers,
exclude_unsubscribed: draftData.excludeUnsubscribed,
content_type: draftData.contentType,
template_id: templateId,
email_subject: draftData.emailSubject,
email_content: draftData.emailContent,
call_to_action_text: draftData.callToActionText,
call_to_action_url: draftData.callToActionUrl,
push_title: draftData.pushTitle,
push_body: draftData.pushBody,
updated_at: new Date()
}
});
notificationId = updatedDraft.id;
} else {
// Create new draft
const newDraft = await tx.notifications.create({
data: {
title: draftData.title,
type: draftData.type,
priority: draftData.priority,
category_id: categoryId,
delivery_type: draftData.deliveryType,
scheduled_at: draftData.scheduledAt ? new Date(draftData.scheduledAt) : null,
timezone: draftData.timezone,
audience_type: draftData.audienceType,
specific_users: draftData.specificUsers,
exclude_unsubscribed: draftData.excludeUnsubscribed,
respect_do_not_disturb: true,
enable_tracking: true,
content_type: draftData.contentType,
template_id: templateId,
email_subject: draftData.emailSubject,
email_content: draftData.emailContent,
call_to_action_text: draftData.callToActionText,
call_to_action_url: draftData.callToActionUrl,
push_title: draftData.pushTitle,
push_body: draftData.pushBody,
created_by: user.userID.toString(),
status: 'draft'
}
});
notificationId = newDraft.id;
}
// Update channels
if (draftData.channels && draftData.channels.length > 0) {
// Delete existing channels
await tx.notification_channels.deleteMany({
where: { notification_id: notificationId }
});
// Insert new channels
await tx.notification_channels.createMany({
data: draftData.channels.map(channel => ({
notification_id: notificationId,
channel_type: channel
}))
});
}
// Update segments
if (draftData.userSegments && draftData.userSegments.length > 0) {
// Delete existing segments
await tx.notification_user_segments.deleteMany({
where: { notification_id: notificationId }
});
// Insert new segments
for (const segment of draftData.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: notificationId,
segment_id: segmentData.id
}
});
}
}
}
return {
id: notificationId
};
});
return {
success: true,
data: {
id: result.id,
message: 'Draft saved successfully'
}
};
} catch (error) {
console.error('Error saving draft:', 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 save draft',
data: {
error: error.message
}
});
} finally {
}
});

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

View File

@@ -0,0 +1,161 @@
import { z } from "zod";
import prisma from "~/server/utils/prisma";
// Query parameter validation schema
const listNotificationSchema = z.object({
page: z
.string()
.transform((val) => parseInt(val) || 1)
.optional(),
limit: z
.string()
.transform((val) => parseInt(val) || 20)
.optional(),
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 {
// Parse and validate query parameters
const queryParams = getQuery(event) || {};
const params = listNotificationSchema.parse(queryParams);
// Build the where clause for filtering
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 } }
];
}
// Get total count for pagination
const total = await prisma.notifications.count({ where });
// Fetch notifications with relations
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
});
// Format the response with only essential fields
const formattedNotifications = notifications.map(notification => {
const totalRecipients = notification.notification_recipients.length;
const deliveredCount = notification.notification_recipients.filter(
r => r.status === 'delivered'
).length;
const successRate = totalRecipients > 0
? Math.round((deliveredCount / totalRecipients) * 100)
: 0;
return {
title: notification.title,
category: notification.notification_categories?.name || 'Uncategorized',
channels: notification.notification_channels.map(c => c.channel_type),
priority: notification.priority,
status: notification.status,
recipients: totalRecipients,
// successRate: {
// successRate,
// delivered: deliveredCount,
// total: totalRecipients
// },
createdAt: notification.created_at,
action: notification.id,
};
});
// Calculate pagination metadata
const totalPages = Math.ceil(total / params.limit);
return {
success: true,
data: {
notifications: formattedNotifications,
pagination: {
page: params.page,
totalPages,
totalItems: total,
hasMore: params.page < totalPages
},
},
};
} catch (error) {
console.error("Error fetching notifications:", error);
if (error.code?.startsWith('P')) {
throw createError({
statusCode: 400,
statusMessage: "Database operation failed",
data: {
error: error.message,
code: error.code
}
});
}
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch notifications",
data: {
error: error.message
}
});
} finally {
}
});

View File

@@ -0,0 +1,44 @@
import prisma from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
try {
// Check authentication
const user = event.context.user;
if (!user || !user.userID) {
return {
statusCode: 401,
body: { success: false, message: 'Unauthorized' }
}
}
const id = event.context.params.id
if (!id) {
return {
statusCode: 400,
body: { success: false, message: 'Log ID is required' }
}
}
const log = await prisma.notification_logs.findUnique({
where: { id }
})
if (!log) {
return {
statusCode: 404,
body: { success: false, message: 'Log entry not found' }
}
}
return {
statusCode: 200,
body: { success: true, data: log }
}
} catch (error) {
console.error('Error fetching log entry:', error)
return {
statusCode: 500,
body: { success: false, message: 'Failed to fetch log entry', error: error.message }
}
}
})

View File

@@ -0,0 +1,492 @@
import prisma from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
try {
// Check authentication
const user = event.context.user;
if (!user || !user.userID) {
return {
statusCode: 401,
body: { success: false, message: 'Unauthorized' }
}
}
const query = getQuery(event)
const period = query.period || '7d' // Default to last 7 days
const channel = query.channel || 'all'
// Calculate date range based on selected period
const endDate = new Date()
let startDate = new Date()
switch(period) {
case '1d':
startDate.setDate(startDate.getDate() - 1)
break
case '7d':
startDate.setDate(startDate.getDate() - 7)
break
case '30d':
startDate.setDate(startDate.getDate() - 30)
break
case '90d':
startDate.setDate(startDate.getDate() - 90)
break
case '12m':
startDate.setMonth(startDate.getMonth() - 12)
break
default:
startDate.setDate(startDate.getDate() - 7)
}
// Channel filter
const channelFilter = channel !== 'all' ? { channel_type: channel } : {}
// Get key metrics
const totalSent = await prisma.notification_logs.count({
where: {
created_at: {
gte: startDate,
lte: endDate
},
status: 'Sent',
...channelFilter
}
})
const previousPeriodEnd = new Date(startDate)
const previousPeriodStart = new Date(startDate)
// Calculate the same duration for previous period
switch(period) {
case '1d':
previousPeriodStart.setDate(previousPeriodStart.getDate() - 1)
break
case '7d':
previousPeriodStart.setDate(previousPeriodStart.getDate() - 7)
break
case '30d':
previousPeriodStart.setDate(previousPeriodStart.getDate() - 30)
break
case '90d':
previousPeriodStart.setDate(previousPeriodStart.getDate() - 90)
break
case '12m':
previousPeriodStart.setMonth(previousPeriodStart.getMonth() - 12)
break
}
const previousTotalSent = await prisma.notification_logs.count({
where: {
created_at: {
gte: previousPeriodStart,
lt: startDate
},
status: 'Sent',
...channelFilter
}
})
const sentChangePercent = previousTotalSent > 0
? ((totalSent - previousTotalSent) / previousTotalSent * 100).toFixed(1)
: '0.0'
// Get success rate
const totalAttempted = await prisma.notification_logs.count({
where: {
created_at: {
gte: startDate,
lte: endDate
},
action: {
in: ['Notification Sent', 'Delivery Attempted']
},
...channelFilter
}
})
const successfulDeliveries = await prisma.notification_logs.count({
where: {
created_at: {
gte: startDate,
lte: endDate
},
status: 'Sent',
...channelFilter
}
})
const successRate = totalAttempted > 0
? ((successfulDeliveries / totalAttempted) * 100).toFixed(1)
: '0.0'
const previousSuccessRate = await calculateSuccessRate(
prisma,
previousPeriodStart,
startDate,
channelFilter
)
const successRateChangePercent = previousSuccessRate > 0
? ((parseFloat(successRate) - previousSuccessRate) / previousSuccessRate * 100).toFixed(1)
: '0.0'
// Get open rate (if tracking available)
const totalOpened = await prisma.notification_logs.count({
where: {
created_at: {
gte: startDate,
lte: endDate
},
status: 'Opened',
...channelFilter
}
})
const openRate = totalSent > 0
? ((totalOpened / totalSent) * 100).toFixed(1)
: '0.0'
const previousOpenRate = await calculateOpenRate(
prisma,
previousPeriodStart,
startDate,
channelFilter
)
const openRateChangePercent = previousOpenRate > 0
? ((parseFloat(openRate) - previousOpenRate) / previousOpenRate * 100).toFixed(1)
: '0.0'
// Get click rate (if tracking available)
const totalClicked = await prisma.notification_logs.count({
where: {
created_at: {
gte: startDate,
lte: endDate
},
status: 'Clicked',
...channelFilter
}
})
const clickRate = totalSent > 0
? ((totalClicked / totalSent) * 100).toFixed(1)
: '0.0'
const previousClickRate = await calculateClickRate(
prisma,
previousPeriodStart,
startDate,
channelFilter
)
const clickRateChangePercent = previousClickRate > 0
? ((parseFloat(clickRate) - previousClickRate) / previousClickRate * 100).toFixed(1)
: '0.0'
// Get channel performance data
const channelPerformance = await getChannelPerformance(prisma, startDate, endDate)
// Get recent notable events
const recentEvents = await getRecentEvents(prisma)
return {
statusCode: 200,
body: {
success: true,
data: {
keyMetrics: [
{
title: "Total Sent",
value: totalSent.toLocaleString(),
icon: "ic:outline-send",
trend: parseFloat(sentChangePercent) >= 0 ? "up" : "down",
change: `${parseFloat(sentChangePercent) >= 0 ? '+' : ''}${sentChangePercent}%`
},
{
title: "Success Rate",
value: `${successRate}%`,
icon: "ic:outline-check-circle",
trend: parseFloat(successRateChangePercent) >= 0 ? "up" : "down",
change: `${parseFloat(successRateChangePercent) >= 0 ? '+' : ''}${successRateChangePercent}%`
},
{
title: "Open Rate",
value: `${openRate}%`,
icon: "ic:outline-open-in-new",
trend: parseFloat(openRateChangePercent) >= 0 ? "up" : "down",
change: `${parseFloat(openRateChangePercent) >= 0 ? '+' : ''}${openRateChangePercent}%`
},
{
title: "Click Rate",
value: `${clickRate}%`,
icon: "ic:outline-touch-app",
trend: parseFloat(clickRateChangePercent) >= 0 ? "up" : "down",
change: `${parseFloat(clickRateChangePercent) >= 0 ? '+' : ''}${clickRateChangePercent}%`
}
],
channelPerformance,
recentEvents
}
}
}
} catch (error) {
console.error('Error fetching analytics data:', error)
return {
statusCode: 500,
body: { success: false, message: 'Failed to fetch analytics data', error: error.message }
}
}
})
// Helper functions
async function calculateSuccessRate(prisma, startDate, endDate, channelFilter) {
const totalAttempted = await prisma.notification_logs.count({
where: {
created_at: {
gte: startDate,
lt: endDate
},
action: {
in: ['Notification Sent', 'Delivery Attempted']
},
...channelFilter
}
})
const successfulDeliveries = await prisma.notification_logs.count({
where: {
created_at: {
gte: startDate,
lt: endDate
},
status: 'Sent',
...channelFilter
}
})
return totalAttempted > 0
? ((successfulDeliveries / totalAttempted) * 100)
: 0
}
async function calculateOpenRate(prisma, startDate, endDate, channelFilter) {
const totalSent = await prisma.notification_logs.count({
where: {
created_at: {
gte: startDate,
lt: endDate
},
status: 'Sent',
...channelFilter
}
})
const totalOpened = await prisma.notification_logs.count({
where: {
created_at: {
gte: startDate,
lt: endDate
},
status: 'Opened',
...channelFilter
}
})
return totalSent > 0
? ((totalOpened / totalSent) * 100)
: 0
}
async function calculateClickRate(prisma, startDate, endDate, channelFilter) {
const totalSent = await prisma.notification_logs.count({
where: {
created_at: {
gte: startDate,
lt: endDate
},
status: 'Sent',
...channelFilter
}
})
const totalClicked = await prisma.notification_logs.count({
where: {
created_at: {
gte: startDate,
lt: endDate
},
status: 'Clicked',
...channelFilter
}
})
return totalSent > 0
? ((totalClicked / totalSent) * 100)
: 0
}
async function getChannelPerformance(prisma, startDate, endDate) {
// Define the channels we want to analyze
const channels = ['Email', 'SMS', 'Push Notification', 'Webhook']
const channelIcons = {
'Email': 'ic:outline-email',
'SMS': 'ic:outline-sms',
'Push Notification': 'ic:outline-notifications',
'Webhook': 'ic:outline-webhook'
}
const result = []
for (const channel of channels) {
// Get total sent for this channel
const sent = await prisma.notification_logs.count({
where: {
created_at: {
gte: startDate,
lte: endDate
},
status: 'Sent',
channel_type: channel
}
})
// Get total failed for this channel
const failed = await prisma.notification_logs.count({
where: {
created_at: {
gte: startDate,
lte: endDate
},
status: 'Failed',
channel_type: channel
}
})
// Get total bounced for this channel
const bounced = await prisma.notification_logs.count({
where: {
created_at: {
gte: startDate,
lte: endDate
},
status: 'Bounced',
channel_type: channel
}
})
// Calculate total attempted (sent + failed + bounced)
const total = sent + failed + bounced
// Calculate success rate
const successRate = total > 0 ? ((sent / total) * 100).toFixed(1) : '0.0'
// Calculate bounce rate
const bounceRate = total > 0 ? ((bounced / total) * 100).toFixed(1) : '0.0'
// Calculate failure rate
const failureRate = total > 0 ? ((failed / total) * 100).toFixed(1) : '0.0'
result.push({
name: channel,
icon: channelIcons[channel] || 'ic:outline-message',
sent: sent.toString(),
failed: failed.toString(),
bounced: bounced.toString(),
total: total.toString(),
successRate,
bounceRate,
failureRate
})
}
return result
}
// Function to get recent notable events
async function getRecentEvents(prisma) {
// Get last 24 hours
const now = new Date()
const oneDayAgo = new Date(now)
oneDayAgo.setDate(oneDayAgo.getDate() - 1)
// Find recent logs with interesting events
const recentLogs = await prisma.notification_logs.findMany({
where: {
created_at: {
gte: oneDayAgo,
lte: now
},
OR: [
{ status: 'Failed' },
{ status: 'Bounced' },
{
AND: [
{ status: 'Sent' },
{
details: {
contains: 'batch'
}
}
]
},
{ status: 'Opened' }
]
},
orderBy: {
created_at: 'desc'
},
take: 5
})
// Transform logs into notable events
return recentLogs.map(log => {
let type = 'info'
let title = log.action
let description = log.details || ''
let value = ''
let time = formatTimeAgo(log.created_at)
if (log.status === 'Failed') {
type = 'error'
title = 'Delivery Failure'
description = log.error_message || log.details || ''
value = log.channel_type || ''
} else if (log.status === 'Bounced') {
type = 'warning'
title = 'Delivery Bounced'
value = log.channel_type || ''
} else if (log.status === 'Opened') {
type = 'success'
title = 'Notification Opened'
value = 'User Engagement'
} else if (log.status === 'Sent' && log.details?.includes('batch')) {
type = 'info'
title = 'Batch Delivery'
// Try to extract batch size from details
const match = log.details?.match(/(\d+)\s*notifications?/i)
value = match ? `${match[1]} sent` : ''
}
return {
title,
description,
value,
time,
type
}
})
}
// Format time ago
function formatTimeAgo(timestamp) {
const now = new Date()
const time = new Date(timestamp)
const diffInMinutes = Math.floor((now - time) / (1000 * 60))
if (diffInMinutes < 1) return "Just now"
if (diffInMinutes < 60) return `${diffInMinutes} minutes ago`
if (diffInMinutes < 1440) return `${Math.floor(diffInMinutes / 60)} hours ago`
return `${Math.floor(diffInMinutes / 1440)} days ago`
}

View File

@@ -0,0 +1,234 @@
import prisma from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
try {
// Check authentication
const user = event.context.user;
if (!user || !user.userID) {
return {
statusCode: 401,
body: { success: false, message: 'Unauthorized' }
}
}
const query = getQuery(event)
let logs = [];
let totalLogs = 0;
let failedDeliveries = 0;
let successfulDeliveries = 0;
let total = 0;
try {
// Define filters
const filters = {}
// Date range filter
if (query.startDate) {
filters.created_at = {
...filters.created_at,
gte: new Date(query.startDate)
}
}
if (query.endDate) {
filters.created_at = {
...filters.created_at,
lte: new Date(query.endDate)
}
}
// Action filter
if (query.action) {
filters.action = query.action
}
// Channel filter
if (query.channel) {
filters.channel_type = query.channel
}
// Status filter
if (query.status) {
filters.status = query.status
}
// Actor/user filter
if (query.actor) {
filters.actor = {
contains: query.actor
}
}
// Notification ID filter
if (query.notificationId) {
filters.notification_id = query.notificationId
}
// Keyword search in details or error_message
if (query.keyword) {
filters.OR = [
{ details: { contains: query.keyword } },
{ error_message: { contains: query.keyword } }
]
}
// Pagination
const page = parseInt(query.page) || 1
const limit = parseInt(query.limit) || 10
const skip = (page - 1) * limit
try {
console.log("Attempting to query notification_logs table...");
// First check if the table exists
let tableExists = true;
try {
await prisma.$queryRaw`SELECT 1 FROM notification_logs LIMIT 1`;
console.log("notification_logs table exists!");
} catch (tableCheckError) {
console.error("Table check error:", tableCheckError.message);
console.error("Table likely doesn't exist - you need to run the migration!");
tableExists = false;
throw new Error("notification_logs table does not exist");
}
if (tableExists) {
// Get total count for pagination
total = await prisma.notification_logs.count({
where: filters
})
// Get logs with pagination and sorting
logs = await prisma.notification_logs.findMany({
where: filters,
orderBy: {
created_at: 'desc'
},
skip,
take: limit
})
// Get summary stats
totalLogs = await prisma.notification_logs.count()
failedDeliveries = await prisma.notification_logs.count({
where: {
status: 'Failed'
}
})
successfulDeliveries = await prisma.notification_logs.count({
where: {
status: 'Sent'
}
})
console.log(`Successfully fetched ${logs.length} logs from database`);
}
} catch (dbError) {
console.error("Database query error:", dbError.message);
console.error("Stack trace:", dbError.stack);
// Uncommenting the mock data code below will revert to using mock data
// If you want to see real errors, keep this commented out
/*
// If the database table doesn't exist or there's another error, use mock data
logs = generateMockLogs(limit);
total = 25; // Mock total count
totalLogs = 25;
failedDeliveries = 3;
successfulDeliveries = 20;
*/
throw dbError; // Re-throw to see actual error in response
}
} catch (prismaError) {
console.error("Prisma error:", prismaError.message);
console.error("Stack trace:", prismaError.stack);
// Uncommenting the mock data code below will revert to using mock data
// If you want to see real errors, keep this commented out
/*
// If there's an issue with Prisma itself, use mock data
logs = generateMockLogs(10);
total = 25; // Mock total count
totalLogs = 25;
failedDeliveries = 3;
successfulDeliveries = 20;
*/
throw prismaError; // Re-throw to see actual error in response
}
// Calculate success rate
const successRate = totalLogs > 0
? Math.round((successfulDeliveries / totalLogs) * 100)
: 0
const page = parseInt(query.page) || 1
const limit = parseInt(query.limit) || 10
return {
statusCode: 200,
body: {
success: true,
data: {
logs,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
},
summary: {
totalLogs,
failedDeliveries,
successfulDeliveries,
successRate
}
}
}
}
} catch (error) {
console.error('Error fetching notification logs:', error)
return {
statusCode: 500,
body: {
success: false,
message: 'Failed to fetch notification logs',
error: error.message,
stack: error.stack
}
}
}
})
// Helper function to generate mock logs for testing
function generateMockLogs(count = 10) {
const actions = ['Notification Created', 'Notification Sent', 'Delivery Attempted', 'Notification Opened'];
const statuses = ['Sent', 'Failed', 'Opened', 'Queued'];
const channels = ['Email', 'SMS', 'Push Notification', 'Webhook'];
const actors = ['System', 'Admin', 'API', 'Scheduler'];
return Array.from({ length: count }, (_, i) => {
const status = statuses[Math.floor(Math.random() * statuses.length)];
const action = actions[Math.floor(Math.random() * actions.length)];
const created_at = new Date();
created_at.setHours(created_at.getHours() - Math.floor(Math.random() * 72)); // Random time in last 72 hours
return {
id: `mock-${i+1}-${Date.now()}`.substring(0, 36),
notification_id: `notif-${i+1}-${Date.now()}`.substring(0, 36),
action,
actor: actors[Math.floor(Math.random() * actors.length)],
actor_id: `user-${i+1}`,
channel_type: channels[Math.floor(Math.random() * channels.length)],
status,
details: `${action} via ${channels[Math.floor(Math.random() * channels.length)]}`,
source_ip: `192.168.1.${i+1}`,
error_code: status === 'Failed' ? 'ERR_DELIVERY_FAILED' : null,
error_message: status === 'Failed' ? 'Failed to deliver notification' : null,
created_at
};
});
}

View File

@@ -0,0 +1,260 @@
import prisma from '~/server/utils/prisma'
export default defineEventHandler(async (event) => {
try {
// Check authentication
const user = event.context.user;
if (!user || !user.userID) {
return {
statusCode: 401,
body: { success: false, message: 'Unauthorized' }
}
}
// Get system status metrics
const now = new Date()
const oneDayAgo = new Date(now)
oneDayAgo.setDate(oneDayAgo.getDate() - 1)
// Get total sent in last 24 hours
const totalSent = await prisma.notification_logs.count({
where: {
created_at: {
gte: oneDayAgo,
lte: now
},
status: 'Sent'
}
})
// Get success rate in last 24 hours
const totalAttempted = await prisma.notification_logs.count({
where: {
created_at: {
gte: oneDayAgo,
lte: now
},
action: {
in: ['Notification Sent', 'Delivery Attempted']
}
}
})
const successfulDeliveries = await prisma.notification_logs.count({
where: {
created_at: {
gte: oneDayAgo,
lte: now
},
status: 'Sent'
}
})
const successRate = totalAttempted > 0
? ((successfulDeliveries / totalAttempted) * 100).toFixed(2)
: 0
// Get error rate in last 24 hours
const failedDeliveries = await prisma.notification_logs.count({
where: {
created_at: {
gte: oneDayAgo,
lte: now
},
status: 'Failed'
}
})
const errorRate = totalAttempted > 0
? ((failedDeliveries / totalAttempted) * 100).toFixed(2)
: 0
// Get average response time (mock data since we need actual measurements)
const avgResponseTime = "145"
// Calculate status based on metrics
const getSystemStatus = (metric, thresholds) => {
if (metric <= thresholds.healthy) return 'healthy'
if (metric <= thresholds.warning) return 'warning'
return 'critical'
}
// Get queue status
const queueStatus = await getQueueStatus(prisma)
// Get recent activity
const recentActivity = await getRecentActivity(prisma)
// Get performance metrics
const performanceMetrics = {
cpu: 23, // Mock data
memory: 67, // Mock data
queueLoad: calculateQueueLoad(queueStatus)
}
// Get error alerts (mock data)
const errorAlerts = await getErrorAlerts(prisma, oneDayAgo, now)
return {
statusCode: 200,
body: {
success: true,
data: {
systemStatus: [
{
title: "System Health",
value: errorRate < 1 ? "Healthy" : errorRate < 5 ? "Warning" : "Critical",
icon: "ic:outline-favorite",
status: getSystemStatus(errorRate, { healthy: 1, warning: 5 })
},
{
title: "Throughput",
value: `${Math.round(totalSent / 24)}/hr`,
icon: "ic:outline-speed",
status: 'healthy' // Simplified for now
},
{
title: "Error Rate",
value: `${errorRate}%`,
icon: "ic:outline-error-outline",
status: getSystemStatus(errorRate, { healthy: 1, warning: 5 })
},
{
title: "Response Time",
value: `${avgResponseTime}ms`,
icon: "ic:outline-timer",
status: getSystemStatus(parseFloat(avgResponseTime), { healthy: 100, warning: 200 })
}
],
performanceMetrics,
queueStatus,
recentActivity,
errorAlerts
}
}
}
} catch (error) {
console.error('Error fetching monitoring data:', error)
return {
statusCode: 500,
body: { success: false, message: 'Failed to fetch monitoring data', error: error.message }
}
}
})
// Helper functions
async function getQueueStatus(prisma) {
const channels = ['Email', 'SMS', 'Push Notification', 'Webhook']
// In a real implementation, this would query the notification_queue table
// For now, we'll use mock data
return [
{
name: "Email Queue",
count: "1,247",
description: "Pending emails",
status: "active",
utilization: 78
},
{
name: "SMS Queue",
count: "89",
description: "Pending SMS",
status: "active",
utilization: 23
},
{
name: "Push Queue",
count: "3,456",
description: "Pending push notifications",
status: "warning",
utilization: 92
},
{
name: "Webhook Queue",
count: "12",
description: "Pending webhooks",
status: "active",
utilization: 8
}
]
}
function calculateQueueLoad(queueStatus) {
// Calculate average utilization across all queues
const totalUtilization = queueStatus.reduce((sum, queue) => sum + queue.utilization, 0)
return Math.round(totalUtilization / queueStatus.length)
}
async function getRecentActivity(prisma) {
// Get the most recent 5 log entries
const recentLogs = await prisma.notification_logs.findMany({
orderBy: {
created_at: 'desc'
},
take: 5
})
// Format for the frontend
return recentLogs.map(log => {
const timeAgo = formatTimeAgo(log.created_at)
return {
action: log.action,
description: log.details || 'No details provided',
status: log.status ? log.status.toLowerCase() : 'unknown',
time: timeAgo,
source: log.channel_type || 'System'
}
})
}
function formatTimeAgo(timestamp) {
const now = new Date()
const time = new Date(timestamp)
const diffInMinutes = Math.floor((now - time) / (1000 * 60))
if (diffInMinutes < 1) return "Just now"
if (diffInMinutes < 60) return `${diffInMinutes} minutes ago`
if (diffInMinutes < 1440) return `${Math.floor(diffInMinutes / 60)} hours ago`
return `${Math.floor(diffInMinutes / 1440)} days ago`
}
async function getErrorAlerts(prisma, startDate, endDate) {
// Get recent errors
const recentErrors = await prisma.notification_logs.findMany({
where: {
created_at: {
gte: startDate,
lte: endDate
},
status: 'Failed',
error_message: {
not: null
}
},
orderBy: {
created_at: 'desc'
},
take: 3
})
// Format for the frontend
return recentErrors.map(error => {
const timeAgo = formatTimeAgo(error.created_at)
// Determine severity based on error code or other factors
let severity = 'warning'
if (error.error_code && error.error_code.startsWith('CRIT')) {
severity = 'critical'
}
return {
title: error.action || 'Error Detected',
description: error.error_message || 'Unknown error occurred',
timestamp: timeAgo,
component: error.channel_type || 'System',
severity
}
})
}

View File

@@ -0,0 +1,95 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Get current user (assuming auth middleware provides this)
const user = event.context.user;
if (!user || !user.userID) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
const body = await readBody(event);
const { action = 'failed_only' } = body; // 'failed_only', 'completed_only', 'all_old'
let whereCondition = {};
switch (action) {
case 'failed_only':
whereCondition = {
status: 'failed'
};
break;
case 'completed_only':
whereCondition = {
status: 'completed'
};
break;
case 'all_old':
// Clear items older than 1 hour except queued ones
whereCondition = {
created_at: {
lt: new Date(Date.now() - 60 * 60 * 1000) // 1 hour ago
},
status: {
not: 'queued'
}
};
break;
case 'all_except_latest':
// Get the latest notification ID first
const latestNotification = await prisma.notifications.findFirst({
orderBy: { created_at: 'desc' },
select: { id: true }
});
if (latestNotification) {
whereCondition = {
notification_id: {
not: latestNotification.id
},
status: {
in: ['failed', 'completed']
}
};
}
break;
default:
throw createError({
statusCode: 400,
statusMessage: "Invalid action. Use: failed_only, completed_only, all_old, all_except_latest"
});
}
// Delete queue items based on condition
const deleteResult = await prisma.notification_queue.deleteMany({
where: whereCondition
});
return {
success: true,
data: {
message: `Cleared ${deleteResult.count} queue items`,
action,
deletedCount: deleteResult.count
}
};
} catch (error) {
console.error('Error clearing queue:', error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to clear queue',
data: {
error: error.message
}
});
} finally {
}
});

View File

@@ -0,0 +1,187 @@
import { z } from "zod";
import prisma from "~/server/utils/prisma";
// Query parameter validation schema
const querySchema = z.object({
period: z.enum(["hour", "day", "week", "month"]).default("day"),
metric: z.enum(["throughput", "error_rate", "response_time"]).default("throughput"),
});
export default defineEventHandler(async (event) => {
try {
// Parse and validate query parameters
const query = getQuery(event);
const { period, metric } = querySchema.parse(query);
// Set time range based on period
const now = new Date();
let startDate;
let intervalMinutes;
let numPoints;
switch (period) {
case "hour":
startDate = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago
intervalMinutes = 1; // 1-minute intervals
numPoints = 60;
break;
case "day":
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 24 hours ago
intervalMinutes = 60; // 1-hour intervals
numPoints = 24;
break;
case "week":
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // 7 days ago
intervalMinutes = 24 * 60; // 1-day intervals
numPoints = 7;
break;
case "month":
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); // 30 days ago
intervalMinutes = 24 * 60; // 1-day intervals
numPoints = 30;
break;
default:
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
intervalMinutes = 60;
numPoints = 24;
}
const dataPoints = [];
// Generate time buckets
for (let i = 0; i < numPoints; i++) {
const bucketStart = new Date(startDate.getTime() + i * intervalMinutes * 60 * 1000);
const bucketEnd = new Date(bucketStart.getTime() + intervalMinutes * 60 * 1000);
let value = 0;
switch (metric) {
case "throughput": {
// Count completed jobs in this time bucket
const count = await prisma.notification_queue.count({
where: {
status: "completed",
updated_at: {
gte: bucketStart,
lt: bucketEnd,
},
},
});
value = count;
break;
}
case "error_rate": {
// Calculate error rate in this time bucket
const [totalJobs, failedJobs] = await Promise.all([
prisma.notification_queue.count({
where: {
OR: [{ status: "completed" }, { status: "failed" }],
updated_at: {
gte: bucketStart,
lt: bucketEnd,
},
},
}),
prisma.notification_queue.count({
where: {
status: "failed",
updated_at: {
gte: bucketStart,
lt: bucketEnd,
},
},
}),
]);
value = totalJobs > 0 ? Math.round((failedJobs / totalJobs) * 100) : 0;
break;
}
case "response_time": {
// Calculate average response time in this time bucket
const jobs = await prisma.notification_queue.findMany({
where: {
status: "completed",
updated_at: {
gte: bucketStart,
lt: bucketEnd,
},
last_attempt_at: { not: null },
},
select: {
created_at: true,
last_attempt_at: true,
},
take: 50, // Sample size
});
if (jobs.length > 0) {
const responseTimes = jobs.map(job => {
const diff = new Date(job.last_attempt_at).getTime() - new Date(job.created_at).getTime();
return Math.abs(diff);
});
value = Math.round(responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length);
} else {
value = 0;
}
break;
}
}
dataPoints.push({
timestamp: bucketStart.toISOString(),
value,
});
}
// Calculate summary statistics
const values = dataPoints.map(p => p.value);
const min = Math.min(...values);
const max = Math.max(...values);
const avg = Math.round(values.reduce((sum, val) => sum + val, 0) / values.length);
// Calculate trend (compare first half vs second half)
const midpoint = Math.floor(values.length / 2);
const firstHalfAvg = values.slice(0, midpoint).reduce((sum, val) => sum + val, 0) / midpoint;
const secondHalfAvg = values.slice(midpoint).reduce((sum, val) => sum + val, 0) / (values.length - midpoint);
const trend = secondHalfAvg > firstHalfAvg * 1.1 ? "increasing"
: secondHalfAvg < firstHalfAvg * 0.9 ? "decreasing"
: "stable";
return {
success: true,
data: {
metric,
period,
dataPoints,
summary: {
min,
max,
avg,
trend,
},
},
};
} catch (error) {
console.error("Error fetching queue history:", error);
if (error.name === "ZodError") {
throw createError({
statusCode: 400,
statusMessage: "Invalid query parameters",
data: {
errors: error.errors,
},
});
}
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch queue history",
data: {
error: error.message,
},
});
} finally {
}
});

View File

@@ -0,0 +1,118 @@
import { z } from "zod";
import prisma from "~/server/utils/prisma";
// Query parameter validation schema
const jobsQuerySchema = z.object({
page: z
.string()
.transform((val) => parseInt(val) || 1)
.optional(),
limit: z
.string()
.transform((val) => parseInt(val) || 10)
.optional(),
status: z.string().optional(),
});
export default defineEventHandler(async (event) => {
try {
// Parse and validate query parameters
const queryParams = getQuery(event) || {};
const params = jobsQuerySchema.parse(queryParams);
// Build where clause for filtering
const where = {};
if (params.status) {
where.status = params.status;
}
// Get total count for pagination
const total = await prisma.notification_queue.count({ where });
// Calculate pagination metadata
const totalPages = Math.ceil(total / params.limit);
// Fetch jobs with relations
const jobs = await prisma.notification_queue.findMany({
where,
select: {
id: true,
status: true,
scheduled_for: true,
attempts: true,
max_attempts: true,
last_attempt_at: true,
error_message: true,
created_at: true,
updated_at: true,
priority: true,
notifications: {
select: {
id: true,
title: true,
type: true,
},
},
notification_recipients: {
select: {
user_id: true,
email: true,
channel_type: true,
},
},
},
orderBy: {
scheduled_for: "asc",
},
skip: (params.page - 1) * params.limit,
take: params.limit,
});
// Format jobs for response
const formattedJobs = jobs.map((job) => {
return {
id: job.id,
type: job.notifications?.type || "unknown",
description: job.notifications?.title || "Job Description",
status: job.status,
attempts: job.attempts,
maxAttempts: job.max_attempts,
priority: job.priority,
recipient: job.notification_recipients?.email || job.notification_recipients?.user_id,
channel: job.notification_recipients?.channel_type,
scheduledFor: job.scheduled_for,
createdAt: job.created_at,
lastAttempt: job.last_attempt_at,
errorMessage: job.error_message,
};
});
return {
success: true,
data: {
jobs: formattedJobs,
pagination: {
page: params.page,
totalPages,
totalItems: total,
hasMore: params.page < totalPages,
},
},
};
} catch (error) {
console.error("Error fetching queue jobs:", error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch queue jobs",
data: {
error: error.message,
},
});
} finally {
}
});

View File

@@ -0,0 +1,144 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
const now = new Date();
const last24Hours = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const last60Minutes = new Date(now.getTime() - 60 * 60 * 1000);
// Fetch comprehensive queue metrics
const [
totalProcessedToday,
totalCompletedToday,
totalFailedToday,
queuedJobs,
processingJobs,
completedLastHour,
allJobsToday
] = await Promise.all([
// Total processed (completed + failed) in last 24 hours
prisma.notification_queue.count({
where: {
OR: [{ status: "completed" }, { status: "failed" }],
updated_at: { gte: last24Hours },
},
}),
// Successful jobs in last 24 hours
prisma.notification_queue.count({
where: {
status: "completed",
updated_at: { gte: last24Hours },
},
}),
// Failed jobs in last 24 hours
prisma.notification_queue.count({
where: {
status: "failed",
updated_at: { gte: last24Hours },
},
}),
// Currently queued jobs
prisma.notification_queue.count({
where: { status: "queued" },
}),
// Currently processing jobs
prisma.notification_queue.count({
where: { status: "processing" },
}),
// Completed in last hour
prisma.notification_queue.count({
where: {
status: "completed",
updated_at: { gte: last60Minutes },
},
}),
// Get sample jobs to calculate average processing time
prisma.notification_queue.findMany({
where: {
status: "completed",
updated_at: { gte: last24Hours },
last_attempt_at: { not: null },
},
select: {
created_at: true,
last_attempt_at: true,
},
take: 100,
}),
]);
// Calculate throughput (messages per minute)
const throughputPerMinute = Math.round(totalProcessedToday / (24 * 60));
const currentThroughput = completedLastHour; // Last hour throughput
const peakThroughput = Math.round(currentThroughput * 1.3); // Estimate peak
const avgThroughput = throughputPerMinute;
// Calculate success rate
const successRate = totalProcessedToday > 0
? ((totalCompletedToday / totalProcessedToday) * 100).toFixed(1)
: "100";
// Calculate error rate
const errorRate = totalProcessedToday > 0
? ((totalFailedToday / totalProcessedToday) * 100).toFixed(1)
: "0";
// Calculate queue load percentage
const totalCapacity = 1000; // Assume max capacity
const queueLoad = Math.min(100, Math.round(((queuedJobs + processingJobs) / totalCapacity) * 100));
// Calculate average response time from actual data
let avgResponseTime = 0;
if (allJobsToday.length > 0) {
const responseTimes = allJobsToday
.filter(job => job.last_attempt_at)
.map(job => {
const diff = new Date(job.last_attempt_at).getTime() - new Date(job.created_at).getTime();
return Math.abs(diff);
});
if (responseTimes.length > 0) {
avgResponseTime = Math.round(
responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length
);
}
}
// Fallback if no data
if (avgResponseTime === 0) avgResponseTime = 250;
// Active workers (estimate based on processing jobs)
const activeWorkers = processingJobs > 0 ? Math.min(10, processingJobs) : 1;
return {
success: true,
data: {
metrics: {
throughput: throughputPerMinute.toString(),
uptime: successRate,
workers: activeWorkers.toString(),
queueLoad: queueLoad.toString(),
},
throughput: {
current: currentThroughput.toString(),
peak: peakThroughput.toString(),
average: avgThroughput.toString(),
},
systemStatus: {
uptimeToday: successRate,
responseTime: avgResponseTime.toString(),
errorRate: errorRate,
},
},
};
} catch (error) {
console.error("Error fetching queue performance:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch queue performance metrics",
data: {
error: error.message,
},
});
} finally {
}
});

View File

@@ -0,0 +1,31 @@
import { triggerQueueProcessing } from "~/server/utils/queueProcessor";
export default defineEventHandler(async (event) => {
try {
// Get current user from auth middleware
const user = event.context.user;
if (!user) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
console.log('🔄 Manually triggering queue processing...');
// Trigger queue processing
await triggerQueueProcessing();
return {
success: true,
message: "Queue processing triggered successfully",
};
} catch (error) {
console.error("Error triggering queue processing:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to trigger queue processing",
data: { error: error.message },
});
}
});

View File

@@ -0,0 +1,84 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Get job ID from params
const id = event.context.params.id;
if (!id) {
throw createError({
statusCode: 400,
statusMessage: "Job ID is required",
});
}
// Check if job exists and is in a failed state
const job = await prisma.notification_queue.findUnique({
where: {
id: id,
},
});
if (!job) {
throw createError({
statusCode: 404,
statusMessage: "Job not found",
});
}
if (job.status !== "failed") {
throw createError({
statusCode: 400,
statusMessage: "Only failed jobs can be retried",
});
}
// Reset job for retry
await prisma.notification_queue.update({
where: {
id: id,
},
data: {
status: "queued",
attempts: job.attempts, // Keep the previous attempts count for tracking
last_attempt_at: null,
error_message: null,
updated_at: new Date(),
},
});
// Log the retry action
await prisma.notification_logs.create({
data: {
notification_id: job.notification_id,
action: "Job Retry",
status: "Queued",
details: `Job ${id} requeued for retry after ${job.attempts} previous attempts`,
created_at: new Date(),
},
});
return {
success: true,
data: {
message: "Job queued for retry",
jobId: id,
},
};
} catch (error) {
console.error("Error retrying job:", error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: "Failed to retry job",
data: {
error: error.message,
},
});
} finally {
}
});

View File

@@ -0,0 +1,73 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Find all failed jobs
const failedJobs = await prisma.notification_queue.findMany({
where: {
status: "failed",
},
select: {
id: true,
notification_id: true,
attempts: true,
},
});
if (failedJobs.length === 0) {
return {
success: true,
data: {
message: "No failed jobs to retry",
count: 0,
},
};
}
// Update all failed jobs to queued status
await prisma.notification_queue.updateMany({
where: {
status: "failed",
},
data: {
status: "queued",
last_attempt_at: null,
error_message: null,
updated_at: new Date(),
},
});
// Log the batch retry action
await prisma.notification_logs.create({
data: {
action: "Batch Job Retry",
status: "Processed",
details: `${failedJobs.length} failed jobs requeued for retry`,
created_at: new Date(),
},
});
return {
success: true,
data: {
message: `${failedJobs.length} jobs queued for retry`,
count: failedJobs.length,
},
};
} catch (error) {
console.error("Error retrying all failed jobs:", error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: "Failed to retry all jobs",
data: {
error: error.message,
},
});
} finally {
}
});

View File

@@ -0,0 +1,118 @@
import { z } from "zod";
import prisma from "~/server/utils/prisma";
// Query parameter validation schema
const jobsQuerySchema = z.object({
page: z
.string()
.transform((val) => parseInt(val) || 1)
.optional(),
limit: z
.string()
.transform((val) => parseInt(val) || 10)
.optional(),
});
export default defineEventHandler(async (event) => {
try {
// Parse and validate query parameters
const queryParams = getQuery(event) || {};
const params = jobsQuerySchema.parse(queryParams);
// Build where clause for filtering failed jobs
const where = {
status: "failed",
};
// Get total count for pagination
const total = await prisma.notification_queue.count({ where });
// Calculate pagination metadata
const totalPages = Math.ceil(total / params.limit);
// Fetch failed jobs with relations
const jobs = await prisma.notification_queue.findMany({
where,
select: {
id: true,
status: true,
scheduled_for: true,
attempts: true,
max_attempts: true,
last_attempt_at: true,
error_message: true,
created_at: true,
notifications: {
select: {
id: true,
title: true,
type: true,
},
},
},
orderBy: {
last_attempt_at: "desc",
},
skip: (params.page - 1) * params.limit,
take: params.limit,
});
// Format jobs for response
const formattedJobs = jobs.map((job) => {
return {
id: job.id,
type: job.notifications?.type || "unknown",
description: job.notifications?.title || "Failed Notification",
status: job.status,
attempts: job.attempts,
maxAttempts: job.max_attempts,
errorType: job.error_message ? getErrorType(job.error_message) : "Unknown Error",
errorMessage: job.error_message || "No error message provided",
failedAt: job.last_attempt_at ? new Date(job.last_attempt_at).toISOString() : null,
nextRetry: "Manual retry required",
};
});
return {
success: true,
data: {
jobs: formattedJobs,
pagination: {
page: params.page,
totalPages,
totalItems: total,
hasMore: params.page < totalPages,
},
},
};
} catch (error) {
console.error("Error fetching failed jobs:", error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch failed jobs",
data: {
error: error.message,
},
});
} finally {
}
});
// Helper function to extract error type from error message
function getErrorType(errorMessage) {
if (!errorMessage) return "Unknown Error";
if (errorMessage.includes("timeout")) return "Timeout Error";
if (errorMessage.includes("connect")) return "Connection Error";
if (errorMessage.includes("authentication")) return "Authentication Error";
if (errorMessage.includes("rate limit")) return "Rate Limit Error";
if (errorMessage.includes("validation")) return "Validation Error";
if (errorMessage.includes("template")) return "Template Error";
return "Processing Error";
}

View File

@@ -0,0 +1,73 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Get current date and set to start of day
const today = new Date();
today.setHours(0, 0, 0, 0);
// Get yesterday
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
// Query counts for different job statuses
const [failed, retrying, recovered, deadLetter] = await Promise.all([
// Failed jobs count
prisma.notification_queue.count({
where: {
status: "failed",
},
}),
// Jobs currently being retried (if your system tracks this state)
prisma.notification_queue.count({
where: {
status: "processing",
attempts: {
gt: 1,
},
},
}),
// Recovered jobs (failed but then succeeded)
prisma.notification_queue.count({
where: {
status: "completed",
attempts: {
gt: 1,
},
updated_at: {
gte: yesterday,
},
},
}),
// Dead letter queue (failed and max attempts reached)
prisma.notification_queue.count({
where: {
status: "failed",
attempts: {
equals: prisma.notification_queue.fields.max_attempts,
},
},
}),
]);
return {
success: true,
data: {
failed,
retrying,
recovered,
deadLetter,
},
};
} catch (error) {
console.error("Error fetching retry stats:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch retry statistics",
data: {
error: error.message,
},
});
} finally {
}
});

View File

@@ -0,0 +1,52 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Get current date and set to start of day
const today = new Date();
today.setHours(0, 0, 0, 0);
// Count pending jobs
const pending = await prisma.notification_queue.count({
where: {
status: "queued",
},
});
// Count completed jobs today
const completed = await prisma.notification_queue.count({
where: {
status: "completed",
updated_at: {
gte: today,
},
},
});
// Count failed jobs
const failed = await prisma.notification_queue.count({
where: {
status: "failed",
},
});
return {
success: true,
data: {
pending,
completed,
failed,
},
};
} catch (error) {
console.error("Error fetching queue stats:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch queue statistics",
data: {
error: error.message,
},
});
} finally {
}
});

View File

@@ -0,0 +1,84 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Get current user (assuming auth middleware provides this)
const user = event.context.user;
if (!user || !user.userID) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
// Get queue statistics
const queueStats = await prisma.notification_queue.groupBy({
by: ['status'],
_count: {
status: true
},
orderBy: {
status: 'asc'
}
});
// Get recent queue items
const recentItems = await prisma.notification_queue.findMany({
include: {
notifications: {
select: {
id: true,
title: true,
created_at: true,
status: true
}
},
notification_recipients: {
select: {
id: true,
email: true,
channel_type: true,
status: true
}
}
},
orderBy: {
created_at: 'desc'
},
take: 20
});
return {
success: true,
data: {
stats: queueStats.reduce((acc, stat) => {
acc[stat.status] = stat._count.status;
return acc;
}, {}),
recentItems: recentItems.map(item => ({
id: item.id,
notificationTitle: item.notifications?.title,
recipientEmail: item.notification_recipients?.email,
channelType: item.notification_recipients?.channel_type,
status: item.status,
scheduledFor: item.scheduled_for,
createdAt: item.created_at,
lastAttempt: item.last_attempt_at,
attempts: item.attempts,
errorMessage: item.error_message
}))
}
};
} catch (error) {
console.error('Error fetching queue status:', error);
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch queue status',
data: {
error: error.message
}
});
} finally {
}
});

View File

@@ -0,0 +1,38 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Fetch active user segments
const segments = await prisma.user_segments.findMany({
where: {
is_active: true,
},
select: {
id: true,
name: true,
value: true,
description: true,
},
orderBy: {
name: "asc",
},
});
return segments.map((segment) => ({
id: segment.id,
name: segment.name,
value: segment.value,
description: segment.description,
}));
} catch (error) {
console.error("Error fetching segments:", error);
throw createError({
statusCode: 500,
statusMessage: "Failed to fetch user segments",
data: {
error: error.message,
},
});
} finally {
}
});

View File

@@ -0,0 +1,130 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Get template ID from route parameters
const templateId = getRouterParam(event, "id");
if (!templateId) {
throw createError({
statusCode: 400,
statusMessage: "Template ID is required",
});
}
console.log("Deleting template:", templateId);
// Get current user (assuming auth middleware provides this)
const user = event.context.user;
if (!user || !user.userID) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
// Check if template exists and user has permission
const existingTemplate = await prisma.notification_templates.findFirst({
where: {
id: templateId,
// Optionally restrict to user's own templates
// created_by: user.userID.toString()
}
});
if (!existingTemplate) {
throw createError({
statusCode: 404,
statusMessage: "Template not found or you don't have permission to delete it",
});
}
// Check if template is being used by any notifications
const templatesInUse = await prisma.notifications.findFirst({
where: {
template_id: templateId
}
});
if (templatesInUse) {
throw createError({
statusCode: 400,
statusMessage: "Cannot delete template that is currently being used by notifications. Please remove it from notifications first.",
});
}
console.log("Template found and can be deleted:", existingTemplate.name);
// Delete the template
await prisma.notification_templates.delete({
where: {
id: templateId
}
});
console.log("Template deleted successfully:", templateId);
// Return success response
return {
success: true,
data: {
message: `Template "${existingTemplate.name}" has been deleted successfully`,
deletedTemplate: {
id: existingTemplate.id,
name: existingTemplate.name,
value: existingTemplate.value
}
}
};
} catch (error) {
console.error("Template deletion 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);
if (error.code === 'P2025') {
throw createError({
statusCode: 404,
statusMessage: "Template not found",
data: {
error: "The template you're trying to delete does not exist.",
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 delete template",
data: {
error: error.message
}
});
} finally {
}
});

View File

@@ -0,0 +1,134 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Get template ID from route parameters
const templateId = getRouterParam(event, "id");
if (!templateId) {
throw createError({
statusCode: 400,
statusMessage: "Template ID is required",
});
}
console.log("Fetching template:", templateId);
// Get current user (assuming auth middleware provides this)
const user = event.context.user;
if (!user || !user.userID) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
// Fetch the template
const template = await prisma.notification_templates.findUnique({
where: {
id: templateId,
},
});
if (!template) {
throw createError({
statusCode: 404,
statusMessage: "Template not found",
});
}
console.log("Template found:", template.name);
// Format the response data to match frontend expectations
const formattedTemplate = {
id: template.id,
title: template.name,
value: template.value,
description: template.description || "",
subject: template.subject || "",
preheader: template.preheader || "",
category: template.category || "",
channels: template.channels || [],
status: template.status || "Draft",
version: template.version || "1.0",
content: template.email_content || "",
tags: template.tags || "",
isPersonal: template.is_personal || false,
// Email specific settings
fromName: template.from_name || "",
replyTo: template.reply_to || "",
trackOpens: template.track_opens !== false, // Default to true
// Push notification specific settings
pushTitle: template.push_title || "",
pushIcon: template.push_icon || "",
pushUrl: template.push_url || "",
// SMS specific settings
smsContent: template.sms_content || "",
// Metadata
isActive: template.is_active || false,
variables: template.variables || null,
createdBy: template.created_by,
updatedBy: template.updated_by,
createdAt: template.created_at,
updatedAt: template.updated_at,
};
// Return success response
return {
success: true,
message: "wowowowow",
data: {
template: formattedTemplate,
},
};
} catch (error) {
console.error("Template fetch 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);
if (error.code === "P2025") {
throw createError({
statusCode: 404,
statusMessage: "Template not found",
data: {
error: "The template you're looking for does not exist.",
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 fetch template",
data: {
error: error.message,
},
});
} finally {
}
});

View File

@@ -0,0 +1,308 @@
import { z } from "zod";
import prisma from "~/server/utils/prisma";
// Input validation schema for updating templates
const updateTemplateSchema = z.object({
title: z.string().min(1, "Title is required").max(100, "Title must be less than 100 characters"),
description: z.string().optional(),
subject: z.string().min(1, "Subject is required").max(255, "Subject must be less than 255 characters"),
preheader: z.string().max(255, "Preheader must be less than 255 characters").optional(),
category: z.string().min(1, "Category is required"),
channels: z.array(z.enum(["email", "sms", "push"])).min(1, "At least one channel is required"),
status: z.enum(["Draft", "Active", "Archived"]).default("Draft"),
version: z.string().default("1.0"),
content: z.string().min(1, "Content is required"),
tags: z.string().optional(),
isPersonal: z.boolean().default(false),
// Email specific settings
fromName: z.string().optional(),
replyTo: z.string().email("Invalid reply-to email format").optional().or(z.literal("")),
trackOpens: z.boolean().default(true),
// Push notification specific settings
pushTitle: z.string().optional(),
pushIcon: z.string().url("Invalid push icon URL").optional().or(z.literal("")),
pushUrl: z.string().url("Invalid push URL").optional().or(z.literal("")),
// SMS specific settings
smsContent: z.string().max(160, "SMS content must be 160 characters or less").optional(),
});
// Helper function to generate unique value from title
function generateUniqueValue(title) {
return title
.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.replace(/\s+/g, '_')
.substring(0, 50);
}
// Helper function to validate template content
function validateTemplateContent(content, channels) {
const errors = [];
// Check for balanced variable syntax
const variableMatches = content.match(/\{\{[^}]*\}\}/g) || [];
for (const match of variableMatches) {
if (!match.endsWith('}}')) {
errors.push(`Unmatched variable syntax: ${match}`);
}
}
return errors;
}
export default defineEventHandler(async (event) => {
try {
// Get template ID from route parameters
const templateId = getRouterParam(event, "id");
if (!templateId) {
throw createError({
statusCode: 400,
statusMessage: "Template ID is required",
});
}
// Read and validate request body
const body = await readBody(event);
console.log("Template update request for ID:", templateId, body);
// Validate input data
const validationResult = updateTemplateSchema.safeParse(body);
if (!validationResult.success) {
const errors = validationResult.error.errors.map(err =>
`${err.path.join('.')}: ${err.message}`
);
throw createError({
statusCode: 400,
statusMessage: "Validation failed",
data: {
errors: errors
}
});
}
const validatedData = validationResult.data;
// 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);
// Check if template exists
const existingTemplate = await prisma.notification_templates.findUnique({
where: {
id: templateId
}
});
if (!existingTemplate) {
throw createError({
statusCode: 404,
statusMessage: "Template not found",
});
}
console.log("Template found for update:", existingTemplate.name);
// Validate template content
const contentErrors = validateTemplateContent(validatedData.content, validatedData.channels);
if (contentErrors.length > 0) {
throw createError({
statusCode: 400,
statusMessage: "Content validation failed",
data: {
errors: contentErrors
}
});
}
// Generate unique value for the template if title changed
let finalValue = existingTemplate.value;
if (validatedData.title !== existingTemplate.name) {
let uniqueValue = generateUniqueValue(validatedData.title);
// Check if value already exists and make it unique
let counter = 1;
finalValue = uniqueValue;
while (true) {
const existingValueTemplate = await prisma.notification_templates.findUnique({
where: {
value: finalValue,
NOT: { id: templateId } // Exclude current template
}
});
if (!existingValueTemplate) {
break;
}
finalValue = `${uniqueValue}_${counter}`;
counter++;
// Safety check to prevent infinite loop
if (counter > 100) {
finalValue = `${uniqueValue}_${Date.now()}`;
break;
}
}
}
// Prepare data for database update
const templateData = {
name: validatedData.title,
value: finalValue,
description: validatedData.description || null,
subject: validatedData.subject,
preheader: validatedData.preheader || null,
email_content: validatedData.content,
push_title: validatedData.pushTitle || null,
push_body: validatedData.content ? validatedData.content.replace(/<[^>]*>/g, '').substring(0, 300) : null,
push_icon: validatedData.pushIcon || null,
push_url: validatedData.pushUrl || null,
sms_content: validatedData.smsContent || null,
category: validatedData.category,
channels: validatedData.channels,
status: validatedData.status,
version: validatedData.version,
tags: validatedData.tags || null,
is_personal: validatedData.isPersonal,
from_name: validatedData.fromName || null,
reply_to: validatedData.replyTo || null,
track_opens: validatedData.trackOpens,
is_active: validatedData.status === "Active",
updated_by: user.userID.toString(),
updated_at: new Date(),
};
console.log("Updating template with data:", templateData);
// Create version history entry before updating the template
await prisma.notification_template_versions.create({
data: {
template_id: templateId,
version: existingTemplate.version || "1.0",
name: existingTemplate.name,
description: existingTemplate.description,
subject: existingTemplate.subject,
preheader: existingTemplate.preheader,
email_content: existingTemplate.email_content,
push_title: existingTemplate.push_title,
push_body: existingTemplate.push_body,
push_icon: existingTemplate.push_icon,
push_url: existingTemplate.push_url,
sms_content: existingTemplate.sms_content,
category: existingTemplate.category,
channels: existingTemplate.channels,
status: existingTemplate.status,
tags: existingTemplate.tags,
is_personal: existingTemplate.is_personal,
from_name: existingTemplate.from_name,
reply_to: existingTemplate.reply_to,
track_opens: existingTemplate.track_opens,
variables: existingTemplate.variables,
is_active: existingTemplate.is_active,
change_description: `Template updated - version ${existingTemplate.version}`,
is_current: false,
created_by: user.userID.toString(),
}
});
// Update the template
const updatedTemplate = await prisma.notification_templates.update({
where: {
id: templateId
},
data: templateData
});
console.log("Template updated successfully:", updatedTemplate.id);
// Return success response
return {
success: true,
data: {
id: updatedTemplate.id,
message: "Template updated successfully",
template: {
id: updatedTemplate.id,
name: updatedTemplate.name,
value: updatedTemplate.value,
status: updatedTemplate.status,
category: updatedTemplate.category,
channels: updatedTemplate.channels,
version: updatedTemplate.version,
updated_at: updatedTemplate.updated_at
}
},
};
} catch (error) {
console.error("Template update 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);
if (error.code === 'P2002') {
throw createError({
statusCode: 400,
statusMessage: "Template with this name already exists",
data: {
error: "A template with this name already exists. Please choose a different name.",
code: error.code
}
});
}
if (error.code === 'P2025') {
throw createError({
statusCode: 404,
statusMessage: "Template not found",
data: {
error: "The template you're trying to update does not exist.",
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 update template",
data: {
error: error.message
}
});
} finally {
}
});

View File

@@ -0,0 +1,185 @@
import prisma from "~/server/utils/prisma";
// Helper function to generate unique value from title
function generateUniqueValue(title) {
return title
.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.replace(/\s+/g, '_')
.substring(0, 50);
}
export default defineEventHandler(async (event) => {
try {
// Get template ID from route parameters
const templateId = getRouterParam(event, "id");
if (!templateId) {
throw createError({
statusCode: 400,
statusMessage: "Template ID is required",
});
}
console.log("Duplicating template:", templateId);
// Get current user (assuming auth middleware provides this)
const user = event.context.user;
if (!user || !user.userID) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
// Find the original template
const originalTemplate = await prisma.notification_templates.findUnique({
where: {
id: templateId
}
});
if (!originalTemplate) {
throw createError({
statusCode: 404,
statusMessage: "Template not found",
});
}
console.log("Original template found:", originalTemplate.name);
// Generate new name and value for the duplicate
const newName = `${originalTemplate.name} (Copy)`;
let uniqueValue = generateUniqueValue(newName);
// Check if value already exists and make it unique
let counter = 1;
let finalValue = uniqueValue;
while (true) {
const existingTemplate = await prisma.notification_templates.findUnique({
where: { value: finalValue }
});
if (!existingTemplate) {
break;
}
finalValue = `${uniqueValue}_copy_${counter}`;
counter++;
// Safety check to prevent infinite loop
if (counter > 100) {
finalValue = `${uniqueValue}_copy_${Date.now()}`;
break;
}
}
// Prepare duplicate template data
const duplicateData = {
name: newName,
value: finalValue,
description: originalTemplate.description,
subject: originalTemplate.subject,
preheader: originalTemplate.preheader,
email_content: originalTemplate.email_content,
push_title: originalTemplate.push_title,
push_body: originalTemplate.push_body,
push_icon: originalTemplate.push_icon,
push_url: originalTemplate.push_url,
sms_content: originalTemplate.sms_content,
category: originalTemplate.category,
channels: originalTemplate.channels,
status: "Draft", // Always start as draft
version: "1.0", // Reset version for new template
tags: originalTemplate.tags,
is_personal: originalTemplate.is_personal,
from_name: originalTemplate.from_name,
reply_to: originalTemplate.reply_to,
track_opens: originalTemplate.track_opens,
variables: originalTemplate.variables,
is_active: false, // Inactive by default
created_by: user.userID.toString(),
updated_by: user.userID.toString(),
};
console.log("Creating duplicate with data:", duplicateData);
// Create the duplicate template
const duplicateTemplate = await prisma.notification_templates.create({
data: duplicateData
});
console.log("Template duplicated successfully:", duplicateTemplate.id);
// Return success response
return {
success: true,
data: {
message: `Template "${originalTemplate.name}" has been duplicated successfully`,
originalTemplate: {
id: originalTemplate.id,
name: originalTemplate.name,
value: originalTemplate.value
},
duplicateTemplate: {
id: duplicateTemplate.id,
name: duplicateTemplate.name,
value: duplicateTemplate.value,
status: duplicateTemplate.status,
version: duplicateTemplate.version,
created_at: duplicateTemplate.created_at
}
}
};
} catch (error) {
console.error("Template duplication 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);
if (error.code === 'P2002') {
throw createError({
statusCode: 400,
statusMessage: "Duplicate value conflict",
data: {
error: "Unable to generate unique identifier for the duplicate template. Please try again.",
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 duplicate template",
data: {
error: error.message
}
});
} finally {
}
});

View File

@@ -0,0 +1,149 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Get template ID from route parameters
const templateId = getRouterParam(event, "id");
if (!templateId) {
throw createError({
statusCode: 400,
statusMessage: "Template ID is required",
});
}
console.log("Fetching version history for template:", templateId);
// Get current user (assuming auth middleware provides this)
const user = event.context.user;
if (!user || !user.userID) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
// Check if template exists
const template = await prisma.notification_templates.findUnique({
where: { id: templateId }
});
if (!template) {
throw createError({
statusCode: 404,
statusMessage: "Template not found",
});
}
// Check if the version history table exists
let versions = [];
try {
// Fetch version history for the template
versions = await prisma.notification_template_versions.findMany({
where: {
template_id: templateId
},
orderBy: {
created_at: 'desc'
}
});
} catch (dbError) {
console.error("Database error when fetching versions:", dbError);
// If table doesn't exist, return empty array
if (dbError.code === 'P2021' || dbError.message.includes('doesn\'t exist')) {
console.log("Version history table doesn't exist, returning empty array");
versions = [];
} else {
throw dbError;
}
}
// Format the response data
const formattedVersions = versions.map(version => ({
id: version.id,
version: version.version,
name: version.name,
description: version.description,
subject: version.subject,
content: version.email_content,
changeDescription: version.change_description,
isCurrent: version.is_current,
status: version.status,
createdBy: version.created_by,
createdAt: version.created_at,
formattedCreatedAt: version.created_at
? new Date(version.created_at).toLocaleDateString("en-US", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: "",
}));
console.log(`Found ${formattedVersions.length} versions for template ${templateId}`);
// Return success response
return {
success: true,
data: {
templateId,
templateName: template.name,
versions: formattedVersions,
totalCount: formattedVersions.length
}
};
} catch (error) {
console.error("Version history fetch 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);
if (error.code === 'P2025') {
throw createError({
statusCode: 404,
statusMessage: "Template not found",
data: {
error: "The template you're looking for does not exist.",
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 fetch version history",
data: {
error: error.message
}
});
} finally {
}
});

View File

@@ -0,0 +1,145 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Get template ID and version ID from route parameters
const templateId = getRouterParam(event, "id");
const versionId = getRouterParam(event, "versionId");
if (!templateId || !versionId) {
throw createError({
statusCode: 400,
statusMessage: "Template ID and Version ID are required",
});
}
console.log(`Deleting version ${versionId} for template ${templateId}`);
// Get current user (assuming auth middleware provides this)
const user = event.context.user;
if (!user || !user.userID) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
// Check if template exists
const template = await prisma.notification_templates.findUnique({
where: { id: templateId }
});
if (!template) {
throw createError({
statusCode: 404,
statusMessage: "Template not found",
});
}
// Check if version exists
const version = await prisma.notification_template_versions.findUnique({
where: { id: versionId }
});
if (!version || version.template_id !== templateId) {
throw createError({
statusCode: 404,
statusMessage: "Version not found",
});
}
// Check if this is the current version
if (version.is_current) {
throw createError({
statusCode: 400,
statusMessage: "Cannot delete current version",
data: {
error: "You cannot delete the current version of a template. Please restore a different version first."
}
});
}
// Check if there are other versions (prevent deletion of the only version)
const versionCount = await prisma.notification_template_versions.count({
where: { template_id: templateId }
});
if (versionCount <= 1) {
throw createError({
statusCode: 400,
statusMessage: "Cannot delete the only version",
data: {
error: "This is the only version of the template. At least one version must exist."
}
});
}
// Delete the version
await prisma.notification_template_versions.delete({
where: { id: versionId }
});
console.log(`Version ${version.version} deleted successfully`);
// Return success response
return {
success: true,
data: {
message: `Version ${version.version} has been deleted successfully`,
templateId,
deletedVersion: version.version,
deletedVersionId: versionId
}
};
} catch (error) {
console.error("Version delete 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);
if (error.code === 'P2025') {
throw createError({
statusCode: 404,
statusMessage: "Template or version not found",
data: {
error: "The template or version you're looking for does not exist.",
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 delete version",
data: {
error: error.message
}
});
} finally {
}
});

View File

@@ -0,0 +1,231 @@
import prisma from "~/server/utils/prisma";
export default defineEventHandler(async (event) => {
try {
// Get template ID and version ID from route parameters
const templateId = getRouterParam(event, "id");
const versionId = getRouterParam(event, "versionId");
if (!templateId || !versionId) {
throw createError({
statusCode: 400,
statusMessage: "Template ID and Version ID are required",
});
}
console.log(`Restoring version ${versionId} for template ${templateId}`);
// Get current user (assuming auth middleware provides this)
const user = event.context.user;
if (!user || !user.userID) {
throw createError({
statusCode: 401,
statusMessage: "Authentication required",
});
}
// Check if template exists
const template = await prisma.notification_templates.findUnique({
where: { id: templateId }
});
if (!template) {
throw createError({
statusCode: 404,
statusMessage: "Template not found",
});
}
// Check if version exists
const version = await prisma.notification_template_versions.findUnique({
where: { id: versionId }
});
if (!version || version.template_id !== templateId) {
throw createError({
statusCode: 404,
statusMessage: "Version not found",
});
}
// Generate new version number (increment current version)
const currentVersion = template.version || "1.0";
const versionParts = currentVersion.split('.');
const majorVersion = parseInt(versionParts[0]) || 1;
const minorVersion = parseInt(versionParts[1]) || 0;
const newVersion = `${majorVersion}.${minorVersion + 1}`;
// Create version history entry for current template state before restore
await prisma.notification_template_versions.create({
data: {
template_id: templateId,
version: currentVersion,
name: template.name,
description: template.description,
subject: template.subject,
preheader: template.preheader,
email_content: template.email_content,
push_title: template.push_title,
push_body: template.push_body,
push_icon: template.push_icon,
push_url: template.push_url,
sms_content: template.sms_content,
category: template.category,
channels: template.channels,
status: template.status,
tags: template.tags,
is_personal: template.is_personal,
from_name: template.from_name,
reply_to: template.reply_to,
track_opens: template.track_opens,
variables: template.variables,
is_active: template.is_active,
change_description: `Automatic backup before restoring version ${version.version}`,
is_current: false,
created_by: user.userID.toString(),
}
});
// Update the current template with the version data
const updatedTemplate = await prisma.notification_templates.update({
where: { id: templateId },
data: {
name: version.name,
description: version.description,
subject: version.subject,
preheader: version.preheader,
email_content: version.email_content,
push_title: version.push_title,
push_body: version.push_body,
push_icon: version.push_icon,
push_url: version.push_url,
sms_content: version.sms_content,
category: version.category,
channels: version.channels,
status: version.status,
tags: version.tags,
is_personal: version.is_personal,
from_name: version.from_name,
reply_to: version.reply_to,
track_opens: version.track_opens,
variables: version.variables,
is_active: version.is_active,
version: newVersion,
updated_by: user.userID.toString(),
updated_at: new Date(),
}
});
// Create version history entry for the restored version
await prisma.notification_template_versions.create({
data: {
template_id: templateId,
version: newVersion,
name: version.name,
description: version.description,
subject: version.subject,
preheader: version.preheader,
email_content: version.email_content,
push_title: version.push_title,
push_body: version.push_body,
push_icon: version.push_icon,
push_url: version.push_url,
sms_content: version.sms_content,
category: version.category,
channels: version.channels,
status: version.status,
tags: version.tags,
is_personal: version.is_personal,
from_name: version.from_name,
reply_to: version.reply_to,
track_opens: version.track_opens,
variables: version.variables,
is_active: version.is_active,
change_description: `Restored from version ${version.version}`,
is_current: true,
created_by: user.userID.toString(),
}
});
// Mark previous current version as not current
await prisma.notification_template_versions.updateMany({
where: {
template_id: templateId,
is_current: true,
version: { not: newVersion }
},
data: {
is_current: false
}
});
console.log(`Version ${version.version} restored successfully as version ${newVersion}`);
// Return success response
return {
success: true,
data: {
message: `Version ${version.version} has been restored successfully as version ${newVersion}`,
templateId,
restoredVersion: version.version,
newVersion,
template: {
id: updatedTemplate.id,
title: updatedTemplate.name,
version: updatedTemplate.version,
updatedAt: updatedTemplate.updated_at
}
}
};
} catch (error) {
console.error("Version restore 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);
if (error.code === 'P2025') {
throw createError({
statusCode: 404,
statusMessage: "Template or version not found",
data: {
error: "The template or version you're looking for does not exist.",
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 restore version",
data: {
error: error.message
}
});
} finally {
}
});

View File

@@ -0,0 +1,231 @@
import { z } from "zod";
import prisma from "~/server/utils/prisma";
// Input validation schema
const createTemplateSchema = z.object({
title: z.string().min(1, "Title is required").max(100, "Title must be less than 100 characters"),
description: z.string().optional(),
subject: z.string().min(1, "Subject is required").max(255, "Subject must be less than 255 characters"),
preheader: z.string().max(255, "Preheader must be less than 255 characters").optional(),
category: z.string().min(1, "Category is required"),
channels: z.array(z.enum(["email", "sms", "push"])).min(1, "At least one channel is required"),
status: z.enum(["Draft", "Active", "Archived"]).default("Draft"),
version: z.string().default("1.0"),
content: z.string().min(1, "Content is required"),
tags: z.string().optional(),
isPersonal: z.boolean().default(false),
// Email specific settings
fromName: z.string().optional(),
replyTo: z.string().email("Invalid reply-to email format").optional().or(z.literal("")),
trackOpens: z.boolean().default(true),
// Push notification specific settings
pushTitle: z.string().optional(),
pushIcon: z.string().url("Invalid push icon URL").optional().or(z.literal("")),
pushUrl: z.string().url("Invalid push URL").optional().or(z.literal("")),
// SMS specific settings
smsContent: z.string().max(160, "SMS content must be 160 characters or less").optional(),
});
// Helper function to generate unique value from title
function generateUniqueValue(title) {
return title
.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.replace(/\s+/g, '_')
.substring(0, 50);
}
// Helper function to validate template content
function validateTemplateContent(content, channels) {
const errors = [];
// Check for balanced variable syntax
const variableMatches = content.match(/\{\{[^}]*\}\}/g) || [];
for (const match of variableMatches) {
if (!match.endsWith('}}')) {
errors.push(`Unmatched variable syntax: ${match}`);
}
}
return errors;
}
export default defineEventHandler(async (event) => {
try {
// Read and validate request body
const body = await readBody(event);
console.log("Template creation request:", body);
// Validate input data
const validationResult = createTemplateSchema.safeParse(body);
if (!validationResult.success) {
const errors = validationResult.error.errors.map(err =>
`${err.path.join('.')}: ${err.message}`
);
throw createError({
statusCode: 400,
statusMessage: "Validation failed",
data: {
errors: errors
}
});
}
const validatedData = validationResult.data;
// 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);
// Validate template content
const contentErrors = validateTemplateContent(validatedData.content, validatedData.channels);
if (contentErrors.length > 0) {
throw createError({
statusCode: 400,
statusMessage: "Content validation failed",
data: {
errors: contentErrors
}
});
}
// Generate unique value for the template
let uniqueValue = generateUniqueValue(validatedData.title);
// Check if value already exists and make it unique
let counter = 1;
let finalValue = uniqueValue;
while (true) {
const existingTemplate = await prisma.notification_templates.findUnique({
where: { value: finalValue }
});
if (!existingTemplate) {
break;
}
finalValue = `${uniqueValue}_${counter}`;
counter++;
// Safety check to prevent infinite loop
if (counter > 100) {
finalValue = `${uniqueValue}_${Date.now()}`;
break;
}
}
// Prepare data for database insertion
const templateData = {
name: validatedData.title,
value: finalValue,
description: validatedData.description || null,
subject: validatedData.subject,
preheader: validatedData.preheader || null,
email_content: validatedData.content,
push_title: validatedData.pushTitle || null,
push_body: validatedData.content ? validatedData.content.replace(/<[^>]*>/g, '').substring(0, 300) : null,
push_icon: validatedData.pushIcon || null,
push_url: validatedData.pushUrl || null,
sms_content: validatedData.smsContent || null,
category: validatedData.category,
channels: validatedData.channels,
status: validatedData.status,
version: validatedData.version,
tags: validatedData.tags || null,
is_personal: validatedData.isPersonal,
from_name: validatedData.fromName || null,
reply_to: validatedData.replyTo || null,
track_opens: validatedData.trackOpens,
is_active: validatedData.status === "Active" ? 1 : validatedData.status === "Draft" ? 0 : 2,
created_by: user.userID.toString(),
updated_by: user.userID.toString(),
};
console.log("Creating template with data:", templateData);
// Create the template
const newTemplate = await prisma.notification_templates.create({
data: templateData
});
console.log("Template created successfully:", newTemplate.id);
// Return success response
return {
success: true,
data: {
id: newTemplate.id,
message: "Template created successfully",
template: {
id: newTemplate.id,
name: newTemplate.name,
value: newTemplate.value,
status: newTemplate.status,
category: newTemplate.category,
channels: newTemplate.channels,
version: newTemplate.version,
created_at: newTemplate.created_at
}
},
};
} catch (error) {
console.error("Template 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);
if (error.code === 'P2002') {
throw createError({
statusCode: 400,
statusMessage: "Template with this name already exists",
data: {
error: "A template with this name already exists. Please choose a different name.",
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 template",
data: {
error: error.message
}
});
} finally {
}
});

View File

@@ -0,0 +1,64 @@
import prisma from "~/server/utils/prisma";
// Helper function to map is_active integer to status string
function getStatusFromIsActive(isActive) {
console.log("Converting is_active value:", isActive, "type:", typeof isActive);
switch (isActive) {
case 1:
return "Active";
case 0:
return "Inactive";
case 2:
return "Draft";
default:
return "Draft";
}
}
export default defineEventHandler(async (event) => {
try {
// Use raw query to get the actual integer values for is_active
const templates = await prisma.$queryRaw`
SELECT
id,
name,
value,
category,
is_active,
created_at,
updated_at
FROM notification_templates
ORDER BY name ASC
`;
console.log("RAW QUERY TEMPLATES:", templates);
console.log("First template is_active:", templates[0]?.is_active, "type:", typeof templates[0]?.is_active);
// Format the response to match frontend expectations
return {
success: true,
message: "Code: A1",
data: {
templates: templates.map((template) => ({
id: template.id,
title: template.name,
value: template.value,
category: template.category || "General",
is_active: template.is_active,
created_at: template.created_at || "",
updated_at: template.updated_at || "",
})),
},
};
} catch (error) {
console.error("Error fetching templates:", error);
return {
success: false,
data: {
message: "Failed to fetch templates",
error: error.message,
},
};
} finally {
}
});

View File

@@ -0,0 +1,161 @@
import { z } from 'zod'
import { sendEmailNotification } from '~/server/utils/emailService'
const testSendSchema = z.object({
email: z.string().email('Valid email is required'),
notificationId: z.string().uuid().optional(),
testData: z.object({
title: z.string(),
channels: z.array(z.string()),
emailSubject: z.string().optional(),
emailContent: z.string().optional(),
pushTitle: z.string().optional(),
pushBody: z.string().optional(),
callToActionText: z.string().optional(),
callToActionUrl: z.string().optional()
}).optional()
})
export default defineEventHandler(async (event) => {
try {
const body = await readValidatedBody(event, testSendSchema.parse)
const { $db } = useNitroApp()
let notificationData = body.testData
// If notificationId is provided, fetch from database
if (body.notificationId) {
const result = await $db.query(`
SELECT n.*, nc.channel_type, nt.subject as template_subject,
nt.email_content as template_email_content,
nt.push_title as template_push_title,
nt.push_body as template_push_body
FROM notifications n
LEFT JOIN notification_channels nc ON n.id = nc.notification_id
LEFT JOIN notification_templates nt ON n.template_id = nt.id
WHERE n.id = $1
`, [body.notificationId])
if (result.rows.length === 0) {
throw createError({
statusCode: 404,
statusMessage: 'Notification not found'
})
}
const notification = result.rows[0]
const channels = result.rows.map(row => row.channel_type).filter(Boolean)
notificationData = {
title: notification.title,
channels,
emailSubject: notification.email_subject || notification.template_subject,
emailContent: notification.email_content || notification.template_email_content,
pushTitle: notification.push_title || notification.template_push_title,
pushBody: notification.push_body || notification.template_push_body,
callToActionText: notification.call_to_action_text,
callToActionUrl: notification.call_to_action_url
}
}
if (!notificationData) {
throw createError({
statusCode: 400,
statusMessage: 'Either notificationId or testData is required'
})
}
const results = []
// Send test email
if (notificationData.channels.includes('email') && notificationData.emailContent) {
try {
await sendEmailNotification({
to: body.email,
subject: notificationData.emailSubject || 'Test Notification',
content: notificationData.emailContent,
callToActionText: notificationData.callToActionText,
callToActionUrl: notificationData.callToActionUrl
})
results.push({
channel: 'email',
status: 'sent',
message: 'Test email sent successfully'
})
} catch (error) {
results.push({
channel: 'email',
status: 'failed',
message: error.message
})
}
}
// Send test push notification
if (notificationData.channels.includes('push') && notificationData.pushTitle) {
try {
await sendTestPush({
email: body.email, // Use email to identify user for push
title: notificationData.pushTitle,
body: notificationData.pushBody
})
results.push({
channel: 'push',
status: 'sent',
message: 'Test push notification sent successfully'
})
} catch (error) {
results.push({
channel: 'push',
status: 'failed',
message: error.message
})
}
}
return {
success: true,
data: {
message: 'Test notifications processed',
results
}
}
} catch (error) {
console.error('Error sending test notification:', error)
if (error.statusCode) {
throw error
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to send test notification'
})
}
})
// Mock push notification function - replace with your actual push service
async function sendTestPush({ email, title, body }) {
// This is a mock implementation
// Replace with your actual push service (Firebase, OneSignal, etc.)
console.log('Sending test push notification:', {
email,
title,
body
})
// Simulate push sending delay
await new Promise(resolve => setTimeout(resolve, 800))
// For demo purposes, we'll just log and return success
// In a real implementation, you would:
// 1. Find user's device tokens by email
// 2. Send to push notification service
// 3. Handle any errors appropriately
return true
}