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:
124
server/api/analyze-asnaf.post.ts
Normal file
124
server/api/analyze-asnaf.post.ts
Normal 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.'
|
||||
};
|
||||
*/
|
||||
});
|
||||
93
server/api/auth/login.post.js
Normal file
93
server/api/auth/login.post.js
Normal 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" });
|
||||
}
|
||||
19
server/api/auth/logout.get.js
Normal file
19
server/api/auth/logout.get.js
Normal 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",
|
||||
};
|
||||
}
|
||||
});
|
||||
34
server/api/auth/validate.get.js
Normal file
34
server/api/auth/validate.get.js
Normal 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",
|
||||
};
|
||||
}
|
||||
});
|
||||
24
server/api/devtool/api/file-code.js
Normal file
24
server/api/devtool/api/file-code.js
Normal 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",
|
||||
};
|
||||
}
|
||||
});
|
||||
191
server/api/devtool/api/linter.js
Normal file
191
server/api/devtool/api/linter.js
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
77
server/api/devtool/api/list.js
Normal file
77
server/api/devtool/api/list.js
Normal 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;
|
||||
}
|
||||
27
server/api/devtool/api/prettier-format.js
Normal file
27
server/api/devtool/api/prettier-format.js
Normal 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",
|
||||
};
|
||||
}
|
||||
});
|
||||
71
server/api/devtool/api/save.js
Normal file
71
server/api/devtool/api/save.js
Normal 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",
|
||||
};
|
||||
}
|
||||
});
|
||||
90
server/api/devtool/config/add-custom-theme.js
Normal file
90
server/api/devtool/config/add-custom-theme.js
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
22
server/api/devtool/config/env.js
Normal file
22
server/api/devtool/config/env.js
Normal 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,
|
||||
};
|
||||
});
|
||||
44
server/api/devtool/config/loading-logo.js
Normal file
44
server/api/devtool/config/loading-logo.js
Normal 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();
|
||||
}
|
||||
});
|
||||
217
server/api/devtool/config/site-settings.js
Normal file
217
server/api/devtool/config/site-settings.js
Normal 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();
|
||||
}
|
||||
});
|
||||
134
server/api/devtool/config/upload-file.js
Normal file
134
server/api/devtool/config/upload-file.js
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
48
server/api/devtool/content/canvas/file-code.js
Normal file
48
server/api/devtool/content/canvas/file-code.js
Normal 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",
|
||||
};
|
||||
}
|
||||
});
|
||||
42
server/api/devtool/content/code/file-code.js
Normal file
42
server/api/devtool/content/code/file-code.js
Normal 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",
|
||||
};
|
||||
}
|
||||
});
|
||||
450
server/api/devtool/content/code/linter.js
Normal file
450
server/api/devtool/content/code/linter.js
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
27
server/api/devtool/content/code/prettier-format.js
Normal file
27
server/api/devtool/content/code/prettier-format.js
Normal 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",
|
||||
};
|
||||
}
|
||||
});
|
||||
22
server/api/devtool/content/code/save.js
Normal file
22
server/api/devtool/content/code/save.js
Normal 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",
|
||||
};
|
||||
}
|
||||
});
|
||||
29
server/api/devtool/content/template/get-list.js
Normal file
29
server/api/devtool/content/template/get-list.js
Normal 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",
|
||||
};
|
||||
}
|
||||
});
|
||||
57
server/api/devtool/content/template/import.js
Normal file
57
server/api/devtool/content/template/import.js
Normal 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",
|
||||
};
|
||||
}
|
||||
});
|
||||
23
server/api/devtool/content/template/list.js
Normal file
23
server/api/devtool/content/template/list.js
Normal 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",
|
||||
};
|
||||
}
|
||||
});
|
||||
23
server/api/devtool/content/template/tag.js
Normal file
23
server/api/devtool/content/template/tag.js
Normal 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",
|
||||
};
|
||||
}
|
||||
});
|
||||
0
server/api/devtool/lookup/list.js
Normal file
0
server/api/devtool/lookup/list.js
Normal file
51
server/api/devtool/menu/add.js
Normal file
51
server/api/devtool/menu/add.js
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
61
server/api/devtool/menu/delete.js
Normal file
61
server/api/devtool/menu/delete.js
Normal 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");
|
||||
}
|
||||
65
server/api/devtool/menu/edit.js
Normal file
65
server/api/devtool/menu/edit.js
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
5
server/api/devtool/menu/new-add.js
Normal file
5
server/api/devtool/menu/new-add.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event);
|
||||
|
||||
// try {
|
||||
});
|
||||
25
server/api/devtool/menu/overwrite-navigation.js
Normal file
25
server/api/devtool/menu/overwrite-navigation.js
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
33
server/api/devtool/menu/role-list.js
Normal file
33
server/api/devtool/menu/role-list.js
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
33
server/api/devtool/menu/user-list.js
Normal file
33
server/api/devtool/menu/user-list.js
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
36
server/api/devtool/orm/data/get.get.js
Normal file
36
server/api/devtool/orm/data/get.get.js
Normal 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",
|
||||
};
|
||||
}
|
||||
});
|
||||
37
server/api/devtool/orm/schema.get.js
Normal file
37
server/api/devtool/orm/schema.get.js
Normal 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",
|
||||
};
|
||||
}
|
||||
});
|
||||
34
server/api/devtool/orm/studio.get.js
Normal file
34
server/api/devtool/orm/studio.get.js
Normal 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",
|
||||
};
|
||||
}
|
||||
});
|
||||
106
server/api/devtool/orm/table/config/configuration.json
Normal file
106
server/api/devtool/orm/table/config/configuration.json
Normal 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"
|
||||
]
|
||||
}
|
||||
81
server/api/devtool/orm/table/config/index.get.js
Normal file
81
server/api/devtool/orm/table/config/index.get.js
Normal 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;
|
||||
}
|
||||
}
|
||||
171
server/api/devtool/orm/table/create/index.post.js
Normal file
171
server/api/devtool/orm/table/create/index.post.js
Normal 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;
|
||||
}
|
||||
}
|
||||
90
server/api/devtool/orm/table/delete/[table]/index.delete.js
Normal file
90
server/api/devtool/orm/table/delete/[table]/index.delete.js
Normal 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;
|
||||
}
|
||||
}
|
||||
113
server/api/devtool/orm/table/modify/get.get.js
Normal file
113
server/api/devtool/orm/table/modify/get.get.js
Normal 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());
|
||||
}
|
||||
191
server/api/devtool/orm/table/modify/index.post.js
Normal file
191
server/api/devtool/orm/table/modify/index.post.js
Normal 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}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
87
server/api/devtool/role/add.js
Normal file
87
server/api/devtool/role/add.js
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
28
server/api/devtool/role/delete.js
Normal file
28
server/api/devtool/role/delete.js
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
77
server/api/devtool/role/edit.js
Normal file
77
server/api/devtool/role/edit.js
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
59
server/api/devtool/role/list.js
Normal file
59
server/api/devtool/role/list.js
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
138
server/api/devtool/user/add.js
Normal file
138
server/api/devtool/user/add.js
Normal 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;
|
||||
}
|
||||
}
|
||||
28
server/api/devtool/user/delete.js
Normal file
28
server/api/devtool/user/delete.js
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
86
server/api/devtool/user/edit.js
Normal file
86
server/api/devtool/user/edit.js
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
60
server/api/devtool/user/list.js
Normal file
60
server/api/devtool/user/list.js
Normal 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,
|
||||
};
|
||||
}
|
||||
});
|
||||
28
server/api/metabase/token.get.js
Normal file
28
server/api/metabase/token.get.js
Normal 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'
|
||||
});
|
||||
}
|
||||
});
|
||||
127
server/api/notifications/[id].delete.js
Normal file
127
server/api/notifications/[id].delete.js
Normal 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 {
|
||||
}
|
||||
})
|
||||
223
server/api/notifications/[id].get.js
Normal file
223
server/api/notifications/[id].get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
150
server/api/notifications/[id].put.js
Normal file
150
server/api/notifications/[id].put.js
Normal 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
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
149
server/api/notifications/audience-preview.post.js
Normal file
149
server/api/notifications/audience-preview.post.js
Normal 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
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
122
server/api/notifications/batch/index.get.js
Normal file
122
server/api/notifications/batch/index.get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
98
server/api/notifications/batch/index.post.js
Normal file
98
server/api/notifications/batch/index.post.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
71
server/api/notifications/batch/stats.get.js
Normal file
71
server/api/notifications/batch/stats.get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
37
server/api/notifications/categories.get.js
Normal file
37
server/api/notifications/categories.get.js
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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 {
|
||||
}
|
||||
});
|
||||
170
server/api/notifications/dashboard/overview.get.js
Normal file
170
server/api/notifications/dashboard/overview.get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
65
server/api/notifications/dashboard/recent.get.js
Normal file
65
server/api/notifications/dashboard/recent.get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
82
server/api/notifications/delivery/email-config.get.js
Normal file
82
server/api/notifications/delivery/email-config.get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
118
server/api/notifications/delivery/email-config.put.js
Normal file
118
server/api/notifications/delivery/email-config.put.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
65
server/api/notifications/delivery/push-config.get.js
Normal file
65
server/api/notifications/delivery/push-config.get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
80
server/api/notifications/delivery/push-config.put.js
Normal file
80
server/api/notifications/delivery/push-config.put.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
66
server/api/notifications/delivery/settings.get.js
Normal file
66
server/api/notifications/delivery/settings.get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
87
server/api/notifications/delivery/settings.put.js
Normal file
87
server/api/notifications/delivery/settings.put.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
54
server/api/notifications/delivery/sms-config.get.js
Normal file
54
server/api/notifications/delivery/sms-config.get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
67
server/api/notifications/delivery/sms-config.put.js
Normal file
67
server/api/notifications/delivery/sms-config.put.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
85
server/api/notifications/delivery/stats.get.js
Normal file
85
server/api/notifications/delivery/stats.get.js
Normal 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}`
|
||||
});
|
||||
}
|
||||
});
|
||||
235
server/api/notifications/draft.post.js
Normal file
235
server/api/notifications/draft.post.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
411
server/api/notifications/index.post.js
Normal file
411
server/api/notifications/index.post.js
Normal file
@@ -0,0 +1,411 @@
|
||||
import prisma from "~/server/utils/prisma";
|
||||
import { processEmailQueue } from "~/server/utils/emailService";
|
||||
|
||||
// Basic input validation function
|
||||
function validateBasicInput(body) {
|
||||
const errors = [];
|
||||
|
||||
// Required fields validation
|
||||
if (!body.title || body.title.trim() === '') {
|
||||
errors.push('Title is required');
|
||||
}
|
||||
|
||||
if (!body.type || !['single', 'bulk'].includes(body.type)) {
|
||||
errors.push('Type must be either "single" or "bulk"');
|
||||
}
|
||||
|
||||
if (!body.priority || !['low', 'medium', 'high', 'critical'].includes(body.priority)) {
|
||||
errors.push('Priority must be one of: low, medium, high, critical');
|
||||
}
|
||||
|
||||
if (!body.category || body.category.trim() === '') {
|
||||
errors.push('Category is required');
|
||||
}
|
||||
|
||||
if (!body.channels || !Array.isArray(body.channels) || body.channels.length === 0) {
|
||||
errors.push('At least one channel is required');
|
||||
} else {
|
||||
const validChannels = ['email', 'push', 'sms'];
|
||||
const invalidChannels = body.channels.filter(channel => !validChannels.includes(channel));
|
||||
if (invalidChannels.length > 0) {
|
||||
errors.push(`Invalid channels: ${invalidChannels.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!body.deliveryType || !['immediate', 'scheduled'].includes(body.deliveryType)) {
|
||||
errors.push('Delivery type must be either "immediate" or "scheduled"');
|
||||
}
|
||||
|
||||
if (!body.audienceType || !['all', 'specific', 'segmented'].includes(body.audienceType)) {
|
||||
errors.push('Audience type must be one of: all, specific, segmented');
|
||||
}
|
||||
|
||||
if (!body.contentType || !['new', 'template'].includes(body.contentType)) {
|
||||
errors.push('Content type must be either "new" or "template"');
|
||||
}
|
||||
|
||||
// Conditional validations
|
||||
if (body.deliveryType === 'scheduled' && !body.scheduledAt) {
|
||||
errors.push('Scheduled date is required for scheduled notifications');
|
||||
}
|
||||
|
||||
if (body.channels && body.channels.includes('email') && !body.emailSubject) {
|
||||
errors.push('Email subject is required when email channel is selected');
|
||||
}
|
||||
|
||||
// Content validations
|
||||
if (body.contentType === 'template' && !body.selectedTemplate) {
|
||||
errors.push('Template selection is required when using template content');
|
||||
}
|
||||
|
||||
if (body.contentType === 'new') {
|
||||
if (body.channels && body.channels.includes('email') && !body.emailContent) {
|
||||
errors.push('Email content is required when using email channel with new content');
|
||||
}
|
||||
|
||||
if (body.channels && body.channels.includes('push') && (!body.pushTitle || !body.pushBody)) {
|
||||
errors.push('Push title and body are required when using push channel with new content');
|
||||
}
|
||||
}
|
||||
|
||||
// Audience validations
|
||||
if (body.audienceType === 'specific' && (!body.specificUsers || body.specificUsers.trim() === '')) {
|
||||
errors.push('Specific users are required when audience type is specific');
|
||||
}
|
||||
|
||||
if (body.audienceType === 'segmented' && (!body.userSegments || body.userSegments.length === 0)) {
|
||||
errors.push('At least one user segment is required when audience type is segmented');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Simple audience count estimation
|
||||
async function estimateAudienceCount(audienceData, tx) {
|
||||
try {
|
||||
if (audienceData.audienceType === 'all') {
|
||||
// For testing, return 1 (since we're using test recipients)
|
||||
return 1;
|
||||
} else if (audienceData.audienceType === 'specific') {
|
||||
// Count lines in specificUsers
|
||||
const userLines = audienceData.specificUsers
|
||||
.split('\n')
|
||||
.filter(line => line.trim() !== '');
|
||||
return userLines.length;
|
||||
} else if (audienceData.audienceType === 'segmented') {
|
||||
// For segmented audience, return an estimate
|
||||
// In a real implementation, this would query based on segments
|
||||
return audienceData.userSegments?.length || 0;
|
||||
}
|
||||
return 0;
|
||||
} catch (error) {
|
||||
console.error("Error estimating audience count:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// Read and validate request body
|
||||
const body = await readBody(event);
|
||||
|
||||
console.log("Request body:", body);
|
||||
|
||||
// Basic input validation
|
||||
const validationErrors = validateBasicInput(body);
|
||||
if (validationErrors.length > 0) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Validation failed",
|
||||
data: {
|
||||
errors: validationErrors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Get current user (assuming auth middleware provides this)
|
||||
const user = event.context.user;
|
||||
if (!user || !user.userID) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Authentication required",
|
||||
});
|
||||
}
|
||||
|
||||
// Set default values for optional fields
|
||||
const notificationData = {
|
||||
title: body.title,
|
||||
type: body.type,
|
||||
priority: body.priority,
|
||||
category: body.category,
|
||||
channels: body.channels,
|
||||
emailSubject: body.emailSubject || null,
|
||||
deliveryType: body.deliveryType,
|
||||
scheduledAt: body.scheduledAt || null,
|
||||
timezone: body.timezone || 'UTC',
|
||||
audienceType: body.audienceType,
|
||||
specificUsers: body.specificUsers || null,
|
||||
userSegments: body.userSegments || [],
|
||||
excludeUnsubscribed: body.excludeUnsubscribed !== false, // default true
|
||||
contentType: body.contentType,
|
||||
selectedTemplate: body.selectedTemplate || null,
|
||||
emailContent: body.emailContent || null,
|
||||
callToActionText: body.callToActionText || null,
|
||||
callToActionUrl: body.callToActionUrl || null,
|
||||
pushTitle: body.pushTitle || null,
|
||||
pushBody: body.pushBody || null,
|
||||
};
|
||||
|
||||
// Use Prisma transaction for consistency
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 1. Get category
|
||||
const category = await tx.notification_categories.findFirst({
|
||||
where: { value: notificationData.category },
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid category",
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Get template data if using template
|
||||
let templateData = null;
|
||||
if (notificationData.contentType === "template") {
|
||||
templateData = await tx.notification_templates.findFirst({
|
||||
where: {
|
||||
value: notificationData.selectedTemplate,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!templateData) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid or inactive template",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Calculate estimated reach
|
||||
const estimatedReach = await estimateAudienceCount(notificationData, tx);
|
||||
|
||||
// 4. Create notification record
|
||||
const notification = await tx.notifications.create({
|
||||
data: {
|
||||
title: notificationData.title,
|
||||
type: notificationData.type,
|
||||
priority: notificationData.priority,
|
||||
category_id: category.id,
|
||||
delivery_type: notificationData.deliveryType,
|
||||
scheduled_at: notificationData.scheduledAt ? new Date(notificationData.scheduledAt) : null,
|
||||
timezone: notificationData.timezone,
|
||||
enable_tracking: true, // Simplified: always enable tracking
|
||||
audience_type: notificationData.audienceType,
|
||||
specific_users: notificationData.specificUsers,
|
||||
exclude_unsubscribed: notificationData.excludeUnsubscribed,
|
||||
respect_do_not_disturb: true, // Simplified: always respect DND
|
||||
content_type: notificationData.contentType,
|
||||
template_id: templateData?.id || null,
|
||||
email_subject: notificationData.emailSubject || templateData?.subject || null,
|
||||
email_content: notificationData.emailContent || templateData?.email_content || null,
|
||||
call_to_action_text: notificationData.callToActionText || null,
|
||||
call_to_action_url: notificationData.callToActionUrl || null,
|
||||
push_title: notificationData.pushTitle || templateData?.push_title || null,
|
||||
push_body: notificationData.pushBody || templateData?.push_body || null,
|
||||
estimated_reach: estimatedReach,
|
||||
created_by: user.userID.toString(),
|
||||
status: notificationData.deliveryType === "immediate" ? "sending" : "scheduled",
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Insert notification channels
|
||||
await tx.notification_channels.createMany({
|
||||
data: notificationData.channels.map((channel) => ({
|
||||
notification_id: notification.id,
|
||||
channel_type: channel,
|
||||
})),
|
||||
});
|
||||
|
||||
// 6. Insert user segments if segmented audience
|
||||
if (notificationData.audienceType === "segmented" && notificationData.userSegments?.length > 0) {
|
||||
for (const segment of notificationData.userSegments) {
|
||||
const segmentData = await tx.user_segments.findFirst({
|
||||
where: {
|
||||
value: segment,
|
||||
is_active: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (segmentData) {
|
||||
await tx.notification_user_segments.create({
|
||||
data: {
|
||||
notification_id: notification.id,
|
||||
segment_id: segmentData.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Add recipients to notification_recipients table and queue them
|
||||
// First, determine who will receive the notification
|
||||
const recipientsList = [];
|
||||
|
||||
// Get users based on audience type
|
||||
if (notificationData.audienceType === 'all') {
|
||||
// For testing, use the configured sender email
|
||||
const senderEmail = process.env.SMTP_USER || 'test@example.com';
|
||||
recipientsList.push({
|
||||
user_id: "test-user-1",
|
||||
email: senderEmail, // Use your email for testing
|
||||
});
|
||||
}
|
||||
else if (notificationData.audienceType === 'specific' && notificationData.specificUsers) {
|
||||
// Parse specific users from the text field (email addresses or IDs)
|
||||
const userIds = notificationData.specificUsers
|
||||
.split('\n')
|
||||
.filter(line => line.trim() !== '')
|
||||
.map(line => line.trim());
|
||||
|
||||
// For each specified user, add to recipients
|
||||
for (const userId of userIds) {
|
||||
const isEmail = userId.includes('@');
|
||||
recipientsList.push({
|
||||
user_id: isEmail ? userId.split('@')[0] : userId, // Extract username or use ID
|
||||
email: isEmail ? userId : `${userId}@example.com`, // Use provided email or generate fake one
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (notificationData.audienceType === 'segmented' && notificationData.userSegments?.length > 0) {
|
||||
// For demo purposes, just add placeholder users for each segment
|
||||
for (let i = 0; i < notificationData.userSegments.length; i++) {
|
||||
const segmentValue = notificationData.userSegments[i];
|
||||
recipientsList.push({
|
||||
user_id: `${segmentValue}-user-${i+1}`,
|
||||
email: `${segmentValue}${i+1}@example.com`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create recipients and queue entries using batch inserts for better performance
|
||||
console.log(`📦 Processing ${recipientsList.length} recipients across ${notificationData.channels.length} channel(s)...`);
|
||||
|
||||
// Prepare batch data for recipients
|
||||
const recipientsData = [];
|
||||
for (const recipient of recipientsList) {
|
||||
for (const channel of notificationData.channels) {
|
||||
recipientsData.push({
|
||||
notification_id: notification.id,
|
||||
user_id: recipient.user_id,
|
||||
email: channel === 'email' ? recipient.email : null,
|
||||
channel_type: channel,
|
||||
status: 'pending',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Batch insert all recipients at once
|
||||
await tx.notification_recipients.createMany({
|
||||
data: recipientsData,
|
||||
});
|
||||
|
||||
// Fetch created recipients to get their IDs for queue
|
||||
const createdRecipients = await tx.notification_recipients.findMany({
|
||||
where: { notification_id: notification.id },
|
||||
orderBy: { id: 'asc' }
|
||||
});
|
||||
|
||||
// Determine when this notification should be scheduled
|
||||
let scheduledFor;
|
||||
if (notificationData.deliveryType === 'immediate') {
|
||||
scheduledFor = new Date(Date.now() + 60000); // 1 minute from now
|
||||
} else if (notificationData.deliveryType === 'scheduled' && notificationData.scheduledAt) {
|
||||
scheduledFor = new Date(notificationData.scheduledAt);
|
||||
} else {
|
||||
scheduledFor = new Date(Date.now() + 300000); // Fallback - 5 minutes from now
|
||||
}
|
||||
|
||||
const priority = notificationData.priority === 'critical' ? 1
|
||||
: notificationData.priority === 'high' ? 2
|
||||
: notificationData.priority === 'medium' ? 3
|
||||
: 5;
|
||||
|
||||
// Prepare batch data for queue
|
||||
const queueData = createdRecipients.map(rec => ({
|
||||
notification_id: notification.id,
|
||||
recipient_id: rec.id,
|
||||
scheduled_for: scheduledFor,
|
||||
priority,
|
||||
status: 'queued',
|
||||
}));
|
||||
|
||||
// Batch insert all queue items at once
|
||||
await tx.notification_queue.createMany({
|
||||
data: queueData,
|
||||
});
|
||||
|
||||
console.log(`✅ Created ${createdRecipients.length} recipients and queue items in batch`);
|
||||
|
||||
return {
|
||||
id: notification.id,
|
||||
estimatedReach,
|
||||
recipientsCreated: createdRecipients.length,
|
||||
};
|
||||
});
|
||||
|
||||
// If this is an immediate notification, trigger background queue processing (non-blocking)
|
||||
if (notificationData.deliveryType === "immediate") {
|
||||
// Don't await - let it run in background
|
||||
processEmailQueue().catch(err => {
|
||||
console.error('Background queue processing error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Return success response
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: result.id,
|
||||
message:
|
||||
notificationData.deliveryType === "immediate"
|
||||
? "Notification queued for immediate delivery"
|
||||
: "Notification has been scheduled",
|
||||
estimatedReach: result.estimatedReach,
|
||||
recipientsQueued: result.recipientsCreated || 0,
|
||||
queueStatus: "Recipients have been added to the notification queue",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Notification creation error:", error);
|
||||
|
||||
// Handle Prisma errors
|
||||
if (error.code && error.code.startsWith('P')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Database operation failed",
|
||||
data: {
|
||||
error: error.message,
|
||||
code: error.code
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle known errors with status codes
|
||||
if (error.statusCode) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Generic server error
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Failed to create notification",
|
||||
data: {
|
||||
error: error.message
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
}
|
||||
});
|
||||
|
||||
161
server/api/notifications/list.get.js
Normal file
161
server/api/notifications/list.get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
44
server/api/notifications/logs/[id].get.js
Normal file
44
server/api/notifications/logs/[id].get.js
Normal 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 }
|
||||
}
|
||||
}
|
||||
})
|
||||
492
server/api/notifications/logs/analytics.get.js
Normal file
492
server/api/notifications/logs/analytics.get.js
Normal 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`
|
||||
}
|
||||
234
server/api/notifications/logs/index.get.js
Normal file
234
server/api/notifications/logs/index.get.js
Normal 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
|
||||
};
|
||||
});
|
||||
}
|
||||
260
server/api/notifications/logs/monitoring.get.js
Normal file
260
server/api/notifications/logs/monitoring.get.js
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
95
server/api/notifications/queue/clear.post.js
Normal file
95
server/api/notifications/queue/clear.post.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
187
server/api/notifications/queue/history.get.js
Normal file
187
server/api/notifications/queue/history.get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
118
server/api/notifications/queue/jobs.get.js
Normal file
118
server/api/notifications/queue/jobs.get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
144
server/api/notifications/queue/performance.get.js
Normal file
144
server/api/notifications/queue/performance.get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
31
server/api/notifications/queue/process.post.js
Normal file
31
server/api/notifications/queue/process.post.js
Normal 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 },
|
||||
});
|
||||
}
|
||||
});
|
||||
84
server/api/notifications/queue/retry/[id].post.js
Normal file
84
server/api/notifications/queue/retry/[id].post.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
73
server/api/notifications/queue/retry/all.post.js
Normal file
73
server/api/notifications/queue/retry/all.post.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
118
server/api/notifications/queue/retry/jobs.get.js
Normal file
118
server/api/notifications/queue/retry/jobs.get.js
Normal 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";
|
||||
}
|
||||
73
server/api/notifications/queue/retry/stats.get.js
Normal file
73
server/api/notifications/queue/retry/stats.get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
52
server/api/notifications/queue/stats.get.js
Normal file
52
server/api/notifications/queue/stats.get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
84
server/api/notifications/queue/status.get.js
Normal file
84
server/api/notifications/queue/status.get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
38
server/api/notifications/segments.get.js
Normal file
38
server/api/notifications/segments.get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
130
server/api/notifications/templates/[id].delete.js
Normal file
130
server/api/notifications/templates/[id].delete.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
134
server/api/notifications/templates/[id].get.js
Normal file
134
server/api/notifications/templates/[id].get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
308
server/api/notifications/templates/[id].put.js
Normal file
308
server/api/notifications/templates/[id].put.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
185
server/api/notifications/templates/[id]/duplicate.post.js
Normal file
185
server/api/notifications/templates/[id]/duplicate.post.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
149
server/api/notifications/templates/[id]/versions.get.js
Normal file
149
server/api/notifications/templates/[id]/versions.get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
}
|
||||
});
|
||||
231
server/api/notifications/templates/create.post.js
Normal file
231
server/api/notifications/templates/create.post.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
64
server/api/notifications/templates/index.get.js
Normal file
64
server/api/notifications/templates/index.get.js
Normal 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 {
|
||||
}
|
||||
});
|
||||
161
server/api/notifications/test-send.post.js
Normal file
161
server/api/notifications/test-send.post.js
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user